pyrtan / sourcev0.19.0 · main

pyrtan / src / pyrtan / js / pyrtan.js

111,099 bytes · last changed 2026-10-28 in Merge main (LAN discovery 0.16.0, exchange rates and synonyms, chat links 0.17.0) into agents; agents are release 0.18.0

1/* pyrtan.js: the tree runtime, in JavaScript.
2 *
3 * The same tree files the Python runtime reads (as JSON, the TOML's own
4 * table), walked the same way: observe, act, decide, set, each, delegate,
5 * ask, done, fail; the same templates, conditions and extraction; the same
6 * trace. tests/conformance holds the vectors both runtimes must agree on.
7 *
8 * What differs, and is written down here rather than hidden:
9 * - `float` in a condition's `type` means any number here; JSON has no
10 * distinct float, so 4.0 and 4 are the same value.
11 * - HTML is read by a port of the Python runtime's own parser and selector
12 * subset, not the host's DOM, so a page reads the same everywhere; named
13 * character references beyond the common ones are left as written.
14 * - Regular expressions are the host's; the subset trees use is common.
15 * - Sentences are read here as there (nlp.py, intent.py and the proposal
16 * from grow.py): a runner reads among the trees it holds and asks its
17 * resolver (a member's hub) only when none answers. Python's \d takes
18 * any script's digits; here a digit is 0-9 (fold makes full-width ones
19 * plain first).
20 * - LocalHub makes a page a hub of its own: the paths and answers of a
21 * service, in memory, for a browser that has joined no hub.
22 * - `grow` drafts the branch a gap asks for by grow.py's rules, and checks
23 * an agent's suggestion as suggestions.py does; it never asks a model.
24 *
25 * No model. No dependencies. Runs in a browser as `Pyrtan` and in node.
26 */
27(function (root, factory) {
28 if (typeof module === "object" && module.exports) module.exports = factory();
29 else root.Pyrtan = factory();
30})(typeof globalThis !== "undefined" ? globalThis : this, function () {
31 "use strict";
32
33 const VERSION = "0.10.0";
34 const MAX_STEPS = 500, MAX_DEPTH = 8, DEFAULT_TIMEOUT = 10.0, DEFAULT_RETRIES = 1;
35 const MISSING = Object.freeze({ toString: () => "<missing>", missing: true });
36 const isMissing = (v) => v === MISSING;
37 const isObj = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
38 const isBool = (v) => typeof v === "boolean";
39 const isNum = (v) => typeof v === "number" && !Number.isNaN(v);
40
41 class TemplateError extends Error {}
42 class TreeError extends Error {}
43 class TransportError extends Error {}
44 class ConditionError extends Error {}
45
46 // ------------------------------------------------------------ python-ish text
47 // Summaries and the chosen branch in a trace read like the Python runtime's,
48 // so both runtimes' traces compare as text.
49 function pyrepr(v) {
50 if (v === null || v === undefined || isMissing(v)) return "None";
51 if (isBool(v)) return v ? "True" : "False";
52 if (isNum(v)) return Number.isInteger(v) ? String(v) : String(v);
53 if (typeof v === "string") {
54 const q = v.includes("'") && !v.includes('"') ? '"' : "'";
55 const body = v.replace(/\\/g, "\\\\").replace(new RegExp(q, "g"), "\\" + q).replace(/\n/g, "\\n").replace(/\r/g, "\\r").replace(/\t/g, "\\t");
56 return q + body + q;
57 }
58 if (Array.isArray(v)) return "[" + v.map(pyrepr).join(", ") + "]";
59 if (isObj(v)) return "{" + Object.keys(v).map((k) => pyrepr(k) + ": " + pyrepr(v[k])).join(", ") + "}";
60 return String(v);
61 }
62 function pyJson(v) { // json.dumps(v, ensure_ascii=False): ", " and ": " separators
63 if (v === null || v === undefined || isMissing(v)) return "null";
64 if (isBool(v)) return v ? "true" : "false";
65 if (isNum(v)) return Number.isInteger(v) ? String(v) : String(v);
66 if (typeof v === "string") return JSON.stringify(v);
67 if (Array.isArray(v)) return "[" + v.map(pyJson).join(", ") + "]";
68 if (isObj(v)) return "{" + Object.keys(v).map((k) => JSON.stringify(k) + ": " + pyJson(v[k])).join(", ") + "}";
69 return JSON.stringify(v);
70 }
71 function brief(v, limit = 80) {
72 if (isMissing(v)) return "<missing>";
73 const text = typeof v === "string" ? v : pyrepr(v);
74 return text.length <= limit ? text : text.slice(0, limit - 1) + "…";
75 }
76 function short(v, limit = 200) {
77 if (typeof v === "string" && v.length > limit) return v.slice(0, limit) + "…";
78 if (Array.isArray(v) || isObj(v)) { const t = pyJson(v); if (t.length > limit) return t.slice(0, limit) + "…"; }
79 return v;
80 }
81 const condText = (cond) => Object.keys(cond).map((k) => `${k} ${pyrepr(cond[k])}`).join(" and ");
82 // A tree's regular expressions are written for Python; the inline flags
83 // (?i) (?s) (?m) at the start become JavaScript flags.
84 function pyRegex(pattern, flags = "") {
85 let src = String(pattern), fl = flags;
86 const m = /^\(\?([imsx]+)\)/.exec(src);
87 if (m) { src = src.slice(m[0].length); for (const f of m[1]) if ("ims".includes(f) && !fl.includes(f)) fl += f; }
88 return new RegExp(src, fl);
89 }
90
91 // ------------------------------------------------------------ templates
92 const PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
93 const PATH_TOKEN = /([A-Za-z_][A-Za-z0-9_\-]*)|\[(-?\d+)\]|\["([^"]*)"\]|\['([^']*)'\]/y;
94
95 function resolve(path, scope) {
96 path = String(path).trim();
97 if (!path) return MISSING;
98 let pos = 0, cur = scope;
99 while (pos < path.length) {
100 if (path[pos] === ".") { pos++; continue; }
101 PATH_TOKEN.lastIndex = pos;
102 const m = PATH_TOKEN.exec(path);
103 if (!m) throw new TemplateError(`bad path ${pyrepr(path)} at offset ${pos}`);
104 pos = PATH_TOKEN.lastIndex;
105 const [, name, index, dq, sq] = m;
106 if (name !== undefined) cur = getKey(cur, name);
107 else if (index !== undefined) cur = getIndex(cur, parseInt(index, 10));
108 else cur = getKey(cur, dq !== undefined ? dq : sq);
109 if (isMissing(cur)) return MISSING;
110 }
111 return cur;
112 }
113 function getKey(cur, key) {
114 if (isObj(cur)) return Object.prototype.hasOwnProperty.call(cur, key) ? cur[key] : MISSING;
115 if (Array.isArray(cur) && /^\d+$/.test(key)) return getIndex(cur, parseInt(key, 10));
116 return MISSING;
117 }
118 function getIndex(cur, index) {
119 if (Array.isArray(cur) || typeof cur === "string") {
120 const i = index < 0 ? cur.length + index : index;
121 if (i < 0 || i >= cur.length) return MISSING;
122 return typeof cur === "string" ? cur[i] : cur[i];
123 }
124 return MISSING;
125 }
126 const pyQuote = (s) => encodeURIComponent(String(s)).replace(/[!'()*]/g, (c) => "%" + c.charCodeAt(0).toString(16).toUpperCase());
127 const asText = (v) => (Array.isArray(v) || isObj(v) ? pyJson(v) : pyStr(v)); // a value as it reads inside a sentence
128 const pyStr = (v) => (typeof v === "string" ? v : isBool(v) ? (v ? "True" : "False") : v === null || v === undefined ? "None" : Array.isArray(v) || isObj(v) ? pyrepr(v) : String(v));
129 const FILTERS = {
130 url: (v) => pyQuote(pyStr(v)),
131 json: (v) => pyJson(v),
132 int: (v) => { const n = Number(typeof v === "string" ? v.trim() : v); if (Number.isNaN(n)) throw new TemplateError(`int: ${pyrepr(v)}`); return Math.trunc(n); },
133 float: (v) => { const n = Number(typeof v === "string" ? v.trim() : v); if (Number.isNaN(n)) throw new TemplateError(`float: ${pyrepr(v)}`); return n; },
134 str: (v) => pyStr(v),
135 lower: (v) => pyStr(v).toLowerCase(),
136 upper: (v) => pyStr(v).toUpperCase(),
137 strip: (v) => pyStr(v).trim(),
138 first: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[0] : MISSING),
139 last: (v) => ((Array.isArray(v) || typeof v === "string") && v.length ? v[v.length - 1] : MISSING),
140 length: (v) => (Array.isArray(v) || typeof v === "string" ? v.length : isObj(v) ? Object.keys(v).length : MISSING),
141 join: (v) => (Array.isArray(v) ? v.map(asText).join(", ") : asText(v)),
142 title: (v) => pyStr(v).toLowerCase().replace(/(^|[^a-z0-9'])([a-z])/gi, (m, pre, c) => pre + c.toUpperCase()).replace(/'([a-z])/gi, (m, c) => "'" + c.toUpperCase()),
143 };
144 function renderValue(expr, scope) {
145 const parts = expr.split("|").map((p) => p.trim());
146 let value = resolve(parts[0], scope);
147 for (const name of parts.slice(1)) {
148 if (!(name in FILTERS)) throw new TemplateError(`unknown filter ${pyrepr(name)} in {{ ${expr} }}`);
149 if (isMissing(value)) continue;
150 value = FILTERS[name](value);
151 }
152 return value;
153 }
154 function render(template, scope, strict = true) {
155 if (typeof template === "string") {
156 const trimmed = template.trim();
157 const whole = /^\{\{\s*([^{}]*?)\s*\}\}$/.exec(trimmed);
158 if (whole && trimmed === template) {
159 const value = renderValue(whole[1], scope);
160 if (isMissing(value)) { if (strict) throw new TemplateError(`${template} resolves to nothing`); return null; }
161 return value;
162 }
163 return template.replace(PLACEHOLDER, (m, expr) => {
164 const value = renderValue(expr, scope);
165 if (isMissing(value)) { if (strict) throw new TemplateError(`${m} resolves to nothing`); return ""; }
166 return asText(value);
167 });
168 }
169 if (Array.isArray(template)) return template.map((t) => render(t, scope, strict));
170 if (isObj(template)) { const out = {}; for (const k of Object.keys(template)) out[k] = render(template[k], scope, strict); return out; }
171 return template;
172 }
173
174 // ------------------------------------------------------------ conditions
175 function num(v) {
176 if (isBool(v)) return null;
177 if (isNum(v)) return v;
178 if (typeof v === "string") { const t = v.trim(); if (t === "" || Number.isNaN(Number(t))) return null; return Number(t); }
179 return null;
180 }
181 function looseEq(a, b) {
182 if (isBool(a) || isBool(b)) return isBool(a) && isBool(b) && a === b;
183 if (a === b) return true;
184 if (Array.isArray(a) && Array.isArray(b)) return pyJson(a) === pyJson(b);
185 if (isObj(a) && isObj(b)) return pyJson(sortKeys(a)) === pyJson(sortKeys(b));
186 if ((a === null || a === undefined) && (b === null || b === undefined)) return true;
187 const na = num(a), nb = num(b);
188 if (na !== null && nb !== null && (typeof a === "string" || typeof b === "string")) return na === nb;
189 return false;
190 }
191 function sortKeys(o) { const out = {}; for (const k of Object.keys(o).sort()) out[k] = isObj(o[k]) ? sortKeys(o[k]) : o[k]; return out; }
192 const isEmpty = (v) => isMissing(v) || v === null || v === undefined || ((typeof v === "string" || Array.isArray(v)) && v.length === 0) || (isObj(v) && Object.keys(v).length === 0);
193 function typeIs(name, v) {
194 switch (name) {
195 case "str": case "string": return typeof v === "string";
196 case "int": return isNum(v) && Number.isInteger(v);
197 case "float": case "number": return isNum(v);
198 case "bool": return isBool(v);
199 case "list": return Array.isArray(v);
200 case "dict": case "map": return isObj(v);
201 case "null": case "none": return v === null || v === undefined;
202 default: throw new ConditionError(`unknown type ${pyrepr(name)}`);
203 }
204 }
205 function test(key, expected, value) {
206 if (key === "exists") return (!isMissing(value) && value !== null && value !== undefined) === Boolean(expected);
207 if (key === "empty") return isEmpty(value) === Boolean(expected);
208 if (isMissing(value)) return false;
209 switch (key) {
210 case "eq": return looseEq(value, expected);
211 case "ne": return !looseEq(value, expected);
212 case "lt": case "lte": case "gt": case "gte": {
213 const a = num(value), b = num(expected);
214 if (a === null || b === null) return false;
215 return key === "lt" ? a < b : key === "lte" ? a <= b : key === "gt" ? a > b : a >= b;
216 }
217 case "between": { const a = num(value), lo = num(expected[0]), hi = num(expected[1]); if (a === null || lo === null || hi === null) return false; return lo <= a && a <= hi; }
218 case "matches": return value !== null && value !== undefined && pyRegex(expected).test(pyStr(value));
219 case "contains":
220 if (Array.isArray(value)) return value.some((item) => looseEq(item, expected));
221 if (isObj(value)) return Object.prototype.hasOwnProperty.call(value, expected);
222 return pyStr(value).includes(pyStr(expected));
223 case "startswith": return pyStr(value).startsWith(pyStr(expected));
224 case "endswith": return pyStr(value).endsWith(pyStr(expected));
225 case "in": return expected.some((option) => looseEq(value, option));
226 case "type": return typeIs(expected, value);
227 default: throw new ConditionError(`unknown condition key ${pyrepr(key)}`);
228 }
229 }
230 const holds = (cond, value) => Object.keys(cond).every((k) => test(k, cond[k], value));
231
232 // ------------------------------------------------------------ html
233 // The Python runtime's own document model and selector subset (html.py),
234 // here too, so a page reads the same on a service and on a member, and
235 // the same in node: tag, #id, .class, [attr], [attr=v], [attr^=v],
236 // [attr$=v], [attr*=v], :first-child, :last-child, :nth-child(n),
237 // :contains(text), descendant and > combinators, and , groups.
238 const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
239 const INLINE = new Set(["a", "abbr", "b", "bdi", "bdo", "cite", "code", "data", "dfn", "em", "i", "kbd", "label", "mark", "q", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "del", "ins"]);
240 const RAW_TEXT = new Set(["script", "style"]);
241 const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " ", copy: "©", reg: "®", trade: "™", hellip: "…", mdash: "—", ndash: "–", laquo: "«", raquo: "»", lsquo: "‘", rsquo: "’", ldquo: "“", rdquo: "”", bull: "•", middot: "·", deg: "°", euro: "€", pound: "£", yen: "¥", cent: "¢", times: "×", divide: "÷", plusmn: "±", frac12: "½", eacute: "é", egrave: "è", agrave: "à", ccedil: "ç", ouml: "ö", auml: "ä", uuml: "ü", szlig: "ß", ntilde: "ñ", aring: "å", oslash: "ø", aelig: "æ" };
242 function unescapeHtml(text) {
243 if (!text.includes("&")) return text;
244 return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);?/g, (m, body) => {
245 if (body[0] === "#") { const code = body[1] === "x" || body[1] === "X" ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10); return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m; }
246 return body in ENTITIES ? ENTITIES[body] : m;
247 });
248 }
249 class Element {
250 constructor(tag, attrs, parent) { this.tag = tag; this.attrs = attrs || {}; this.children = []; this.parent = parent || null; }
251 get text() {
252 const parts = [];
253 for (const child of this.children) {
254 if (typeof child === "string") parts.push(child);
255 else parts.push(INLINE.has(child.tag) ? child.text : " " + child.text + " ");
256 }
257 return parts.join("").replace(/\s+/g, " ").trim();
258 }
259 get classes() { return (this.attrs.class || "").split(/\s+/).filter(Boolean); }
260 getAttribute(name) { return name in this.attrs ? this.attrs[name] : null; }
261 elements() { return this.children.filter((c) => typeof c !== "string"); }
262 *descendants() { for (const child of this.elements()) { yield child; yield* child.descendants(); } }
263 select(selector) { return select(this, selector); }
264 }
265 const ATTR = /\s*([^\s"'>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/y;
266 function parseHtml(text) {
267 const root = new Element("document");
268 const stack = [root];
269 let pos = 0;
270 const n = text.length;
271 const addText = (t) => { if (t) stack[stack.length - 1].children.push(unescapeHtml(t)); };
272 while (pos < n) {
273 const lt = text.indexOf("<", pos);
274 if (lt < 0) { addText(text.slice(pos)); break; }
275 addText(text.slice(pos, lt));
276 if (text.startsWith("<!--", lt)) { const e = text.indexOf("-->", lt + 4); pos = e < 0 ? n : e + 3; continue; }
277 if (text[lt + 1] === "!" || text[lt + 1] === "?") { const e = text.indexOf(">", lt); pos = e < 0 ? n : e + 1; continue; }
278 if (text[lt + 1] === "/") {
279 const e = text.indexOf(">", lt);
280 const tag = text.slice(lt + 2, e < 0 ? n : e).trim().toLowerCase();
281 pos = e < 0 ? n : e + 1;
282 for (let i = stack.length - 1; i > 0; i--) if (stack[i].tag === tag) { stack.length = i; break; }
283 continue;
284 }
285 const m = /^<([a-zA-Z][^\s\/>]*)/.exec(text.slice(lt, lt + 200));
286 if (!m) { addText("<"); pos = lt + 1; continue; }
287 const tag = m[1].toLowerCase();
288 let p = lt + m[0].length;
289 const attrs = {};
290 while (p < n) {
291 const ws = /\s*/y; ws.lastIndex = p; ws.exec(text); p = ws.lastIndex;
292 if (text[p] === ">" || text.startsWith("/>", p) || p >= n) break;
293 ATTR.lastIndex = p;
294 const am = ATTR.exec(text);
295 if (!am) { p++; continue; }
296 p = ATTR.lastIndex;
297 const name = am[1].toLowerCase();
298 if (name !== "/") attrs[name] = unescapeHtml(am[2] !== undefined ? am[2] : am[3] !== undefined ? am[3] : am[4] !== undefined ? am[4] : "");
299 }
300 const selfClosing = text.startsWith("/>", p);
301 pos = p >= n ? n : p + (selfClosing ? 2 : 1);
302 const el = new Element(tag, attrs, stack[stack.length - 1]);
303 stack[stack.length - 1].children.push(el);
304 if (selfClosing || VOID.has(tag)) continue;
305 if (RAW_TEXT.has(tag)) {
306 const close = text.toLowerCase().indexOf("</" + tag, pos);
307 const raw = text.slice(pos, close < 0 ? n : close);
308 if (raw) el.children.push(raw);
309 if (close < 0) { pos = n; continue; }
310 const e = text.indexOf(">", close); pos = e < 0 ? n : e + 1;
311 continue;
312 }
313 stack.push(el);
314 }
315 return root;
316 }
317 class SelectorError extends Error {}
318 const SEL_TOKEN = /([a-zA-Z][a-zA-Z0-9\-]*|\*)|#([\w\-]+)|\.([\w\-]+)|\[([\w\-:]+)(?:([\^$*]?=)(["']?)([^\]"']*)\6)?\]|:(first-child|last-child|nth-child\(\d+\)|contains\((["']?)([^)"']*)\9\))/y;
319 function parseCompound(text) {
320 const simple = { tag: null, id: null, classes: [], attrs: [], pseudos: [] };
321 text = text.trim();
322 if (!text) throw new SelectorError("empty selector part");
323 let pos = 0;
324 while (pos < text.length) {
325 SEL_TOKEN.lastIndex = pos;
326 const m = SEL_TOKEN.exec(text);
327 if (!m) throw new SelectorError(`cannot parse selector near ${pyrepr(text.slice(pos))}`);
328 pos = SEL_TOKEN.lastIndex;
329 if (m[1]) { if (simple.tag) throw new SelectorError(`two tags in one compound selector: ${pyrepr(text)}`); simple.tag = m[1].toLowerCase(); }
330 else if (m[2]) simple.id = m[2];
331 else if (m[3]) simple.classes.push(m[3]);
332 else if (m[4]) simple.attrs.push([m[4].toLowerCase(), m[5] || "", m[7] === undefined ? null : m[7]]);
333 else if (m[8]) simple.pseudos.push(m[8].startsWith("contains(") ? "contains:" + m[10] : m[8]);
334 }
335 return simple;
336 }
337 function compileSelector(selector) {
338 const groups = [];
339 for (let alt of String(selector).split(",")) {
340 alt = alt.trim();
341 if (!alt) throw new SelectorError(`empty alternative in ${pyrepr(selector)}`);
342 const chain = [];
343 let comb = " ";
344 for (const part of alt.split(/\s*(>)\s*|\s+/)) {
345 if (part === undefined || part === "") continue;
346 if (part === ">") { comb = ">"; continue; }
347 chain.push([comb, parseCompound(part)]);
348 comb = " ";
349 }
350 if (!chain.length) throw new SelectorError(`nothing to match in ${pyrepr(selector)}`);
351 groups.push(chain);
352 }
353 return groups;
354 }
355 function simpleMatches(simple, el) {
356 if (simple.tag && simple.tag !== "*" && el.tag !== simple.tag) return false;
357 if (simple.id && el.attrs.id !== simple.id) return false;
358 if (simple.classes.length) { const have = new Set(el.classes); if (!simple.classes.every((c) => have.has(c))) return false; }
359 for (const [name, op, val] of simple.attrs) {
360 if (!(name in el.attrs)) return false;
361 const have = el.attrs[name];
362 if (op === "=" && have !== val) return false;
363 if (op === "^=" && !have.startsWith(val || "")) return false;
364 if (op === "$=" && !have.endsWith(val || "")) return false;
365 if (op === "*=" && !have.includes(val || "")) return false;
366 }
367 for (const p of simple.pseudos) {
368 if (p.startsWith("contains:")) { if (!el.text.includes(p.slice(9))) return false; continue; }
369 const siblings = el.parent ? el.parent.elements() : [el];
370 if (p === "first-child") { if (siblings[0] !== el) return false; }
371 else if (p === "last-child") { if (siblings[siblings.length - 1] !== el) return false; }
372 else if (p.startsWith("nth-child(")) { const k = parseInt(p.slice(10, -1), 10); if (!(k > 0 && k <= siblings.length && siblings[k - 1] === el)) return false; }
373 else throw new SelectorError(`unknown pseudo-class :${p}`);
374 }
375 return true;
376 }
377 function chainMatches(el, chain) {
378 const [comb, simple] = chain[chain.length - 1];
379 if (!simpleMatches(simple, el)) return false;
380 if (chain.length === 1) return true;
381 const rest = chain.slice(0, -1);
382 if (comb === ">") return el.parent !== null && el.parent.tag !== "document" && chainMatches(el.parent, rest);
383 for (let anc = el.parent; anc !== null && anc.tag !== "document"; anc = anc.parent) if (chainMatches(anc, rest)) return true;
384 return false;
385 }
386 function select(rootEl, selector) {
387 const compiled = compileSelector(selector);
388 const out = [];
389 const seen = new Set();
390 for (const el of rootEl.descendants()) for (const chain of compiled) if (chainMatches(el, chain) && !seen.has(el)) { seen.add(el); out.push(el); break; }
391 return out;
392 }
393 const selectAll = select;
394 const textOf = (el) => el.text;
395 const html = { parse: parseHtml, select, compileSelector, Element, SelectorError };
396
397 // ------------------------------------------------------------ extract
398 function extract(entry, source, cache) {
399 if ("json" in entry || "path" in entry) return resolve(entry.json !== undefined ? entry.json : entry.path, source);
400 if ("regex" in entry) {
401 const text = textSource(entry, source);
402 if (isMissing(text)) return MISSING;
403 const re = pyRegex(entry.regex, (entry.dotall ? "s" : "") + "g");
404 const groups = pyRegex(entry.regex + "|").exec("").length - 1;
405 const group = entry.group !== undefined ? entry.group : groups ? 1 : 0;
406 if (entry.all) return Array.from(text.matchAll(re), (m) => m[group]);
407 const m = re.exec(text);
408 return m ? (m[group] === undefined ? null : m[group]) : MISSING;
409 }
410 if ("css" in entry) {
411 const text = textSource(entry, source);
412 if (isMissing(text)) return MISSING;
413 let doc = cache && cache.get(text);
414 if (!doc) { doc = html.parse(text); if (cache) cache.set(text, doc); }
415 const found = selectAll(doc, entry.css);
416 if (entry.all) return found.map((el) => elementValue(entry, el));
417 if (!found.length) return MISSING;
418 return elementValue(entry, found[0]);
419 }
420 throw new Error(`extract entry ${pyrepr(entry.into)} has no way`);
421 }
422 function textSource(entry, source) {
423 const frm = entry.from !== undefined ? entry.from : "body";
424 const value = typeof frm === "string" ? resolve(frm, source) : MISSING;
425 if (isMissing(value) || value === null || value === undefined) return MISSING;
426 return typeof value === "string" ? value : pyStr(value);
427 }
428 function elementValue(entry, el) {
429 if (entry.fields) {
430 const out = {};
431 for (const name of Object.keys(entry.fields)) {
432 const spec = entry.fields[name];
433 const sub = selectAll(el, typeof spec === "string" ? spec : spec.css)[0] || null;
434 if (!sub) out[name] = null;
435 else if (typeof spec !== "string" && "attr" in spec) out[name] = sub.getAttribute(spec.attr);
436 else out[name] = textOf(sub);
437 }
438 return out;
439 }
440 if ("attr" in entry) return el.getAttribute(entry.attr);
441 return textOf(el);
442 }
443
444 // ------------------------------------------------------------ sources and their allow-lists
445 // The other forms of I/O (file, shell, serial, queue, browser) live on a
446 // service; a member refuses them unless a recorded fixture answers for
447 // them. The allow-list checks and the fixture keys are the Python ones.
448 const SOURCES = ["http", "browser", "file", "shell", "serial", "queue"];
449 const LIST_NAMES = ["paths", "commands", "devices", "topics"];
450 const MQTT_PORTS = { mqtt: 1883, mqtts: 8883 };
451 function absolutePath(path) {
452 path = String(path);
453 const parts = path.split("/"), out = [];
454 for (const part of parts) { if (part === "" || part === ".") continue; if (part === "..") out.pop(); else out.push(part); }
455 return (path.startsWith("/") ? "/" : "") + out.join("/");
456 }
457 function globMatch(pattern, text) {
458 let re = "";
459 for (const ch of pattern) re += ch === "*" ? ".*" : ch === "?" ? "." : ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
460 return new RegExp("^" + re + "$").test(text);
461 }
462 function pathAllowed(patterns, path) {
463 const target = absolutePath(path);
464 for (let pat of patterns) {
465 if (pat === "*") return true;
466 pat = absolutePath(pat);
467 if (/[*?[]/.test(pat)) { if (globMatch(pat, target)) return true; }
468 else if (target === pat || target.startsWith(pat.replace(/\/+$/, "") + "/")) return true;
469 }
470 return false;
471 }
472 function commandAllowed(commands, command) {
473 command = String(command);
474 for (const entry of commands) {
475 if (entry === "*") return true;
476 if (entry.includes("/")) { if (absolutePath(command) === absolutePath(entry)) return true; }
477 else if (command === entry || command.split("/").pop() === entry) return true;
478 }
479 return false;
480 }
481 const deviceAllowed = (devices, device) => devices.some((d) => d === "*" || globMatch(d, String(device)));
482 function topicAllowed(topics, topic) {
483 const parts = String(topic).split("/");
484 for (const entry of topics) {
485 if (entry === "*" || entry === "#") return true;
486 const pat = entry.split("/");
487 let ok = true, i = 0;
488 for (; i < pat.length; i++) {
489 const seg = pat[i];
490 if (seg === "#") { ok = ok && i <= parts.length; break; }
491 if (i >= parts.length || (seg !== "+" && seg !== "*" && seg !== parts[i])) { ok = false; break; }
492 }
493 if (i === pat.length) ok = ok && pat.length === parts.length;
494 if (ok) return true;
495 }
496 return false;
497 }
498 function broker(url) {
499 const m = /^(mqtts?):\/\/([^/:?#]+)(?::(\d+))?/.exec(String(url));
500 if (!m) throw new Error(`${pyrepr(String(url))} is not mqtt://host[:port] or mqtts://host[:port]`);
501 return [m[1], m[2], m[3] ? Number(m[3]) : MQTT_PORTS[m[1]]];
502 }
503 const arg = (a) => (typeof a === "string" ? a : pyJson(a));
504 function adapterKey(source, spec) {
505 if (source === "file") return `file ${"write" in spec ? "write " : "append" in spec ? "append " : ""}${spec.path}`;
506 if (source === "shell") return "shell " + [String(spec.command)].concat((spec.args || []).map(arg)).join(" ");
507 if (source === "serial") { const w = spec.write; return `serial ${spec.device}` + (w === undefined || w === null || w === "" ? "" : ` ${JSON.stringify(String(w))}`); }
508 if (source === "queue") return `queue ${"publish" in spec ? "publish " : ""}${spec.url} ${spec.topic}`;
509 if (source === "browser") return `browser ${spec.url}`;
510 throw new Error(source);
511 }
512 function what(source, spec) {
513 if (source === "file") return String(spec.path);
514 if (source === "shell") return [String(spec.command)].concat((spec.args || []).map(arg)).join(" ");
515 if (source === "serial") return String(spec.device);
516 if (source === "queue") return `${spec.topic} at ${spec.url}`;
517 return String(spec.url);
518 }
519 function briefResult(source, observed) {
520 if (source === "http" || source === "browser") return pyStr(observed.status === undefined ? null : observed.status);
521 if (source === "file") return "written" in observed ? `wrote ${observed.written} B` : observed.exists ? `${observed.size} B` : "missing";
522 if (source === "shell") return `exit ${observed.status}`;
523 if (source === "serial") return `${observed.bytes} B`;
524 if (source === "queue") return "published" in observed ? `published ${observed.published} B` : observed.received ? "1 message" : "no message";
525 return "ok";
526 }
527
528 // ------------------------------------------------------------ language (nlp.py, intent.py)
529 // How a sentence finds its tree, the same rules as the Python runtime: the
530 // same tables, the same rewrites in the same order, the same regular
531 // expression built from each trigger, the same verdicts and notes, so a
532 // browser reads a sentence the way a hub does.
533 const CONTRACTIONS = {
534 "what's": "what is", "whats": "what is", "where's": "where is", "who's": "who is", "how's": "how is",
535 "when's": "when is", "why's": "why is", "it's": "it is", "that's": "that is", "there's": "there is",
536 "here's": "here is", "let's": "let us", "isn't": "is not", "aren't": "are not", "wasn't": "was not",
537 "weren't": "were not", "don't": "do not", "doesn't": "does not", "didn't": "did not", "can't": "can not",
538 "cannot": "can not", "couldn't": "could not", "won't": "will not", "wouldn't": "would not",
539 "shouldn't": "should not", "haven't": "have not", "hasn't": "has not", "hadn't": "had not",
540 "i'm": "i am", "i've": "i have", "i'd": "i would", "i'll": "i will", "you're": "you are",
541 "you've": "you have", "you'd": "you would", "you'll": "you will", "we're": "we are", "we've": "we have",
542 "they're": "they are", "they've": "they have", "gimme": "give me", "lemme": "let me", "wanna": "want to",
543 "gonna": "going to", "u": "you", "pls": "please", "plz": "please", "thx": "thanks",
544 };
545 const LEADING_FILLERS = [
546 "hey pyrtan", "hi pyrtan", "hello pyrtan", "ok pyrtan", "okay pyrtan", "pyrtan",
547 "hey", "hi", "hello", "yo", "ok", "okay", "so", "um", "uh", "well", "please", "kindly",
548 "can you", "could you", "would you", "will you", "can you please", "could you please", "would you please",
549 "i want you to", "i need you to", "i would like you to", "i'd like you to",
550 "quick question", "question",
551 ];
552 const TRAILING_FILLERS = ["please", "thanks", "thank you", "for me", "if you can", "if you could", "if possible", "pyrtan"];
553 // Python's sorted(..., key=len, reverse=True) is stable: equal lengths keep their order.
554 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]);
555 const LEADING_SORTED = byLengthDesc(LEADING_FILLERS), TRAILING_SORTED = byLengthDesc(TRAILING_FILLERS);
556 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 };
557 const TENS = { twenty: 20, thirty: 30, forty: 40, fourty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };
558 const SCALES = { hundred: 100, thousand: 1000, million: 1000000, billion: 1000000000 };
559 const has = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
560 const NOT_PLURALS = new Set(["news", "is", "as", "us", "his", "this", "thus", "yes", "plus", "bus", "gas", "was", "has", "its",
561 "status", "bonus", "always", "perhaps", "physics", "series", "species", "means", "analysis", "basis",
562 "lens", "chess", "address", "process", "access", "class", "pass", "less", "unless", "across", "ssl",
563 "https", "dns", "tls", "os", "ios", "chaos", "canvas", "atlas", "virus", "campus", "census", "focus",
564 "iris", "oasis", "crisis", "thesis", "diagnosis", "axis", "texas", "paris", "mars", "venus"]);
565 const WEEKDAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
566 const MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
567 const MONTH_ABBR = {}; MONTHS.forEach((m, i) => { MONTH_ABBR[m.slice(0, 3)] = i + 1; }); MONTH_ABBR.sept = 9;
568 const DURATION_UNITS = {
569 s: 1, sec: 1, secs: 1, second: 1, seconds: 1, m: 60, min: 60, mins: 60, minute: 60, minutes: 60,
570 h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600, d: 86400, day: 86400, days: 86400,
571 w: 604800, wk: 604800, wks: 604800, week: 604800, weeks: 604800,
572 };
573 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;
574 const URL_RE = /^https?:\/\/[^\s<>"']+$/i;
575 const EMAIL_RE = /^[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,63}$/;
576 const isAlpha = (s) => s.length > 0 && /^\p{L}+$/u.test(s);
577 const isUpperChar = (c) => c !== c.toLowerCase() && c === c.toUpperCase();
578 const isLowerWord = (w) => w === w.toLowerCase() && w !== w.toUpperCase();
579 const reEscape = (s) => String(s).replace(/[.*+?^${}()|[\]\\\/]/g, "\\$&");
580 // Python's str() of a float: 2.0, not 2.
581 const pyFloat = (n) => (Number.isInteger(n) && Math.abs(n) < 1e16 ? n.toFixed(1) : String(n));
582 class ParseError extends Error {}
583 class TriggerError extends Error {}
584
585 function fold(text) {
586 text = String(text).normalize("NFKC");
587 text = text.replace(/’/g, "'").replace(/‘/g, "'").replace(/“/g, '"').replace(/”/g, '"');
588 text = text.replace(/[‐-―−]/g, "-");
589 return text.replace(/\s+/gu, " ").trim();
590 }
591 function trim(text) {
592 text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");
593 // A comma between digits (1,000) is part of a number and stays.
594 text = text.replace(/\s*[,;!?]+\s*/g, (m, at, s) => (m === "," && /[0-9]/.test(s[at - 1] || "") && /[0-9]/.test(s[at + 1] || "") ? m : " "));
595 text = text.replace(/(?<=[A-Za-z])\.(?=\s|$)/g, "");
596 return text.replace(/\s+/gu, " ").trim();
597 }
598 function stripFillers(text, keep) {
599 keep = keep || new Set();
600 const dropped = [];
601 let changed = true;
602 while (changed && text) {
603 changed = false;
604 let low = text.toLowerCase();
605 for (const f of LEADING_SORTED) {
606 if ((low === f || low.startsWith(f + " ") || low.startsWith(f + ",")) && !keep.has(f.split(" ")[0])) {
607 dropped.push(text.slice(0, f.length));
608 text = text.slice(f.length).replace(/^[ ,]+/, "");
609 changed = true;
610 break;
611 }
612 }
613 low = text.toLowerCase();
614 for (const f of TRAILING_SORTED) {
615 const words = f.split(" ");
616 if ((low === f || low.endsWith(" " + f) || low.endsWith("," + f)) && !keep.has(words[words.length - 1])) {
617 dropped.push(text.slice(text.length - f.length));
618 text = text.slice(0, text.length - f.length).replace(/[ ,]+$/, "");
619 changed = true;
620 break;
621 }
622 }
623 }
624 return [text, dropped];
625 }
626 function expandContractions(text) {
627 const expanded = [];
628 const out = text.replace(/[A-Za-z]+(?:'[A-Za-z]+)?/g, (word) => {
629 const low = word.toLowerCase().replace(/’/g, "'");
630 if (!has(CONTRACTIONS, low)) return word;
631 const full = CONTRACTIONS[low];
632 expanded.push(`${word}->${full}`);
633 return isLowerWord(word) || !isUpperChar(word[0]) ? full : full[0].toUpperCase() + full.slice(1);
634 });
635 return [out, expanded];
636 }
637 const isNumberWord = (word) => word.toLowerCase().split("-").every((p) => has(UNITS, p) || has(TENS, p) || has(SCALES, p) || p === "dozen");
638 function numberRun(words, start) {
639 let i = start;
640 const first = words[i].toLowerCase();
641 if (first === "a" && i + 1 < words.length && ["dozen", "hundred", "thousand", "million", "billion"].includes(words[i + 1].toLowerCase())) i += 1;
642 else if (!isNumberWord(first)) return start;
643 let end = i;
644 while (i < words.length) {
645 const w = words[i].toLowerCase();
646 if (isNumberWord(w)) { i += 1; end = i; }
647 else if ((w === "and" || w === "a") && i + 1 < words.length && isNumberWord(words[i + 1].toLowerCase())) i += 1;
648 else break;
649 }
650 return end;
651 }
652 function runValue(run) {
653 const words = [];
654 for (const tok of run) words.push(...tok.toLowerCase().split("-"));
655 let total = 0, current = 0, seen = false;
656 for (const w of words) {
657 if (w === "and" || w === "a") { if (w === "a") current = 1; continue; }
658 if (has(UNITS, w)) { current += UNITS[w]; seen = true; }
659 else if (has(TENS, w)) { current += TENS[w]; seen = true; }
660 else if (w === "dozen") { current = (current || 1) * 12; seen = true; }
661 else if (w === "hundred") { current = (current || 1) * 100; seen = true; }
662 else if (has(SCALES, w)) { total += (current || 1) * SCALES[w]; current = 0; seen = true; }
663 else return null;
664 }
665 return seen ? total + current : null;
666 }
667 function wordsToDigits(text) {
668 const words = text.split(" "), out = [], changed = [];
669 let i = 0;
670 while (i < words.length) {
671 const end = numberRun(words, i);
672 if (end > i) {
673 const run = words.slice(i, end), value = runValue(run);
674 if (value !== null) { out.push(String(value)); changed.push(`${run.join(" ")}->${value}`); i = end; continue; }
675 }
676 out.push(words[i]); i += 1;
677 }
678 return [out.join(" "), changed];
679 }
680 function clean(text, opts) {
681 opts = opts || {};
682 const steps = [];
683 const folded = fold(text);
684 if (folded !== String(text).trim()) steps.push("fold");
685 text = trim(folded);
686 if (opts.fillers !== false) {
687 const [t, dropped] = stripFillers(text, opts.keep);
688 text = t;
689 if (dropped.length) { steps.push("fillers: " + dropped.map(pyrepr).join(", ")); text = trim(text); }
690 }
691 const [t2, expanded] = expandContractions(text);
692 text = t2;
693 if (expanded.length) steps.push("contractions: " + expanded.join(", "));
694 if (opts.numbers !== false) {
695 const [t3, changed] = wordsToDigits(text);
696 text = t3;
697 if (changed.length) steps.push("numbers: " + changed.join(", "));
698 }
699 return [text.replace(/\s+/gu, " ").trim(), steps];
700 }
701 function singular(word) {
702 const w = word.toLowerCase();
703 if (w.length < 4 || NOT_PLURALS.has(w) || w.endsWith("ss") || !isAlpha(w)) return w;
704 if (w.endsWith("ies") && w.length > 4) return w.slice(0, -3) + "y";
705 if (["ches", "shes", "sses", "xes", "zes"].some((e) => w.endsWith(e))) return w.slice(0, -2);
706 if (w.endsWith("s")) return w.slice(0, -1);
707 return w;
708 }
709 function plural(word) {
710 const w = word.toLowerCase();
711 if (!isAlpha(w) || NOT_PLURALS.has(w)) return w;
712 if (w.endsWith("y") && w.length > 2 && !"aeiou".includes(w[w.length - 2])) return w.slice(0, -1) + "ies";
713 if (["ch", "sh", "ss", "x", "z", "s"].some((e) => w.endsWith(e))) return w + "es";
714 return w + "s";
715 }
716 function forms(word) {
717 const w = word.toLowerCase(), out = new Set([w]);
718 if (isAlpha(w) && Array.from(w).length >= 3) { const base = singular(w); out.add(base); out.add(plural(base)); }
719 return out;
720 }
721 function distance(a, b, limit = 2) {
722 a = Array.from(a.toLowerCase()); b = Array.from(b.toLowerCase());
723 if (Math.abs(a.length - b.length) > limit) return limit + 1;
724 let prevPrev = null, prev = Array.from({ length: b.length + 1 }, (_, j) => j);
725 for (let i = 1; i <= a.length; i++) {
726 const cur = [i].concat(new Array(b.length).fill(0));
727 for (let j = 1; j <= b.length; j++) {
728 const cost = a[i - 1] === b[j - 1] ? 0 : 1;
729 cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost);
730 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);
731 }
732 if (Math.min(...cur) > limit) return limit + 1;
733 prevPrev = prev; prev = cur;
734 }
735 return prev[prev.length - 1];
736 }
737
738 // ---- values: each typed slot's fixed reader
739 function parseNumber(text) {
740 let [t] = wordsToDigits(text.trim());
741 t = t.replace(/,/g, "");
742 if (/^-?\d+$/.test(t)) return { value: parseInt(t, 10), py: String(parseInt(t, 10)) };
743 if (/^-?\d+\.\d+$/.test(t)) { const v = parseFloat(t); return { value: v, py: pyFloat(v) }; }
744 throw new ParseError(`${pyrepr(text)} is not a number`);
745 }
746 function parseDuration(text) {
747 let [t] = wordsToDigits(text.trim().toLowerCase());
748 t = t.replace(/\bhalf\s+an?\s+([a-z]+)/g, "0.5 $1");
749 t = t.replace(/\b(an?)\s+(?=(?:hour|minute|second|day|week)s?\b)/g, "1 ");
750 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}`);
751 t = t.replace(/\s+and\s+/g, " ");
752 let total = 0, found = false, pos = 0;
753 for (const m of t.matchAll(/(\d+(?:\.\d+)?)\s*([a-z]+)/g)) {
754 if (t.slice(pos, m.index).trim()) throw new ParseError(`${pyrepr(text)} is not a duration`);
755 if (!has(DURATION_UNITS, m[2])) throw new ParseError(`${pyrepr(text)}: unknown unit ${pyrepr(m[2])}`);
756 total += parseFloat(m[1]) * DURATION_UNITS[m[2]];
757 found = true; pos = m.index + m[0].length;
758 }
759 if (!found || t.slice(pos).trim()) throw new ParseError(`${pyrepr(text)} is not a duration`);
760 const v = Math.trunc(total);
761 return { value: v, py: String(v) };
762 }
763 const pad2 = (n) => String(n).padStart(2, "0");
764 function parseTime(text) {
765 let [t] = wordsToDigits(text.trim().toLowerCase());
766 t = t.replace(/\./g, ":").replace(/ /g, "");
767 const done = (s) => ({ value: s, py: s });
768 if (t === "noon" || t === "midday") return done("12:00");
769 if (t === "midnight") return done("00:00");
770 const m = /^(\d{1,2})(?::(\d{2}))?(?:o'?clock)?(am|pm)?$/.exec(t);
771 if (!m) throw new ParseError(`${pyrepr(text)} is not a time`);
772 let hour = parseInt(m[1], 10);
773 const minute = parseInt(m[2] || "0", 10), half = m[3];
774 if (half) {
775 if (!(hour >= 1 && hour <= 12)) throw new ParseError(`${pyrepr(text)}: ${hour} with ${half}`);
776 hour = (hour % 12) + (half === "pm" ? 12 : 0);
777 } 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`);
778 if (!(hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59)) throw new ParseError(`${pyrepr(text)} is not a time`);
779 return done(`${pad2(hour)}:${pad2(minute)}`);
780 }
781 // Dates are plain calendar days in UTC, as the hub's clock gives them.
782 const isLeap = (y) => y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0);
783 const monthDays = (y, m) => [31, isLeap(y) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m - 1];
784 const dayOf = (y, m, d) => { const out = new Date(0); out.setUTCFullYear(y, m - 1, d); return out; };
785 const isoDay = (d) => `${String(d.getUTCFullYear()).padStart(4, "0")}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
786 const addDays = (d, n) => new Date(d.getTime() + n * 86400000);
787 const weekdayOf = (d) => (d.getUTCDay() + 6) % 7;
788 function mkDate(year, month, day, text) {
789 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;
790 if (why) throw new ParseError(text === null ? why : `${pyrepr(text)}: ${why}`);
791 return dayOf(year, month, day);
792 }
793 function shift(day, n, unit) {
794 unit = unit.replace(/s+$/, "");
795 if (unit === "day") return addDays(day, n);
796 if (unit === "week") return addDays(day, 7 * n);
797 const months = day.getUTCMonth() + (unit === "month" ? n : 12 * n);
798 const year = day.getUTCFullYear() + Math.floor(months / 12), month = (((months % 12) + 12) % 12) + 1;
799 return mkDate(year, month, Math.min(day.getUTCDate(), monthDays(year, month)), null);
800 }
801 function monthOf(word) {
802 if (!word || /^\d+$/.test(word)) return null;
803 const i = MONTHS.indexOf(word);
804 if (i >= 0) return i + 1;
805 const three = word.slice(0, 3);
806 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; }
807 return null;
808 }
809 function weekdayIndex(word) {
810 const i = WEEKDAYS.indexOf(word);
811 if (i >= 0) return i;
812 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; }
813 return null;
814 }
815 function parseDate(text, now) {
816 const n = now instanceof Date ? now : new Date();
817 const today = dayOf(n.getUTCFullYear(), n.getUTCMonth() + 1, n.getUTCDate());
818 const done = (d) => { const s = isoDay(d); return { value: s, py: s }; };
819 let [t] = wordsToDigits(text.trim().toLowerCase());
820 t = t.replace(/^(on|at|for|the|this coming)\s+/, "").trim();
821 t = t.replace(/(\d)(st|nd|rd|th)\b/g, "$1");
822 t = t.split(",").join(" ").split(" ").join(" ").trim();
823 let m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(t);
824 if (m) return done(mkDate(+m[1], +m[2], +m[3], text));
825 m = /^(\d{1,2})[/.](\d{1,2})(?:[/.](\d{2,4}))?$/.exec(t);
826 if (m) {
827 const d = +m[1], mo = +m[2];
828 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]}'`);
829 let year = m[3] ? +m[3] : today.getUTCFullYear();
830 if (year < 100) year += 2000;
831 return done(mkDate(year, mo, d, text));
832 }
833 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);
834 if (m && (monthOf(m[1]) || monthOf(m[2]))) {
835 let day, month;
836 if (/^\d+$/.test(m[1])) { day = +m[1]; month = monthOf(m[2]); } else { month = monthOf(m[1]); day = +m[2]; }
837 if (!month) throw new ParseError(`${pyrepr(text)} is not a date`);
838 return done(mkDate(m[3] ? +m[3] : today.getUTCFullYear(), month, day, text));
839 }
840 if (t === "today" || t === "now") return done(today);
841 if (["tomorrow", "tmrw", "tmr"].includes(t)) return done(addDays(today, 1));
842 if (t === "yesterday") return done(addDays(today, -1));
843 if (t === "day after tomorrow" || t === "the day after tomorrow") return done(addDays(today, 2));
844 if (t === "day before yesterday" || t === "the day before yesterday") return done(addDays(today, -2));
845 m = /^(?:(this|next|last)\s+)?([a-z]+)$/.exec(t);
846 if (m && weekdayIndex(m[2]) !== null) {
847 let ahead = (((weekdayIndex(m[2]) - weekdayOf(today)) % 7) + 7) % 7;
848 if (m[1] === "next") ahead = ahead || 7;
849 else if (m[1] === "last") ahead = ahead ? ahead - 7 : -7;
850 return done(addDays(today, ahead));
851 }
852 m = /^in\s+(\d+)\s+(day|days|week|weeks|month|months|year|years)$/.exec(t);
853 if (m) return done(shift(today, +m[1], m[2]));
854 m = /^(\d+)\s+(day|days|week|weeks|month|months|year|years)\s+ago$/.exec(t);
855 if (m) return done(shift(today, -m[1], m[2]));
856 m = /^(next|last)\s+(week|month|year)$/.exec(t);
857 if (m) return done(shift(today, m[1] === "next" ? 1 : -1, m[2]));
858 m = /^end of (?:the |this )?(month|year)$/.exec(t);
859 if (m) {
860 if (m[1] === "year") return done(dayOf(today.getUTCFullYear(), 12, 31));
861 return done(addDays(shift(dayOf(today.getUTCFullYear(), today.getUTCMonth() + 1, 1), 1, "month"), -1));
862 }
863 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));
864 throw new ParseError(`${pyrepr(text)} is not a date this reads`);
865 }
866 function parseHost(text) {
867 const m = HOST_RE.exec(text.trim());
868 if (!m) throw new ParseError(`${pyrepr(text)} is not a host name`);
869 const v = m[1].toLowerCase();
870 return { value: v, py: v };
871 }
872 function parseUrl(text) {
873 const t = text.trim();
874 if (!URL_RE.test(t)) throw new ParseError(`${pyrepr(text)} is not a URL`);
875 return { value: t, py: t };
876 }
877 function parseEmail(text) {
878 const t = text.trim();
879 if (!EMAIL_RE.test(t)) throw new ParseError(`${pyrepr(text)} is not an email address`);
880 return { value: t.toLowerCase(), py: t.toLowerCase() };
881 }
882 const PARSERS = { number: parseNumber, duration: parseDuration, time: parseTime, date: parseDate, host: parseHost, url: parseUrl, email: parseEmail };
883 const nlp = { fold, trim, stripFillers, expandContractions, wordsToDigits, clean, singular, plural, forms, distance, parseNumber, parseDuration, parseTime, parseDate, parseHost, parseUrl, parseEmail, ParseError };
884
885 // ---- triggers
886 const KINDS = ["int", "float", "word", "text", "number", "date", "time", "duration", "host", "url", "email"];
887 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+", "": ".+?" };
888 const TYPO_MIN_LENGTH = 5;
889 function normalise(text) {
890 text = String(text).trim();
891 text = text.replace(/^[^\p{L}\p{N}]+|[\s?!.,;:]+$/gu, "");
892 return text.replace(/\s+/gu, " ");
893 }
894 function language(raw, where = "language") {
895 if (raw === null || raw === undefined) return { synonyms: {}, typos: false };
896 if (!isObj(raw)) throw new TriggerError(`${where} must be a table`);
897 const syn = raw.synonyms === undefined ? {} : raw.synonyms;
898 if (!isObj(syn)) throw new TriggerError(`${where}.synonyms must be a table of word = [words]`);
899 const synonyms = {};
900 for (const [word, a] of Object.entries(syn)) {
901 const alts = typeof a === "string" ? [a] : a;
902 if (!Array.isArray(alts) || !alts.every((x) => typeof x === "string" && x.trim())) throw new TriggerError(`${where}.synonyms.${word} must be a list of words`);
903 if (!/^[A-Za-z][A-Za-z'-]*$/.test(word)) throw new TriggerError(`${where}.synonyms: ${pyrepr(word)} is not a word`);
904 synonyms[word.toLowerCase()] = alts.map((x) => x.trim().toLowerCase());
905 }
906 const typos = raw.typos === undefined ? false : raw.typos;
907 if (!isBool(typos)) throw new TriggerError(`${where}.typos must be true or false`);
908 for (const key of Object.keys(raw)) if (key !== "synonyms" && key !== "typos") throw new TriggerError(`${where}: unknown key ${pyrepr(key)}; one of synonyms, typos`);
909 return { synonyms, typos };
910 }
911 const languageDict = (lang) => { const out = {}; if (Object.keys(lang.synonyms).length) out.synonyms = lang.synonyms; if (lang.typos) out.typos = true; return out; };
912 function wordRegex(word, lang) {
913 const spellings = forms(word), phrases = [];
914 for (const syn of lang.synonyms[word.toLowerCase()] || []) {
915 if (syn.includes(" ")) phrases.push(syn.split(/\s+/).map(reEscape).join("\\s+"));
916 else for (const f of forms(syn)) spellings.add(f);
917 }
918 const sorted = [...spellings].sort((a, b) => b.length - a.length || (a < b ? -1 : a > b ? 1 : 0));
919 return "(?:" + sorted.map(reEscape).concat(phrases).join("|") + ")";
920 }
921 function compilePattern(pattern, lang) {
922 lang = lang || { synonyms: {}, typos: false };
923 if (!pattern || !pattern.trim()) throw new TriggerError("a trigger pattern cannot be empty");
924 const slots = {}, out = [], vocabulary = new Set(), ordered = [];
925 let literal = 0, pos = 0, depth = 0, word = [];
926 const text = Array.from(pattern.trim().toLowerCase());
927 const flush = () => {
928 if (!word.length) return;
929 const w = word.join("");
930 out.push(wordRegex(w, lang)); vocabulary.add(w); ordered.push(w);
931 for (const syn of lang.synonyms[w] || []) for (const s of syn.split(/\s+/)) if (s) vocabulary.add(s);
932 word = [];
933 };
934 while (pos < text.length) {
935 const ch = text[pos];
936 if (isAlpha(ch) || (word.length && "'-".includes(ch) && pos + 1 < text.length && isAlpha(text[pos + 1]))) { word.push(ch); literal++; pos++; continue; }
937 flush();
938 if (ch === "{") {
939 const m = /^\{([A-Za-z_][A-Za-z0-9_]*)(?::([a-z]+))?\}/.exec(text.slice(pos).join(""));
940 if (!m) throw new TriggerError(`bad slot in trigger ${pyrepr(pattern)} at ${pos}`);
941 const name = m[1], kind = m[2] === undefined ? null : m[2];
942 if (kind !== null && !KINDS.includes(kind)) throw new TriggerError(`slot {${name}:${kind}} in ${pyrepr(pattern)}: kind must be one of ${KINDS.join(", ")}`);
943 if (has(slots, name)) throw new TriggerError(`slot ${pyrepr(name)} appears twice in ${pyrepr(pattern)}`);
944 slots[name] = kind;
945 out.push(`(?<${name}>${SLOT_RE[kind === null ? "" : kind]})`);
946 pos += m[0].length;
947 } else if (ch === "[") { depth++; out.push("(?:\\s*"); pos++; }
948 else if (ch === "]") { if (depth === 0) throw new TriggerError(`unbalanced ] in trigger ${pyrepr(pattern)}`); depth--; out.push(")?"); pos++; }
949 else if (ch === "(") { depth++; out.push("(?:"); pos++; }
950 else if (ch === ")") { if (depth === 0) throw new TriggerError(`unbalanced ) in trigger ${pyrepr(pattern)}`); depth--; out.push(")"); pos++; }
951 else if (ch === "|" && depth > 0) { out.push("|"); pos++; }
952 else if (/^\s$/u.test(ch)) {
953 while (pos < text.length && /^\s$/u.test(text[pos])) pos++;
954 // Space is optional around optional groups and slots, so "[the] weather" matches "weather".
955 if (out.length && ["(?:\\s*", ")?", "(?:", ")", "|"].includes(out[out.length - 1])) out.push("\\s*");
956 else if (pos < text.length && "[(|".includes(text[pos])) out.push("\\s*");
957 else out.push("\\s+");
958 } else { out.push(reEscape(ch)); literal++; pos++; }
959 }
960 flush();
961 if (depth !== 0) throw new TriggerError(`unbalanced [ in trigger ${pyrepr(pattern)}`);
962 if (literal === 0) throw new TriggerError(`trigger ${pyrepr(pattern)} has no literal words; it would match anything`);
963 let regex;
964 try { regex = new RegExp("^(?:" + out.join("") + ")$", "iu"); } catch (e) { throw new TriggerError(`trigger ${pyrepr(pattern)} does not compile: ${e.message}`); }
965 return { regex, slots, specificity: literal, vocabulary, edges: ordered.length ? [ordered[0], ordered[ordered.length - 1]] : ["", ""] };
966 }
967 function correctTypos(sentence, vocabulary) {
968 if (!vocabulary.size) return [sentence, []];
969 const fixed = [];
970 const out = sentence.replace(/[A-Za-z]+/g, (word) => {
971 const low = word.toLowerCase();
972 if (low.length < TYPO_MIN_LENGTH || vocabulary.has(low) || [...vocabulary].some((v) => forms(v).has(low))) return word;
973 const close = [...vocabulary].filter((v) => v.length >= TYPO_MIN_LENGTH && distance(low, v, 1) <= 1);
974 if (close.length !== 1) return word;
975 fixed.push([word, close[0]]);
976 return close[0];
977 });
978 return [out, fixed];
979 }
980 function readSlot(kind, raw, now) {
981 if (kind === "int") return { value: parseInt(raw, 10), py: String(parseInt(raw, 10)) };
982 if (kind === "float") { const v = parseFloat(raw); return { value: v, py: pyFloat(v) }; }
983 if (kind === null || kind === "word" || kind === "text") return { value: raw, py: raw };
984 return kind === "date" ? PARSERS.date(raw, now) : PARSERS[kind](raw);
985 }
986 class Trigger {
987 constructor(pattern, fixedInputs, lang) {
988 this.pattern = pattern;
989 this.fixedInputs = fixedInputs || {};
990 this.language = lang && lang.synonyms ? lang : language(lang);
991 // The pattern gets the rewrites a sentence gets, minus fillers and number words.
992 this.cleaned = fold(pattern).split(/(\{[^}]*\})/).map((part) => (part.startsWith("{") ? part : expandContractions(part)[0])).join("");
993 Object.assign(this, compilePattern(this.cleaned, this.language));
994 }
995 match(text, now) { const m = this.matchDetail(text, now); return m ? m.inputs : null; }
996 matchDetail(text, now, cleaned = false) {
997 let sentence = cleaned ? text : clean(text)[0];
998 const notes = [];
999 if (this.language.typos) { const [s, fixed] = correctTypos(sentence, this.vocabulary); sentence = s; for (const [a, b] of fixed) notes.push(`typo: ${a}->${b}`); }
1000 const m = this.regex.exec(sentence);
1001 if (!m) return null;
1002 const inputs = Object.assign({}, this.fixedInputs);
1003 for (const [name, kind] of Object.entries(this.slots)) {
1004 let raw = m.groups[name];
1005 if (raw === undefined) continue;
1006 raw = raw.trim();
1007 let got;
1008 try { got = readSlot(kind, raw, now); }
1009 catch (e) {
1010 if (!(e instanceof ParseError)) throw e;
1011 notes.push(`slot ${name}: ${e.message}`);
1012 return { tree: "", trigger: this, inputs: {}, specificity: this.specificity, notes, rejected: true };
1013 }
1014 inputs[name] = got.value;
1015 if (has(PARSERS, kind) && got.py !== raw) notes.push(`${name}: ${pyrepr(raw)} read as ${typeof got.value === "string" ? pyrepr(got.value) : got.py}`);
1016 }
1017 return { tree: "", trigger: this, inputs, specificity: this.specificity, notes, rejected: false };
1018 }
1019 }
1020 const matchDict = (m) => ({ tree: m.tree, trigger: m.trigger.pattern, specificity: m.specificity, inputs: m.inputs, notes: m.notes, rejected: m.rejected });
1021 function isoNow(d) {
1022 if (!(d instanceof Date)) return null;
1023 const ms = d.getUTCMilliseconds();
1024 return d.toISOString().replace(/\.\d{3}Z$/, (ms ? "." + String(ms).padStart(3, "0") + "000" : "") + "+00:00");
1025 }
1026 class Reading {
1027 constructor(text, cleaned, steps, matches, considered, now) { Object.assign(this, { text, cleaned, steps, matches, considered, now }); }
1028 get winner() { return this.matches.length === 1 ? this.matches[0] : null; }
1029 toDict() { return { text: this.text, cleaned: this.cleaned, steps: this.steps, now: this.now, matches: this.matches.map(matchDict), considered: this.considered.map(matchDict) }; }
1030 step() {
1031 const rewrite = pyrepr(this.text) + (this.cleaned !== this.text ? ` -> ${pyrepr(this.cleaned)}` : "");
1032 let summary;
1033 const w = this.winner;
1034 if (w) { summary = `${rewrite}; ${w.tree} via ${pyrepr(w.trigger.pattern)} (${w.specificity} literal chars)`; if (w.notes.length) summary += "; " + w.notes.join("; "); }
1035 else if (this.matches.length) summary = `${rewrite}; tie between ${pyrepr([...new Set(this.matches.map((m) => m.tree))].sort())}`;
1036 else summary = `${rewrite}; no trigger matched`;
1037 const observed = { cleaned: this.cleaned, steps: this.steps };
1038 if (w) { observed.trigger = w.trigger.pattern; observed.inputs = w.inputs; if (w.notes.length) observed.notes = w.notes; }
1039 return { node: "intent", kind: "intent", summary, observed };
1040 }
1041 }
1042 // candidates: [[treeName, Trigger], ...] in the order a registry lists them (trees by name).
1043 function read(text, candidates, now) {
1044 const keep = new Set();
1045 for (const [, trig] of candidates) for (const w of trig.edges) if (w) keep.add(w);
1046 const [cleaned, steps] = clean(text, { keep });
1047 const considered = [];
1048 for (const [tree, trig] of candidates) { const got = trig.matchDetail(cleaned, now, true); if (got) { got.tree = tree; considered.push(got); } }
1049 const live = considered.filter((m) => !m.rejected);
1050 const top = live.reduce((t, m) => Math.max(t, m.specificity), 0);
1051 return new Reading(text, cleaned, steps, live.filter((m) => m.specificity === top), considered, isoNow(now));
1052 }
1053
1054 // ---- the tree that would answer a sentence nobody answered (grow.py)
1055 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"]);
1056 const NAME_MAX = 40;
1057 function proposalSlot(tok, inputs) {
1058 const bare = tok.replace(/^["'(),;:!?]+|["'(),;:!?]+$/g, "");
1059 const unique = (base) => { if (!has(inputs, base)) return base; let n = 2; while (has(inputs, base + n)) n++; return base + n; };
1060 if (/^-?\d+$/.test(bare)) { const s = unique("count"); inputs[s] = { type: "int", description: `as in: ${bare}` }; return `{${s}:int}`; }
1061 if (/^-?\d+\.\d+$/.test(bare)) { const s = unique("value"); inputs[s] = { type: "float", description: `as in: ${bare}` }; return `{${s}:float}`; }
1062 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}`; }
1063 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}`; }
1064 return null;
1065 }
1066 const proposalLiteral = (tok) => tok.replace(/[{}[\]()|]/g, "").trim();
1067 function shellQuote(s) {
1068 if (/^[\p{L}\p{N}_./:@%+=,-]+$/u.test(s)) return s;
1069 if (!/["$`\\]/.test(s)) return `"${s}"`;
1070 return "'" + s.replace(/'/g, "'\"'\"'") + "'";
1071 }
1072 function proposalName(literalWords, tokens, slots) {
1073 const slug = (w) => w.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1074 let words = literalWords.filter((w) => !PROPOSE_FILLER.has(w.toLowerCase())).map(slug).filter(Boolean);
1075 if (words.length < 2 && slots.length) words = slots.map((s) => s.replace(/\d+$/, "")).concat(words);
1076 if (!words.length) words = tokens.map(slug).filter(Boolean);
1077 let name = words.join("-").slice(0, NAME_MAX).replace(/^-+|-+$/g, "");
1078 name = name.replace(/-{2,}/g, "-");
1079 if (!name || !/^[a-z0-9]/.test(name)) name = "new-tree";
1080 return name;
1081 }
1082 function propose(text) {
1083 const cleanText = normalise(text) || String(text).trim() || "new tree";
1084 const tokens = cleanText.split(" ");
1085 const pieces = [], literalWords = [];
1086 let inputs = {};
1087 for (const tok of tokens) {
1088 const slot = proposalSlot(tok, inputs);
1089 if (slot !== null) pieces.push(slot); else { pieces.push(proposalLiteral(tok)); literalWords.push(tok); }
1090 }
1091 let trigger = pieces.filter(Boolean).join(" "), words = literalWords;
1092 try { new Trigger(trigger); }
1093 catch (e) {
1094 if (!(e instanceof TriggerError)) throw e;
1095 trigger = tokens.map(proposalLiteral).filter(Boolean).join(" ") || "new tree";
1096 inputs = {}; words = tokens;
1097 }
1098 const name = proposalName(words, tokens, Object.keys(inputs));
1099 return { text: cleanText, name, trigger, inputs, scaffold: ["pyrtan", "new", name, "--from", shellQuote(cleanText)].join(" ") };
1100 }
1101 function noMatchOutcome(text, where) {
1102 const p = propose(text);
1103 return { say: `${where} answers ${pyrepr(text)}. To write the tree that would: ${p.scaffold}`, output: { unanswered: text, proposal: p } };
1104 }
1105 const intent = { Trigger, TriggerError, read, normalise, language, compilePattern, correctTypos, propose, noMatchOutcome, KINDS };
1106
1107 // ------------------------------------------------------------ trees
1108 const INPUT_TYPES = ["str", "int", "float", "bool", "list", "dict"];
1109 function inferType(v) { return isBool(v) ? "bool" : isNum(v) ? (Number.isInteger(v) ? "int" : "float") : Array.isArray(v) ? "list" : isObj(v) ? "dict" : "str"; }
1110 function coerce(spec, value) {
1111 const bad = () => { throw new TreeError(`input ${pyrepr(spec.name)} must be ${spec.type}, got ${pyrepr(value)}`); };
1112 if (spec.type === "int") { if (isBool(value)) bad(); const n = Number(typeof value === "string" ? value.trim() : value); if (Number.isNaN(n) || (typeof value === "string" && !/^-?\d+$/.test(value.trim()))) bad(); value = Math.trunc(n); }
1113 else if (spec.type === "float") { const n = Number(typeof value === "string" ? value.trim() : value); if (Number.isNaN(n) || isBool(value)) bad(); value = n; }
1114 else if (spec.type === "bool") {
1115 if (typeof value === "string") { const low = value.trim().toLowerCase(); if (["true", "yes", "1", "on"].includes(low)) value = true; else if (["false", "no", "0", "off"].includes(low)) value = false; else bad(); }
1116 else value = Boolean(value);
1117 }
1118 else if (spec.type === "str") value = pyStr(value);
1119 else if (spec.type === "list" && !Array.isArray(value)) bad();
1120 else if (spec.type === "dict" && !isObj(value)) bad();
1121 if (spec.min !== undefined && spec.min !== null && isNum(value) && value < spec.min) throw new TreeError(`input ${pyrepr(spec.name)} must be at least ${spec.min}, got ${value}`);
1122 if (spec.max !== undefined && spec.max !== null && isNum(value) && value > spec.max) throw new TreeError(`input ${pyrepr(spec.name)} must be at most ${spec.max}, got ${value}`);
1123 if (spec.one_of && !spec.one_of.some((o) => looseEq(o, value) && typeof o === typeof value)) throw new TreeError(`input ${pyrepr(spec.name)} must be one of ${pyrepr(spec.one_of)}, got ${pyrepr(value)}`);
1124 if (spec.pattern && typeof value === "string" && !pyRegex("^(?:" + String(spec.pattern).replace(/^\(\?[imsx]+\)/, (f) => { spec._flags = f; return ""; }) + ")$", (spec._flags || "").replace(/[()?x]/g, "")).test(value)) throw new TreeError(`input ${pyrepr(spec.name)} must match ${pyrepr(spec.pattern)}, got ${pyrepr(value)}`);
1125 return value;
1126 }
1127 class Tree {
1128 constructor(raw) {
1129 this.raw = raw;
1130 this.name = raw.name; this.version = raw.version || 1; this.description = raw.description || "";
1131 this.hosts = raw.hosts || []; this.start = raw.start; this.tags = raw.tags || []; this.license = raw.license || ""; this.namespace = raw.namespace || "";
1132 for (const k of LIST_NAMES) this[k] = raw[k] || [];
1133 try { this.language = language(raw.language); } catch (e) { if (e instanceof TriggerError) throw new TreeError(`${this.name}: ${e.message}`); throw e; }
1134 this.triggers = (raw.triggers || []).map((t, i) => {
1135 if (typeof t === "string") t = { pattern: t };
1136 if (!isObj(t) || !("pattern" in t)) throw new TreeError(`${this.name}: triggers[${i}] needs a 'pattern'`);
1137 try { return new Trigger(t.pattern, Object.assign({}, t.inputs || {}), this.language); }
1138 catch (e) { if (e instanceof TriggerError) throw new TreeError(`${this.name}: triggers[${i}]: ${e.message}`); throw e; }
1139 });
1140 this.inputs = {};
1141 for (const [name, s] of Object.entries(raw.inputs || {})) {
1142 const spec = isObj(s) ? s : { default: s };
1143 this.inputs[name] = { name, type: spec.type || inferType(spec.default), default: spec.default === undefined ? null : spec.default, required: spec.required !== undefined ? Boolean(spec.required) : spec.default === undefined, description: spec.description || "", min: spec.min, max: spec.max, one_of: spec.one_of, pattern: spec.pattern };
1144 }
1145 this.nodes = {};
1146 for (const [id, spec] of Object.entries(raw.nodes || {})) this.nodes[id] = { id, kind: spec.kind, spec, get: (k, d) => (spec[k] !== undefined ? spec[k] : d), next: spec.next };
1147 }
1148 node(id) { const n = this.nodes[id]; if (!n) throw new TreeError(`${this.name}: no node ${pyrepr(id)}`); return n; }
1149 get id() { return this.namespace ? `${this.namespace}/${this.name}` : this.name; }
1150 hostAllowed(url) {
1151 let host = "";
1152 try { host = new URL(url).hostname.toLowerCase(); } catch (e) { return false; }
1153 if (!host) return false;
1154 for (let allowed of this.hosts) {
1155 allowed = allowed.toLowerCase();
1156 if (allowed === "*") return true;
1157 if (allowed.startsWith("*.")) { const suffix = allowed.slice(1); if (host.endsWith(suffix) || host === allowed.slice(2)) return true; }
1158 else if (host === allowed) return true;
1159 }
1160 return false;
1161 }
1162 allowed(source, spec) {
1163 if (source === "http" || source === "browser") { const url = String(spec.url === undefined ? "" : spec.url); return this.hostAllowed(url) ? null : ["host-not-allowed", `${url} is outside this tree's hosts ${pyrepr(this.hosts)}.`]; }
1164 if (source === "file") { const path = absolutePath(spec.path === undefined ? "" : spec.path); return pathAllowed(this.paths, path) ? null : ["path-not-allowed", `${path} is outside this tree's paths ${pyrepr(this.paths)}.`]; }
1165 if (source === "shell") { const cmd = String(spec.command === undefined ? "" : spec.command); return commandAllowed(this.commands, cmd) ? null : ["command-not-allowed", `${pyrepr(cmd)} is not in this tree's commands ${pyrepr(this.commands)}.`]; }
1166 if (source === "serial") { const dev = String(spec.device === undefined ? "" : spec.device); return deviceAllowed(this.devices, dev) ? null : ["device-not-allowed", `${dev} is outside this tree's devices ${pyrepr(this.devices)}.`]; }
1167 if (source === "queue") {
1168 let host, port;
1169 try { [, host, port] = broker(spec.url === undefined ? "" : spec.url); } catch (e) { return ["host-not-allowed", e.message]; }
1170 if (!this.hostAllowed(`mqtt://${host}:${port}/`)) return ["host-not-allowed", `${host} is outside this tree's hosts ${pyrepr(this.hosts)}.`];
1171 const topic = String(spec.topic === undefined ? "" : spec.topic);
1172 return topicAllowed(this.topics, topic) ? null : ["topic-not-allowed", `${pyrepr(topic)} is outside this tree's topics ${pyrepr(this.topics)}.`];
1173 }
1174 return null;
1175 }
1176 sources() {
1177 const out = new Set();
1178 for (const n of Object.values(this.nodes)) if (n.kind === "observe" || n.kind === "act") for (const s of SOURCES) if (n.spec[s] !== undefined && n.spec[s] !== null) out.add(s);
1179 return [...out].sort();
1180 }
1181 bindInputs(given) {
1182 given = given || {};
1183 const unknown = Object.keys(given).filter((k) => !(k in this.inputs)).sort();
1184 if (unknown.length) throw new TreeError(`${this.name}: unknown inputs ${pyrepr(unknown)}`);
1185 const bound = {};
1186 for (const [name, spec] of Object.entries(this.inputs)) {
1187 if (name in given && given[name] !== null && given[name] !== undefined) bound[name] = coerce(spec, given[name]);
1188 else if (spec.default !== null && spec.default !== undefined) bound[name] = spec.default;
1189 else if (spec.required) throw new TreeError(`${this.name}: input ${pyrepr(name)} is required`);
1190 else bound[name] = null;
1191 }
1192 return bound;
1193 }
1194 capability() {
1195 const inputs = {};
1196 for (const [n, s] of Object.entries(this.inputs)) {
1197 const d = {};
1198 if (s.type) d.type = s.type;
1199 if (s.default !== null && s.default !== undefined && s.default !== "" && s.default !== false && s.default !== 0) d.default = s.default; // Python drops what == False
1200 if (s.required && (s.default === null || s.default === undefined)) d.required = true;
1201 if (s.description) d.description = s.description;
1202 inputs[n] = d;
1203 }
1204 const cap = { name: this.name, version: this.version };
1205 if (this.namespace) cap.namespace = this.namespace;
1206 Object.assign(cap, { description: this.description, tags: this.tags, hosts: this.hosts });
1207 for (const k of LIST_NAMES) if (this[k].length) cap[k] = this[k];
1208 cap.sources = this.sources();
1209 const watches = Array.isArray(this.raw.watches) ? this.raw.watches : [];
1210 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) }; });
1211 const fixed = this.triggers.filter((t) => Object.keys(t.fixedInputs).length);
1212 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 });
1213 }
1214 }
1215
1216 // ------------------------------------------------------------ transports
1217 class Response {
1218 constructor(status, headers, body, url, elapsed_ms) { this.status = status; this.headers = headers; this.body = body; this.url = url; this.elapsed_ms = elapsed_ms || 0; }
1219 get json() { try { return JSON.parse(this.body); } catch (e) { return null; } }
1220 toState() { return { status: this.status, ok: this.status >= 200 && this.status < 300, headers: this.headers, body: this.body, json: this.json, url: this.url, elapsed_ms: this.elapsed_ms }; }
1221 }
1222 class FetchTransport {
1223 constructor(opts) { this.userAgent = (opts && opts.userAgent) || "pyrtan.js"; }
1224 async request(method, url, headers, body, timeout) {
1225 const ctl = typeof AbortController !== "undefined" ? new AbortController() : null;
1226 const timer = ctl ? setTimeout(() => ctl.abort(), (timeout || DEFAULT_TIMEOUT) * 1000) : null;
1227 const started = Date.now();
1228 try {
1229 const r = await fetch(url, { method: method.toUpperCase(), headers: Object.assign({ Accept: "application/json, text/html;q=0.9, */*;q=0.8" }, headers || {}), body: body === null || body === undefined ? undefined : body, signal: ctl ? ctl.signal : undefined, redirect: "follow" });
1230 const text = await r.text();
1231 const h = {}; r.headers.forEach((v, k) => { h[k.toLowerCase()] = v; });
1232 return new Response(r.status, h, text, r.url || url, Date.now() - started);
1233 } catch (e) {
1234 // A browser hides why: most often the site does not let pages on other origins read it (CORS).
1235 const why = typeof window !== "undefined" && e instanceof TypeError ? " (the site may not let a page in a browser read it; a hub can)" : "";
1236 throw new TransportError(`${method} ${url}: ${e && e.message ? e.message : e}${why}`);
1237 } finally { if (timer) clearTimeout(timer); }
1238 }
1239 }
1240 class RecordedTransport {
1241 constructor(responses) { this.responses = JSON.parse(JSON.stringify(responses || {})); this.calls = []; }
1242 async request(method, url) {
1243 const key = `${method.toUpperCase()} ${url}`;
1244 this.calls.push([method.toUpperCase(), url]);
1245 let rec = this.responses[key];
1246 if (rec === undefined) throw new TransportError(`no recorded response for ${key}`);
1247 if (Array.isArray(rec)) rec = rec.length > 1 ? rec.shift() : rec[0];
1248 if ("error" in rec) throw new TransportError(String(rec.error));
1249 let text = rec.body === undefined ? "" : rec.body;
1250 if (typeof text !== "string") text = pyJson(text);
1251 const headers = {}; for (const [k, v] of Object.entries(rec.headers || {})) headers[k.toLowerCase()] = v;
1252 return new Response(rec.status === undefined ? 200 : Number(rec.status), headers, text, url, 0);
1253 }
1254 async perform(source, spec) {
1255 const key = adapterKey(source, spec);
1256 this.calls.push([source, key]);
1257 let rec = this.responses[key];
1258 if (rec === undefined) throw new TransportError(`no recorded result for ${key}`);
1259 if (Array.isArray(rec)) rec = rec.length > 1 ? rec.shift() : rec[0];
1260 if ("error" in rec) throw new TransportError(String(rec.error));
1261 return JSON.parse(JSON.stringify(rec));
1262 }
1263 }
1264
1265 // ------------------------------------------------------------ runtime
1266 class Halt { constructor(outcome) { this.outcome = outcome; } }
1267 const statePath = (path) => { path = String(path).trim(); const head = path.split(".")[0].split("[")[0]; return ["inputs", "state", "item", "index", "tree", "now", "env"].includes(head) ? path : "state." + path; };
1268 function put(state, path, value) {
1269 if (path.startsWith("state.")) path = path.slice(6);
1270 const parts = path.split("."); let cur = state;
1271 for (const part of parts.slice(0, -1)) { let nxt = cur[part]; if (!isObj(nxt)) { nxt = {}; cur[part] = nxt; } cur = nxt; }
1272 cur[parts[parts.length - 1]] = value;
1273 }
1274 const plain = (v) => (isMissing(v) ? null : v);
1275 function nowScope(date) {
1276 const d = date || new Date();
1277 const iso = d.toISOString().replace(/\.\d{3}Z$/, "+00:00");
1278 return { iso, date: iso.slice(0, 10), epoch: Math.floor(d.getTime() / 1000), year: d.getUTCFullYear() };
1279 }
1280 const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1281
1282 class Trace {
1283 constructor(tree) { this.tree = tree; this.steps = []; this.started = Date.now() / 1000; this.gaps = []; }
1284 add(step) { this.steps.push(step); return step; }
1285 gap(node, on, observed) { this.gaps.push({ tree: this.tree, node, on, observed: short(observed) }); }
1286 toDict() { return { tree: this.tree, started: this.started, steps: this.steps.map(stepDict), gaps: this.gaps }; }
1287 render() {
1288 const lines = [`trace: ${this.tree}`];
1289 for (const s of this.steps) lines.push(`${" ".repeat((s.depth || 0) + 1)}${s.node} (${s.kind})${s.chosen ? " [" + s.chosen + "]" : ""}: ${s.summary}${s.next ? " -> " + s.next : ""}`);
1290 for (const g of this.gaps) lines.push(` gap: ${g.node} saw ${g.on} = ${pyJson(g.observed)} and had no branch for it`);
1291 return lines.join("\n");
1292 }
1293 }
1294 function step(node, kind, summary, extra) { return Object.assign({ node, kind, summary, observed: null, chosen: null, next: null, elapsed_ms: 0, depth: 0 }, extra || {}); }
1295 function stepDict(s) { const d = { node: s.node, kind: s.kind, summary: s.summary, next: s.next, elapsed_ms: s.elapsed_ms, depth: s.depth }; if (s.observed !== null && s.observed !== undefined) d.observed = s.observed; if (s.chosen !== null && s.chosen !== undefined) d.chosen = s.chosen; return d; }
1296 function outcome(status, tree, fields) { return Object.assign({ status, tree, say: null, output: null, reason: null, question: null, snapshot: null, trace: null, inputs: {} }, fields || {}); }
1297 function outcomeDict(o, withTrace = true) {
1298 const d = { status: o.status, tree: o.tree, inputs: o.inputs || {} };
1299 for (const k of ["say", "output", "reason", "question", "snapshot"]) if (o[k] !== null && o[k] !== undefined) d[k] = o[k];
1300 if (withTrace && o.trace) d.trace = o.trace.toDict();
1301 return d;
1302 }
1303
1304 class Runner {
1305 constructor(opts) {
1306 opts = opts || {};
1307 this.trees = {};
1308 for (const t of opts.trees || []) this.add(t);
1309 this.transport = opts.transport || new FetchTransport();
1310 // The other sources: a fixture answers for them; a live member has none and says so.
1311 this.adapters = opts.adapters || (typeof this.transport.perform === "function" ? this.transport : null);
1312 this.resolver = opts.resolver || null; // {runTree(name, inputs, depth), runIntent(text, depth)} -> outcome dicts
1313 this.env = opts.env || {};
1314 this.clock = opts.clock || (() => new Date());
1315 this.maxSteps = opts.maxSteps || MAX_STEPS; this.maxDepth = opts.maxDepth || MAX_DEPTH;
1316 this.emitted = [];
1317 }
1318 add(treeOrRaw) { const t = treeOrRaw instanceof Tree ? treeOrRaw : new Tree(treeOrRaw); this.trees[t.name] = t; return t; }
1319 get(name) { return this.trees[name] || null; }
1320
1321 async runTree(name, inputs, depth = 0) {
1322 const tree = this.get(name);
1323 if (!tree) {
1324 if (this.resolver) return fromDict(await this.resolver.runTree(name, inputs || {}, depth), name);
1325 return outcome("error", name, { reason: "no-such-tree", say: `No tree named ${pyrepr(name)}.` });
1326 }
1327 return this.run(tree, inputs || {}, { depth });
1328 }
1329 candidates() {
1330 const out = [];
1331 for (const name of Object.keys(this.trees).sort()) for (const trig of this.trees[name].triggers) out.push([name, trig]);
1332 return out;
1333 }
1334 read(text) { return read(text, this.candidates(), this.clock()); }
1335 // Read a sentence and run the tree it names; the trace begins with the intent step.
1336 async runIntent(text, depth = 0) {
1337 const reading = this.read(text);
1338 if (!reading.matches.length) {
1339 if (this.resolver) return fromDict(await this.resolver.runIntent(text, depth), "");
1340 const nm = noMatchOutcome(text, "Nothing here");
1341 nm.output.reading = { cleaned: reading.cleaned, steps: reading.steps, considered: reading.considered.map((m) => m.trigger.pattern) };
1342 return outcome("error", "", Object.assign({ reason: "no-match" }, nm));
1343 }
1344 const trees = [...new Set(reading.matches.map((m) => m.tree))].sort();
1345 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 } } });
1346 const match = reading.matches[0], tree = this.get(match.tree);
1347 const trace = new Trace(tree.name), st = reading.step();
1348 trace.add(step(st.node, st.kind, st.summary, { observed: st.observed, next: tree.start, depth }));
1349 return this.run(tree, match.inputs, { depth, trace });
1350 }
1351 // Continue a run paused by ask: the answer goes where the ask node said.
1352 async resume(snapshot, answer) {
1353 const tree = this.get(snapshot.tree);
1354 if (!tree) return outcome("error", snapshot.tree || "", { reason: "no-such-tree" });
1355 const node = tree.node(snapshot.node), into = String(node.get("into"));
1356 const inputs = Object.assign({}, snapshot.inputs), state = Object.assign({}, snapshot.state);
1357 if (into.startsWith("inputs.")) {
1358 const key = into.slice(7);
1359 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; }
1360 } else put(state, into.startsWith("state.") ? into.slice(6) : into, answer);
1361 const trace = new Trace(tree.name);
1362 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 }));
1363 trace.add(step(node.id, "ask", `answered: ${brief(answer)}`, { next: node.next }));
1364 return this.run(tree, inputs, { start: node.next, state, trace });
1365 }
1366 async run(tree, inputs, opts) {
1367 opts = opts || {};
1368 const depth = opts.depth || 0;
1369 const trace = opts.trace || new Trace(tree.name);
1370 if (depth > this.maxDepth) return outcome("error", tree.name, { reason: "too-deep", say: `Delegation deeper than ${this.maxDepth} levels.`, trace });
1371 let bound;
1372 try { bound = tree.bindInputs(inputs); } catch (e) { if (e instanceof TreeError) return outcome("error", tree.name, { reason: "bad-input", say: e.message, trace }); throw e; }
1373 const state = opts.state ? opts.state : {};
1374 const scope = Object.assign({ inputs: bound, state, tree: { name: tree.name, version: tree.version }, now: nowScope(this.clock()), env: this.env }, opts.scopeExtra || {});
1375 try {
1376 return await this.walk(tree, opts.start || tree.start, scope, trace, depth);
1377 } catch (h) {
1378 if (!(h instanceof Halt)) throw h;
1379 h.outcome.inputs = bound;
1380 h.outcome.trace = h.outcome.trace || trace;
1381 return h.outcome;
1382 }
1383 }
1384 async walk(tree, start, scope, trace, depth) {
1385 let nodeId = start, steps = 0;
1386 for (;;) {
1387 steps++;
1388 if (steps > this.maxSteps) throw new Halt(outcome("error", tree.name, { reason: "too-many-steps", say: `More than ${this.maxSteps} steps.`, trace }));
1389 const node = tree.node(nodeId);
1390 const started = Date.now();
1391 let result;
1392 try {
1393 result = await this["do_" + node.kind](tree, node, scope, trace, depth);
1394 } catch (e) {
1395 if (e instanceof TemplateError) { trace.add(step(node.id, node.kind, `template: ${e.message}`, { depth })); throw new Halt(outcome("error", tree.name, { reason: "template", say: `${node.id}: ${e.message}`, trace })); }
1396 throw e;
1397 }
1398 if (result && result.status) { result.trace = trace; throw new Halt(result); }
1399 const [st, nextId] = result;
1400 st.elapsed_ms = Date.now() - started; st.depth = depth; st.next = nextId === undefined ? null : nextId;
1401 trace.add(st);
1402 if (nextId === undefined || nextId === null) throw new Halt(outcome("error", tree.name, { reason: "dead-end", say: `${node.id} has nowhere to go.`, trace }));
1403 nodeId = nextId;
1404 }
1405 }
1406 do_observe(tree, node, scope, trace, depth) { return this.observeOrAct(tree, node, scope, trace, true); }
1407 async do_act(tree, node, scope, trace, depth) {
1408 const emit = node.get("emit");
1409 if (emit !== undefined && emit !== null) {
1410 const event = { event: render(emit.event, scope), data: render(emit.data !== undefined ? emit.data : {}, scope), tree: tree.name };
1411 this.emitted.push(event);
1412 put(scope.state, node.get("into", "last"), event);
1413 return [step(node.id, "act", `emitted ${event.event}`, { observed: event.data }), node.next];
1414 }
1415 return this.observeOrAct(tree, node, scope, trace, false);
1416 }
1417 async observeOrAct(tree, node, scope, trace, retry) {
1418 const state = scope.state, into = node.get("into", "last");
1419 const sourceName = SOURCES.find((s) => node.spec[s] !== undefined && node.spec[s] !== null);
1420 let source, summary;
1421 if (sourceName !== undefined) {
1422 const spec = render(node.get(sourceName), scope);
1423 const problem = tree.allowed(sourceName, spec);
1424 if (problem) return outcome("error", tree.name, { reason: problem[0], say: `${node.id}: ${problem[1]}` });
1425 const timeout = Number(node.get("timeout", DEFAULT_TIMEOUT));
1426 let label, call;
1427 if (sourceName === "http") {
1428 const method = String(spec.method || "GET").toUpperCase(), url = String(spec.url);
1429 const headers = {}; for (const [k, v] of Object.entries(spec.headers || {})) headers[String(k)] = String(v);
1430 let body = spec.body;
1431 if (body !== undefined && body !== null && typeof body !== "string") { body = JSON.stringify(body); if (!("Content-Type" in headers)) headers["Content-Type"] = "application/json"; }
1432 label = `${method} ${url}`;
1433 call = async () => (await this.transport.request(method, url, headers, body === undefined ? null : body, timeout)).toState();
1434 } else {
1435 label = `${sourceName} ${what(sourceName, spec)}`;
1436 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")}.` });
1437 call = () => this.adapters.perform(sourceName, spec, timeout, (u) => tree.hostAllowed(u));
1438 }
1439 const attempts = retry ? 1 + Number(node.get("retries", DEFAULT_RETRIES)) : 1;
1440 let lastError = null, observed = null;
1441 for (let attempt = 0; attempt < attempts; attempt++) {
1442 try { observed = await call(); break; }
1443 catch (e) { if (!(e instanceof TransportError)) throw e; lastError = e.message; if (attempt + 1 < attempts) await sleep(Math.min(200 * (attempt + 1), 1000)); }
1444 }
1445 if (observed === null) { trace.add(step(node.id, node.kind, `${label} failed: ${lastError}`)); return outcome("error", tree.name, { reason: "transport", say: `${node.id}: ${lastError}` }); }
1446 put(state, into, observed);
1447 source = observed;
1448 summary = `${label} -> ${briefResult(sourceName, observed)}`;
1449 } else {
1450 const frm = node.get("from");
1451 const sourceVal = resolve(statePath(frm), scope);
1452 if (isMissing(sourceVal)) source = {};
1453 else if (isObj(sourceVal)) source = sourceVal;
1454 else source = { value: sourceVal, body: typeof sourceVal === "string" ? sourceVal : null };
1455 summary = `from ${frm}`;
1456 }
1457 const extracted = {}; const cache = new Map();
1458 for (const entry of node.get("extract", []) || []) {
1459 const value = extract(entry, source, cache);
1460 if (isMissing(value)) extracted[entry.into] = null;
1461 else { put(state, entry.into, value); extracted[entry.into] = value; }
1462 }
1463 const keys = Object.keys(extracted);
1464 if (keys.length) summary += "; " + keys.map((k) => `${k}=${brief(extracted[k])}`).join(", ");
1465 return [step(node.id, node.kind, summary, { observed: keys.length ? extracted : null }), node.next];
1466 }
1467 do_decide(tree, node, scope, trace, depth) {
1468 const on = statePath(node.get("on"));
1469 const value = resolve(on, scope);
1470 for (const br of node.get("branches")) {
1471 if ("else" in br) return [step(node.id, "decide", `${on} = ${brief(value)}`, { observed: plain(value), chosen: "else" }), br.else];
1472 if (holds(br.when, value)) return [step(node.id, "decide", `${on} = ${brief(value)}`, { observed: plain(value), chosen: condText(br.when) }), br.then];
1473 }
1474 trace.gap(node.id, on, plain(value));
1475 trace.add(step(node.id, "decide", `${on} = ${brief(value)}: no branch`, { observed: plain(value), depth }));
1476 return outcome("error", tree.name, { reason: "gap", say: `${node.id}: no branch for ${on} = ${brief(value)}.`, output: { gap: trace.gaps[trace.gaps.length - 1] } });
1477 }
1478 do_set(tree, node, scope) {
1479 const values = {};
1480 for (const [key, tmpl] of Object.entries(node.get("set"))) { const value = render(tmpl, scope); put(scope.state, key, value); values[key] = value; }
1481 return [step(node.id, "set", Object.keys(values).map((k) => `${k}=${brief(values[k])}`).join(", "), { observed: values }), node.next];
1482 }
1483 async do_each(tree, node, scope, trace, depth) {
1484 const state = scope.state;
1485 let items = resolve(statePath(node.get("over")), scope);
1486 if (isMissing(items) || items === null || items === undefined) items = [];
1487 if (!Array.isArray(items)) return outcome("error", tree.name, { reason: "not-a-list", say: `${node.id}: ${node.get("over")} is not a list.` });
1488 let limit = node.get("limit");
1489 if (limit !== undefined && limit !== null) { limit = Math.trunc(Number(render(limit, scope))); items = items.slice(0, limit); }
1490 const results = []; let failures = 0;
1491 for (let index = 0; index < items.length; index++) {
1492 const item = items[index];
1493 const withInputs = render(node.get("with", {}), Object.assign({}, scope, { item, index }));
1494 let sub;
1495 if ("tree" in node.spec) sub = await this.runTree(node.get("tree"), withInputs, depth + 1);
1496 else sub = await this.run(tree, scope.inputs, { depth: depth + 1, start: node.get("start"), state, scopeExtra: { item, index }, trace });
1497 if (sub.status === "done") results.push(sub.output !== null && sub.output !== undefined ? sub.output : sub.say);
1498 else { failures++; results.push(null); if (node.get("stop_on_fail", false)) return outcome("error", tree.name, { reason: "each-failed", say: `${node.id}: item ${index} failed: ${sub.say || sub.reason}` }); }
1499 if (sub.trace && sub.trace !== trace) { for (const s of sub.trace.steps) { s.depth = depth + 1; trace.steps.push(s); } trace.gaps.push(...sub.trace.gaps); }
1500 }
1501 put(state, node.get("into"), results);
1502 return [step(node.id, "each", `${items.length} item(s)` + (failures ? `, ${failures} failed` : "")), node.next];
1503 }
1504 async do_delegate(tree, node, scope, trace, depth) {
1505 const withInputs = render(node.get("with", {}), scope);
1506 let sub, summary;
1507 if ("intent" in node.spec) { const text = render(node.get("intent"), scope); sub = await this.runIntent(pyStr(text), depth + 1); summary = `intent ${pyrepr(pyStr(text))} -> ${sub.tree || "?"}: ${sub.status}`; }
1508 else { sub = await this.runTree(node.get("tree"), withInputs, depth + 1); summary = `tree ${node.get("tree")}: ${sub.status}`; }
1509 if (sub.trace && sub.trace !== trace) { for (const s of sub.trace.steps) { s.depth = depth + 1; trace.steps.push(s); } trace.gaps.push(...sub.trace.gaps); }
1510 put(scope.state, node.get("into"), { status: sub.status, say: sub.say, output: sub.output, reason: sub.reason, tree: sub.tree });
1511 if (sub.status === "asked") return outcome("error", tree.name, { reason: "ask-in-delegate", say: `${node.id}: ${sub.tree} asked a question; delegated trees cannot pause.` });
1512 return [step(node.id, "delegate", summary), node.next];
1513 }
1514 do_ask(tree, node, scope, trace, depth) {
1515 if (depth > 0) return outcome("error", tree.name, { reason: "ask-in-subflow", say: `${node.id}: only a top-level run can ask.` });
1516 const prompt = render(node.get("prompt"), scope);
1517 trace.add(step(node.id, "ask", `asked: ${prompt}`, { depth }));
1518 return outcome("asked", tree.name, { question: pyStr(prompt), snapshot: { tree: tree.name, node: node.id, inputs: scope.inputs, state: scope.state, trace: trace.steps.map(stepDict) } });
1519 }
1520 do_done(tree, node, scope, trace, depth) {
1521 let say = node.get("say"), output = node.get("output");
1522 say = say !== undefined && say !== null ? render(say, scope, false) : null;
1523 output = output !== undefined && output !== null ? render(output, scope, false) : null;
1524 trace.add(step(node.id, "done", say ? pyStr(say) : "done", { depth }));
1525 return outcome("done", tree.name, { say: say !== null ? pyStr(say) : null, output });
1526 }
1527 do_fail(tree, node, scope, trace, depth) {
1528 let say = node.get("say");
1529 say = say !== undefined && say !== null ? render(say, scope, false) : null;
1530 const reason = pyStr(node.get("reason"));
1531 trace.add(step(node.id, "fail", `${reason}: ${say || ""}`.replace(/: $/, ""), { depth }));
1532 const out = node.get("output");
1533 return outcome("failed", tree.name, { reason, say: say !== null ? pyStr(say) : null, output: out !== undefined && out !== null ? render(out, scope, false) : null });
1534 }
1535 }
1536 function fromDict(d, fallbackTree) {
1537 if (!isObj(d)) return outcome("error", fallbackTree, { reason: "remote", say: "no answer" });
1538 let trace = null;
1539 if (isObj(d.trace)) { trace = new Trace(d.trace.tree || fallbackTree); trace.steps = (d.trace.steps || []).map((s) => step(s.node, s.kind, s.summary, { observed: s.observed === undefined ? null : s.observed, chosen: s.chosen === undefined ? null : s.chosen, next: s.next === undefined ? null : s.next, elapsed_ms: s.elapsed_ms || 0, depth: s.depth || 0 })); trace.gaps = d.trace.gaps || []; }
1540 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 || {} });
1541 }
1542
1543 // ------------------------------------------------------------ the hub in a page
1544 // ------------------------------------------------------------ growing (grow.py, suggestions.py)
1545 // The branch a gap asks for, by the same rules as grow.draft_branch, on a
1546 // tree's raw table; and the checks suggestions.problem makes of what an
1547 // agent sends. Rules only: an agent's words are data these check.
1548 const tomlStr = (s) => String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
1549 function tomlValue(v) {
1550 if (isBool(v)) return v ? "true" : "false";
1551 if (isNum(v)) return Number.isInteger(v) ? String(v) : String(v);
1552 if (Array.isArray(v)) return "[" + v.map(tomlValue).join(", ") + "]";
1553 return '"' + tomlStr(v === null || v === undefined ? "None" : String(v)) + '"';
1554 }
1555 const tomlInline = (cond) => "{ " + Object.keys(cond).map((k) => `${k} = ${tomlValue(cond[k])}`).join(", ") + " }";
1556 function growSlug(value, limit = 24) {
1557 let text = value === null || value === undefined ? "missing" : (typeof value === "string" ? value : pyrepr(value)).toLowerCase();
1558 text = text.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1559 return text.slice(0, limit).replace(/-+$/, "") || "value";
1560 }
1561 const safeHolds = (cond, value) => { try { return holds(cond, value); } catch (e) { return false; } };
1562 function conditionFor(observed, branches) {
1563 if (observed === null || observed === undefined) return [{ exists: false }, "the value was missing"];
1564 if (typeof observed === "string" && /^<(str|dict|list|int|float|bool)>$/.test(observed)) return [{ type: observed.slice(1, -1) }, "a report carries a long or structured value as its type"];
1565 if (isNum(observed)) {
1566 const covered = [];
1567 for (const b of branches) {
1568 const w = isObj(b) ? b.when : null;
1569 if (!isObj(w)) continue;
1570 if (Array.isArray(w.between) && w.between.length === 2) { const lo = Number(w.between[0]), hi = Number(w.between[1]); if (!Number.isNaN(lo) && !Number.isNaN(hi)) covered.push([lo, hi]); }
1571 else if (isNum(w.eq)) covered.push([w.eq, w.eq]);
1572 }
1573 if (covered.length && Number.isInteger(observed)) {
1574 const below = covered.filter(([, hi]) => hi < observed).map(([, hi]) => hi), above = covered.filter(([lo]) => lo > observed).map(([lo]) => lo);
1575 if (below.length && above.length && below.concat(above).every(Number.isInteger)) {
1576 const b = Math.max(...below), a = Math.min(...above), lo = b + 1, hi = a - 1;
1577 if (lo <= hi) return [{ between: [lo, hi] }, `the value falls in the hole between ${b} and ${a}, which the ranges around it leave open`];
1578 }
1579 }
1580 }
1581 return [{ eq: observed }, "the value itself"];
1582 }
1583 // raw: a tree's table (as trees.json carries it); gap: {node, on, observed}. null when the gap names no decide node or a branch covers the value.
1584 function draftBranch(raw, gap) {
1585 const nodes = (raw && raw.nodes) || {};
1586 const id = String(gap.node === undefined ? "" : gap.node), node = nodes[id];
1587 if (!isObj(node) || node.kind !== "decide") return null;
1588 const on = String(gap.on || node.on || ""), observed = gap.observed === undefined ? null : gap.observed;
1589 const branches = (node.branches || []).filter(isObj);
1590 if (branches.some((b) => "when" in b && safeHolds(b.when, observed))) return null;
1591 const [when, reason] = conditionFor(observed, branches);
1592 const base = `${id}-${growSlug(observed)}`;
1593 let then = base, n = 2;
1594 while (has(nodes, then)) then = `${base}-${n++}`;
1595 return { tree: raw.name, node: id, on, observed, when, then, branch: `{ when = ${tomlInline(when)}, then = "${then}" },`, reason };
1596 }
1597 // The tree a draft and a meaning make, as a new raw table: the branch before the else, the node after the rest. The caller loads it (new Tree) to see it holds.
1598 function growTree(raw, draft, meaning) {
1599 const out = JSON.parse(JSON.stringify(raw));
1600 const node = out.nodes[draft.node];
1601 const branches = node.branches || (node.branches = []);
1602 const at = branches.findIndex((b) => isObj(b) && "else" in b);
1603 branches.splice(at < 0 ? branches.length : at, 0, { when: draft.when, then: draft.then });
1604 out.nodes[draft.then] = Object.assign({ kind: meaning.kind }, meaning.kind === "fail" ? { reason: meaning.reason || growSlug(draft.then, 40) } : {}, { say: meaning.say });
1605 return out;
1606 }
1607
1608 const SUGGESTION_KINDS = ["branch", "trigger", "tree"], SAY_ROOTS = ["inputs", "state", "tree"];
1609 function suggestionProblem(s) {
1610 if (!isObj(s)) return "a suggestion is a table";
1611 const kind = s.kind;
1612 if (!SUGGESTION_KINDS.includes(kind)) return `kind is one of ${SUGGESTION_KINDS.join(", ")}`;
1613 const why = s.why === undefined ? "" : s.why;
1614 if (typeof why !== "string" || why.length > 500) return "why is text of at most 500 characters";
1615 const by = s.by === undefined ? {} : s.by;
1616 if (!isObj(by) || !Object.values(by).every((v) => typeof v === "string" && v.length <= 120)) return "by is a table of short strings: agent, model, engine";
1617 if ((kind === "branch" || kind === "trigger") && !(typeof s.tree === "string" && s.tree)) return "a branch or a trigger names its tree";
1618 if (kind === "branch") {
1619 if (typeof s.node !== "string" || !s.node) return "a branch names the decide node it belongs to";
1620 if (!("observed" in s)) return "a branch says what value it is for (observed)";
1621 const m = s.meaning;
1622 if (!isObj(m) || !["done", "fail"].includes(m.kind)) return "a branch's meaning is a done or a fail";
1623 if (typeof m.say !== "string" || !m.say.trim() || m.say.length > 300) return "a meaning says something, in at most 300 characters";
1624 for (const mm of m.say.matchAll(PLACEHOLDER)) {
1625 const head = mm[1].split("|")[0].trim().split(/[.[|\s]/)[0];
1626 if (!SAY_ROOTS.includes(head)) return `say may read ${SAY_ROOTS.join(", ")}, not ${head}`;
1627 }
1628 if ("reason" in m && !(typeof m.reason === "string" && /^[a-z0-9][a-z0-9-]{0,39}$/.test(m.reason))) return "reason is a short lowercase slug, like no-such-place";
1629 }
1630 if (kind === "trigger") {
1631 const pattern = s.pattern;
1632 if (typeof pattern !== "string" || !pattern.trim() || pattern.length > 200) return "a trigger is a pattern of at most 200 characters";
1633 try { new Trigger(pattern); } catch (e) { if (e instanceof TriggerError) return `pattern: ${e.message}`; throw e; }
1634 const covers = s.covers === undefined ? [] : s.covers;
1635 if (!Array.isArray(covers) || !covers.length || covers.length > 10 || !covers.every((c) => typeof c === "string" && c.length > 0 && c.length <= 200)) return "a trigger names the sentences it covers (1 to 10)";
1636 }
1637 if (kind === "tree") {
1638 if (typeof s.text !== "string" || !s.text.trim() || s.text.length > 200) return "a tree suggestion carries the sentence it would answer";
1639 const d = s.description === undefined ? "" : s.description;
1640 if (typeof d !== "string" || d.length > 200) return "description is one line";
1641 const hints = s.hosts_hint === undefined ? [] : s.hosts_hint;
1642 if (!Array.isArray(hints) || !hints.every((h) => typeof h === "string" && h.length <= 100)) return "hosts_hint is a list of host names (a note, never a host)";
1643 }
1644 return null;
1645 }
1646 function suggestionKey(s) {
1647 const parts = s.kind === "branch" ? [s.kind, s.tree, s.node, s.on === undefined ? null : s.on, s.observed === undefined ? null : s.observed]
1648 : s.kind === "trigger" ? [s.kind, s.tree, s.pattern] : [s.kind, String(s.text === undefined ? "" : s.text).trim().toLowerCase()];
1649 return JSON.stringify(parts);
1650 }
1651 const SUGGESTION_FIELDS = ["kind", "tree", "node", "on", "observed", "meaning", "pattern", "covers", "text", "description", "hosts_hint", "why", "by", "evidence"];
1652 function suggestionClean(s) {
1653 const out = {};
1654 for (const k of SUGGESTION_FIELDS) if (has(s, k)) out[k] = s[k];
1655 if (isObj(out.meaning)) { const m = {}; for (const k of ["kind", "say", "reason"]) if (has(out.meaning, k)) m[k] = out.meaning[k]; out.meaning = m; }
1656 if (out.evidence !== undefined) out.evidence = Array.isArray(out.evidence) ? out.evidence.filter(isObj).slice(0, 10) : [];
1657 return JSON.parse(JSON.stringify(out));
1658 }
1659 const grow = { draftBranch, growTree, conditionFor, problem: suggestionProblem, key: suggestionKey, clean: suggestionClean, SAY_ROOTS };
1660
1661 // A browser that has joined no hub is a hub of its own, for as long as the
1662 // page is open: it holds trees (the catalog's, as a page loads them), reads
1663 // sentences, runs the trees and keeps the gaps and the unanswered sentences
1664 // in memory. It answers the same paths with the same shapes as a service
1665 // (service.py), so the console talks to it exactly as it talks to a hub.
1666 // What it cannot do is reach what only a box can: files, programs, serial
1667 // lines, brokers, a browser to drive, and sites that do not let a page read
1668 // them. Those trees answer with the reason, and joining a hub is the cure.
1669 const UNANSWERED_LIMIT = 200;
1670 class LocalHub {
1671 constructor(opts) {
1672 opts = opts || {};
1673 this.name = opts.name || "this browser";
1674 this.version = opts.version || VERSION;
1675 this.clock = opts.clock || (() => new Date());
1676 this.wall = opts.wall || (() => Date.now() / 1000);
1677 this.runner = new Runner({ transport: opts.transport || new FetchTransport(), adapters: opts.adapters || null, clock: this.clock, env: opts.env });
1678 this.errors = {}; this.held = {};
1679 this.gaps = new Map(); this.unanswered = new Map(); this.suggestions = new Map();
1680 this.runs = 0; this.started = this.wall();
1681 for (const raw of opts.trees || []) this.add(raw);
1682 }
1683 // where: what to call the tree in errors (a catalog id). The first tree of a name is the one held.
1684 add(raw, where) {
1685 const name = raw && raw.name;
1686 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; }
1687 try { const t = this.runner.add(raw); this.held[t.name] = where || t.name; delete this.errors[where || t.name]; return t; }
1688 catch (e) { if (!(e instanceof TreeError)) throw e; this.errors[where || (raw && raw.name) || "?"] = e.message; return null; }
1689 }
1690 trees() { return Object.keys(this.runner.trees).sort().map((n) => this.runner.trees[n]); }
1691 health() {
1692 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, suggestions: this.suggestions.size, uptime_s: Math.floor(this.wall() - this.started), watches: 0 };
1693 }
1694 capabilities() { return { name: this.name, trees: this.trees().map((t) => t.capability()) }; }
1695 noteGaps(gaps) {
1696 const now = this.wall();
1697 for (const g of gaps) {
1698 const key = JSON.stringify([g.tree, g.node, g.on, g.observed === undefined ? null : g.observed]);
1699 const cur = this.gaps.get(key);
1700 if (cur) { cur.count += 1; cur.last_seen = Math.max(cur.last_seen, now); }
1701 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 });
1702 }
1703 }
1704 noteUnanswered(text) {
1705 const now = this.wall(), key = normalise(text).toLowerCase() || text;
1706 let e = this.unanswered.get(key);
1707 if (!e) { e = { text: normalise(text) || text, count: 0, first: now, last: now, proposal: propose(text) }; this.unanswered.set(key, e); }
1708 e.count += 1; e.last = Math.max(e.last, now);
1709 while (this.unanswered.size > UNANSWERED_LIMIT) {
1710 let oldest = null;
1711 for (const [k, v] of this.unanswered) if (oldest === null || v.last < this.unanswered.get(oldest).last) oldest = k;
1712 this.unanswered.delete(oldest);
1713 }
1714 }
1715 // What agents sent, as suggestions.py keeps it: checked, counted, bounded, never applied.
1716 noteSuggestions(items) {
1717 const now = this.wall(), refused = [];
1718 let kept = 0;
1719 items.forEach((s, index) => {
1720 const why = suggestionProblem(s);
1721 if (why) { refused.push({ index, why }); return; }
1722 const c = suggestionClean(s), k = suggestionKey(c);
1723 let e = this.suggestions.get(k);
1724 if (!e) { e = Object.assign({}, c, { count: 0, first: now, last: now }); this.suggestions.set(k, e); }
1725 else Object.assign(e, c);
1726 e.count += 1; e.last = Math.max(e.last, now); kept += 1;
1727 while (this.suggestions.size > UNANSWERED_LIMIT) {
1728 let oldest = null;
1729 for (const [kk, v] of this.suggestions) if (oldest === null || v.last < this.suggestions.get(oldest).last) oldest = kk;
1730 this.suggestions.delete(oldest);
1731 }
1732 });
1733 return { status: "ok", kept, refused };
1734 }
1735 finish(o, body) {
1736 this.runs += 1;
1737 if (o.trace && o.trace.gaps && o.trace.gaps.length) this.noteGaps(o.trace.gaps);
1738 return outcomeDict(o, body.trace === undefined ? true : Boolean(body.trace));
1739 }
1740 async intent(body) {
1741 const text = body.text;
1742 if (typeof text !== "string" || !text.trim()) return { status: "error", reason: "bad-request", say: 'Send {"text": "..."}.' };
1743 const o = await this.runner.runIntent(text, Math.trunc(Number(body.depth || 0)));
1744 if (o.reason === "no-match") this.noteUnanswered(text);
1745 return this.finish(o, body);
1746 }
1747 async run(body) {
1748 if (typeof body.tree !== "string") return { status: "error", reason: "bad-request", say: 'Send {"tree": "...", "inputs": {...}}.' };
1749 return this.finish(await this.runner.runTree(body.tree, body.inputs || {}, Math.trunc(Number(body.depth || 0))), body);
1750 }
1751 async resume(body) {
1752 const snap = body.snapshot;
1753 if (!isObj(snap) || !("tree" in snap)) return { status: "error", reason: "bad-request", say: 'Send {"snapshot": {...}, "answer": ...}.' };
1754 return this.finish(await this.runner.resume(snap, body.answer === undefined ? null : body.answer), body);
1755 }
1756 // (method, path, body) -> [status, payload], as service.py's route.
1757 async route(method, path, body) {
1758 method = String(method || "GET").toUpperCase();
1759 path = "/" + String(path || "").split("?")[0].replace(/^\/+/, "");
1760 body = isObj(body) ? body : {};
1761 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", "/suggestions", "/watches"] }];
1762 if (method === "GET" && path === "/health") return [200, this.health()];
1763 if (method === "GET" && path === "/capabilities") return [200, this.capabilities()];
1764 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) }];
1765 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) }];
1766 if (method === "GET" && path === "/watches") return [200, { count: 0, watches: [], watching: false }];
1767 if (method === "GET" && path === "/suggestions") return [200, { suggestions: [...this.suggestions.values()].map((e) => Object.assign({}, e)).sort((a, b) => b.count - a.count || b.last - a.last) }];
1768 if (method === "POST" && path === "/suggestions") {
1769 const items = Array.isArray(body.suggestions) ? body.suggestions : body.kind ? [body] : null;
1770 if (items === null) return [400, { status: "error", reason: "bad-request", say: 'Send {"suggestions": [{"kind": "branch"|"trigger"|"tree", ...}]}.' }];
1771 if (items.length > 50) return [400, { status: "error", reason: "too-many", say: "At most 50 suggestions at a time." }];
1772 return [200, this.noteSuggestions(items)];
1773 }
1774 if (method === "POST" && path === "/intent") return [200, await this.intent(body)];
1775 if (method === "POST" && path === "/run") return [200, await this.run(body)];
1776 if (method === "POST" && path === "/resume") return [200, await this.resume(body)];
1777 return [404, { status: "error", reason: "not-found", say: `No ${method} ${path} here.` }];
1778 }
1779 }
1780
1781 return { MISSING, Tree, Runner, LocalHub, Reading, nlp, intent, read, propose, grow, Trace, FetchTransport, RecordedTransport, Response, render, resolve, holds, extract, outcomeDict, fromDict, pyrepr, pyJson, html, TemplateError, TreeError, TransportError, ConditionError, SOURCES, adapterKey, version: VERSION };
1782});