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 | }); |