pyrtan / src / pyrtan / js / agents.js
45,734 bytes · last changed 2026-10-28 in Agents beside the swarm: open-weights models that probe the trees, suggest, chat, remember and follow skills; release 0.15.0
| 1 | /* agents.js: agents beside the swarm, in the browser. |
| 2 | * |
| 3 | * An agent is an open-weights model a person starts in the console. It |
| 4 | * runs in the page (WebLLM, on the browser's GPU) or on a server of the |
| 5 | * person's own that speaks the OpenAI chat API (Ollama, llama.cpp, vLLM). |
| 6 | * It is a client of the swarm like any other: it reads the trees' |
| 7 | * triggers, asks sentences through /intent, reads /gaps and /unanswered, |
| 8 | * and sends what it thinks to /suggestions. It never sits inside a run: |
| 9 | * every answer a tree gives is still the tree's, with its trace. |
| 10 | * |
| 11 | * What it does: |
| 12 | * - probes a tree with sentences of its own, read here by the same rules |
| 13 | * the swarm reads them (pyrtan.js), and runs the ones that reach the |
| 14 | * tree, so a value no branch covers becomes a real gap; |
| 15 | * - says what a gap's value means (a branch), what pattern would let a |
| 16 | * tree read sentences it missed (a trigger), and what tree a sentence |
| 17 | * nobody answered needs (a tree); |
| 18 | * - talks with the person, and asks the swarm when a tree can answer; |
| 19 | * - remembers: notes (its own and the person's), what it probed, what was |
| 20 | * accepted, dismissed or refused, and the conversation; |
| 21 | * - follows skills: instructions, built in or written by the person, for |
| 22 | * each of its jobs. |
| 23 | * |
| 24 | * What it may not do, by construction rather than by asking it nicely: |
| 25 | * every suggestion is checked here by rules (Pyrtan.grow.problem, a |
| 26 | * reading with the new pattern, a tree that still loads) before it is |
| 27 | * shown, and again by `pyrtan grow --suggestions` before it is written. |
| 28 | * No suggestion adds I/O, a host, or a read of env. A person accepts. |
| 29 | * |
| 30 | * Runs in a browser as `PyrtanAgents` (after pyrtan.js) and in node. |
| 31 | */ |
| 32 | (function (root, factory) { |
| 33 | if (typeof module === "object" && module.exports) module.exports = factory(require("./pyrtan.js")); |
| 34 | else root.PyrtanAgents = factory(root.Pyrtan); |
| 35 | })(typeof globalThis !== "undefined" ? globalThis : this, function (Pyrtan) { |
| 36 | "use strict"; |
| 37 | |
| 38 | // ------------------------------------------------------------ models |
| 39 | // Open weights under open licences, as WebLLM builds them. Sizes are the |
| 40 | // download, once; the browser keeps it. |
| 41 | const WEBLLM_URL = "https://cdn.jsdelivr.net/npm/@mlc-ai/web-llm@0.2.85/+esm"; |
| 42 | const MODELS = [ |
| 43 | { id: "Qwen2.5-1.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 1.5B Instruct", licence: "Apache-2.0", size: "1.0 GB" }, |
| 44 | { id: "Qwen3-4B-q4f16_1-MLC", label: "Qwen3 4B", licence: "Apache-2.0", size: "2.3 GB" }, |
| 45 | { id: "Phi-3.5-mini-instruct-q4f16_1-MLC", label: "Phi-3.5 mini", licence: "MIT", size: "2.2 GB" }, |
| 46 | { id: "SmolLM2-1.7B-Instruct-q4f16_1-MLC", label: "SmolLM2 1.7B Instruct", licence: "Apache-2.0", size: "1.0 GB" }, |
| 47 | { id: "Qwen2.5-0.5B-Instruct-q4f16_1-MLC", label: "Qwen2.5 0.5B Instruct (small, rough)", licence: "Apache-2.0", size: "0.4 GB" }, |
| 48 | ]; |
| 49 | const DEFAULT_MODEL = MODELS[0].id; |
| 50 | |
| 51 | // ------------------------------------------------------------ engines |
| 52 | // An engine is {name, model, complete(messages, {json, temperature, maxTokens}) -> text}. |
| 53 | class WebLLMEngine { |
| 54 | constructor(opts) { |
| 55 | opts = opts || {}; |
| 56 | this.name = "webllm"; this.model = opts.model || DEFAULT_MODEL; this.url = opts.url || WEBLLM_URL; |
| 57 | this.onProgress = opts.onProgress || null; this.engine = null; this.loading = null; |
| 58 | } |
| 59 | static available() { return typeof navigator !== "undefined" && !!navigator.gpu; } |
| 60 | load() { |
| 61 | if (this.engine) return Promise.resolve(); |
| 62 | if (!this.loading) { |
| 63 | this.loading = (async () => { |
| 64 | if (!WebLLMEngine.available()) throw new Error("this browser has no WebGPU; use a server of your own (Ollama, llama.cpp) instead"); |
| 65 | const lib = await import(this.url); |
| 66 | this.engine = await lib.CreateMLCEngine(this.model, { initProgressCallback: (r) => this.onProgress && this.onProgress(r.text, r.progress) }); |
| 67 | })().catch((e) => { this.loading = null; throw e; }); |
| 68 | } |
| 69 | return this.loading; |
| 70 | } |
| 71 | async complete(messages, opts) { |
| 72 | opts = opts || {}; |
| 73 | await this.load(); |
| 74 | const req = { messages, temperature: opts.temperature === undefined ? 0.4 : opts.temperature, max_tokens: opts.maxTokens || 512 }; |
| 75 | if (opts.json) req.response_format = { type: "json_object" }; |
| 76 | if (/^Qwen3/.test(this.model)) req.extra_body = { enable_thinking: false }; |
| 77 | const r = await this.engine.chat.completions.create(req); |
| 78 | return (r.choices[0] && r.choices[0].message && r.choices[0].message.content) || ""; |
| 79 | } |
| 80 | async unload() { if (this.engine) await this.engine.unload(); this.engine = null; this.loading = null; } |
| 81 | } |
| 82 | |
| 83 | // A server of the person's own: Ollama (http://localhost:11434/v1), llama.cpp, vLLM, LM Studio. |
| 84 | class OpenAIEngine { |
| 85 | constructor(opts) { |
| 86 | opts = opts || {}; |
| 87 | this.name = "openai"; this.url = String(opts.url || "http://localhost:11434/v1").replace(/\/+$/, ""); this.model = opts.model || "qwen2.5:3b"; this.key = opts.key || ""; |
| 88 | } |
| 89 | async load() {} |
| 90 | async complete(messages, opts) { |
| 91 | opts = opts || {}; |
| 92 | const body = { model: this.model, messages, temperature: opts.temperature === undefined ? 0.4 : opts.temperature, max_tokens: opts.maxTokens || 512, stream: false }; |
| 93 | if (opts.json) body.response_format = { type: "json_object" }; |
| 94 | const headers = { "Content-Type": "application/json" }; |
| 95 | if (this.key) headers.Authorization = "Bearer " + this.key; |
| 96 | const r = await fetch(this.url + "/chat/completions", { method: "POST", headers, body: JSON.stringify(body) }); |
| 97 | if (!r.ok) throw new Error(`${this.url} answered ${r.status}${r.status === 403 || r.status === 0 ? " (does it allow this page's origin? OLLAMA_ORIGINS for Ollama)" : ""}`); |
| 98 | const d = await r.json(); |
| 99 | return (d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content) || ""; |
| 100 | } |
| 101 | async unload() {} |
| 102 | } |
| 103 | |
| 104 | // For tests and demos: fn(messages, opts) -> text, or a list of replies in turn. |
| 105 | class ScriptedEngine { |
| 106 | constructor(fn, opts) { |
| 107 | this.name = "scripted"; this.model = (opts && opts.model) || "scripted"; this.calls = []; |
| 108 | this.fn = typeof fn === "function" ? fn : ((list) => () => (list.length > 1 ? list.shift() : list[0]))(Array.isArray(fn) ? fn.slice() : [String(fn)]); |
| 109 | } |
| 110 | async load() {} |
| 111 | async complete(messages, opts) { this.calls.push({ messages, opts }); const out = await this.fn(messages, opts || {}); return typeof out === "string" ? out : JSON.stringify(out); } |
| 112 | async unload() {} |
| 113 | } |
| 114 | |
| 115 | // A model's JSON, found in what it said: fences, a preamble and a thinking block are its habits. |
| 116 | function parseJSON(text) { |
| 117 | let t = String(text || "").replace(/<think>[\s\S]*?<\/think>/g, "").replace(/```(?:json)?/g, ""); |
| 118 | const start = t.search(/[{[]/); |
| 119 | if (start < 0) return null; |
| 120 | t = t.slice(start); |
| 121 | const open = t[0], close = open === "{" ? "}" : "]"; |
| 122 | let depth = 0, inStr = false, esc = false; |
| 123 | for (let i = 0; i < t.length; i++) { |
| 124 | const c = t[i]; |
| 125 | if (inStr) { if (esc) esc = false; else if (c === "\\") esc = true; else if (c === '"') inStr = false; continue; } |
| 126 | if (c === '"') inStr = true; |
| 127 | else if (c === open) depth++; |
| 128 | else if (c === close && --depth === 0) { try { return JSON.parse(t.slice(0, i + 1)); } catch (e) { return null; } } |
| 129 | } |
| 130 | return null; |
| 131 | } |
| 132 | const stripThinking = (text) => String(text || "").replace(/<think>[\s\S]*?<\/think>/g, "").trim(); |
| 133 | |
| 134 | // ------------------------------------------------------------ storage |
| 135 | // {get(key), set(key, value), remove(key)}, JSON values. The browser's is |
| 136 | // localStorage, per origin; nothing in it leaves the device. |
| 137 | function browserStorage(prefix) { |
| 138 | prefix = prefix || "pyrtan.agents:"; |
| 139 | return { |
| 140 | get(k) { try { return JSON.parse(localStorage.getItem(prefix + k) || "null"); } catch (e) { return null; } }, |
| 141 | set(k, v) { try { localStorage.setItem(prefix + k, JSON.stringify(v)); } catch (e) {} }, |
| 142 | remove(k) { try { localStorage.removeItem(prefix + k); } catch (e) {} }, |
| 143 | }; |
| 144 | } |
| 145 | function memoryStorage() { |
| 146 | const m = new Map(); |
| 147 | return { get: (k) => (m.has(k) ? JSON.parse(m.get(k)) : null), set: (k, v) => { m.set(k, JSON.stringify(v)); }, remove: (k) => { m.delete(k); }, map: m }; |
| 148 | } |
| 149 | |
| 150 | // ------------------------------------------------------------ memory |
| 151 | const NOTES_MAX = 100, PROBES_MAX = 60, CHAT_MAX = 40, NOTE_LEN = 300; |
| 152 | const words = (text) => new Set(String(text || "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2)); |
| 153 | class Memory { |
| 154 | constructor(storage, key, clock) { |
| 155 | this.storage = storage; this.key = key; this.clock = clock || (() => new Date()); |
| 156 | const d = storage.get(key) || {}; |
| 157 | this.notes = Array.isArray(d.notes) ? d.notes : []; this.probes = d.probes && typeof d.probes === "object" ? d.probes : {}; |
| 158 | this.verdicts = d.verdicts && typeof d.verdicts === "object" ? d.verdicts : {}; this.chat = Array.isArray(d.chat) ? d.chat : []; this.seq = d.seq || 0; |
| 159 | } |
| 160 | save() { this.storage.set(this.key, { notes: this.notes, probes: this.probes, verdicts: this.verdicts, chat: this.chat, seq: this.seq }); } |
| 161 | now() { return Math.floor(this.clock().getTime() / 1000); } |
| 162 | // source: "you" (the person), "agent" (the model asked to), "rule" (what happened: a refusal, a verdict). |
| 163 | remember(text, source) { |
| 164 | text = String(text || "").replace(/\s+/g, " ").trim().slice(0, NOTE_LEN); |
| 165 | if (!text) return null; |
| 166 | const same = this.notes.find((n) => n.text.toLowerCase() === text.toLowerCase()); |
| 167 | if (same) { same.at = this.now(); this.save(); return same; } |
| 168 | const note = { id: ++this.seq, text, source: source || "agent", at: this.now() }; |
| 169 | this.notes.push(note); |
| 170 | while (this.notes.length > NOTES_MAX) { |
| 171 | const drop = this.notes.findIndex((n) => n.source !== "you"); // the person's own notes go last |
| 172 | this.notes.splice(drop < 0 ? 0 : drop, 1); |
| 173 | } |
| 174 | this.save(); |
| 175 | return note; |
| 176 | } |
| 177 | forget(id) { |
| 178 | if (id === "all") { const n = this.notes.length; this.notes = []; this.save(); return n; } |
| 179 | const before = this.notes.length; this.notes = this.notes.filter((n) => String(n.id) !== String(id)); this.save(); return before - this.notes.length; |
| 180 | } |
| 181 | clear() { this.notes = []; this.probes = {}; this.verdicts = {}; this.chat = []; this.save(); } |
| 182 | // The notes that share most words with the query; the person's and the newest first among equals. |
| 183 | recall(query, n) { |
| 184 | n = n || 6; |
| 185 | const q = words(query); |
| 186 | const scored = this.notes.map((note) => { let s = 0; for (const w of words(note.text)) if (q.has(w)) s++; return { note, s }; }); |
| 187 | const hits = scored.filter((x) => x.s > 0).sort((a, b) => b.s - a.s || (b.note.source === "you") - (a.note.source === "you") || b.note.at - a.note.at).map((x) => x.note); |
| 188 | const recent = this.notes.slice().sort((a, b) => b.at - a.at).filter((x) => !hits.includes(x)); |
| 189 | return hits.concat(recent).slice(0, n); |
| 190 | } |
| 191 | probed(tree, entry) { |
| 192 | const list = this.probes[tree] || (this.probes[tree] = []); |
| 193 | const i = list.findIndex((p) => p.text.toLowerCase() === entry.text.toLowerCase()); |
| 194 | if (i >= 0) list.splice(i, 1); |
| 195 | list.push(Object.assign({ at: this.now() }, entry)); |
| 196 | while (list.length > PROBES_MAX) list.shift(); |
| 197 | this.save(); |
| 198 | } |
| 199 | tried(tree) { return (this.probes[tree] || []).map((p) => p.text); } |
| 200 | known() { const out = []; for (const list of Object.values(this.probes)) for (const p of list) if (p.read && p.read !== "tie") out.push([p.text, p.read]); return out; } |
| 201 | verdict(key, v, why) { this.verdicts[key] = { v, why: why || "", at: this.now() }; this.save(); } |
| 202 | decided(key) { return this.verdicts[key] ? this.verdicts[key].v : null; } |
| 203 | say(role, content) { this.chat.push({ role, content: String(content).slice(0, 2000), at: this.now() }); while (this.chat.length > CHAT_MAX) this.chat.shift(); this.save(); } |
| 204 | } |
| 205 | |
| 206 | // ------------------------------------------------------------ skills |
| 207 | // A skill is instructions for a job, the way a Claude skill is: a name, a |
| 208 | // description, when it applies, and the text. The built-in ones are the |
| 209 | // jobs themselves; a person's own are added to the job they name (or to |
| 210 | // every job, "always"), and a chat skill can be called as /name. |
| 211 | const JOBS = ["chat", "probe", "meaning", "trigger", "tree"]; |
| 212 | const BUILTIN = [ |
| 213 | { name: "probe", when: "probe", description: "Write sentences that test whether a tree reads what people say", instructions: "Write sentences a real person might type to get what this tree gives. Mix them: plain ones; other words for the same thing; other word orders; other values for each input, including unusual but real ones; a chatty or polite one; one with a small typo. Each sentence is one line, at most 15 words. Never repeat a sentence already tried." }, |
| 214 | { name: "meaning", when: "meaning", description: "Say what a value a decision met means", instructions: "A decision in a deterministic tree met a value none of its branches covers. Say what that value means to the person who asked, in one plain sentence they would want to read. If the tree can still give them an answer, it is a done; if it cannot, it is a fail with a short reason slug (lowercase words joined by dashes). Never invent facts the value does not carry: when you do not know what it means, say what was seen and that the source did not say more." }, |
| 215 | { name: "trigger", when: "trigger", description: "Write a trigger pattern for sentences a tree missed", instructions: "Write the smallest pattern that reads the sentences. A pattern is literal words (case and plurals ignored), [optional words], (one|or|other) for a required choice, and {slot} or {slot:kind} for an input; kinds: text (default), word, int, float, number, date, time, duration, host, url, email. A pattern needs at least one literal word, and every slot must be one of the tree's inputs. Prefer one general pattern over many narrow ones, but never one so loose it would take sentences meant for other trees." }, |
| 216 | { name: "tree", when: "tree", description: "Find the tree a sentence nobody answered needs", instructions: "A person typed a sentence no tree's trigger read. If one of the trees listed gives what they want, name it. If none does, describe in one line the tree that would, and name hosts that might answer it (an API is better than a page); a person checks them before any is listed." }, |
| 217 | { name: "chat", when: "chat", description: "Talk with the person about the swarm", instructions: "Be brief and plain. When the person asks something a tree can answer, ask the swarm instead of answering from what you know, and quote the tree's answer as it is. Say so when you are guessing." }, |
| 218 | ]; |
| 219 | const RESERVED = new Set(["help", "ask", "read", "probe", "gaps", "unanswered", "patrol", "remember", "forget", "memory", "skills", "suggestions", "clear"].concat(BUILTIN.map((s) => s.name))); |
| 220 | function skillProblem(s) { |
| 221 | if (!s || typeof s !== "object") return "a skill is a table"; |
| 222 | if (typeof s.name !== "string" || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(s.name)) return "a skill's name is lowercase letters, digits and dashes, at most 32"; |
| 223 | if (RESERVED.has(s.name)) return `${s.name} is taken by a command or a built-in skill`; |
| 224 | if (!JOBS.concat(["always"]).includes(s.when || "chat")) return `when is one of ${JOBS.join(", ")}, always`; |
| 225 | if (typeof s.instructions !== "string" || !s.instructions.trim() || s.instructions.length > 4000) return "a skill has instructions, at most 4000 characters"; |
| 226 | if (s.description !== undefined && (typeof s.description !== "string" || s.description.length > 200)) return "description is one line"; |
| 227 | return null; |
| 228 | } |
| 229 | // A skill written as Markdown with front matter (name, description, when), as Claude skills are; or JSON. |
| 230 | function parseSkills(text) { |
| 231 | text = String(text || "").trim(); |
| 232 | if (text.startsWith("{") || text.startsWith("[")) { |
| 233 | const d = JSON.parse(text); |
| 234 | return Array.isArray(d) ? d : Array.isArray(d.skills) ? d.skills : [d]; |
| 235 | } |
| 236 | const m = /^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/.exec(text); |
| 237 | if (!m) throw new Error("a skill file starts with --- front matter (name, description, when) or is JSON"); |
| 238 | const s = { instructions: m[2].trim() }; |
| 239 | for (const line of m[1].split("\n")) { const kv = /^(\w+)\s*:\s*(.*)$/.exec(line.trim()); if (kv) s[kv[1]] = kv[2].replace(/^["']|["']$/g, "").trim(); } |
| 240 | return [s]; |
| 241 | } |
| 242 | class Skills { |
| 243 | constructor(storage, key) { this.storage = storage; this.key = key || "skills"; const d = storage.get(this.key); this.mine = Array.isArray(d) ? d.filter((s) => !skillProblem(s)) : []; } |
| 244 | save() { this.storage.set(this.key, this.mine); } |
| 245 | list() { return BUILTIN.map((s) => Object.assign({ builtin: true, on: true }, s)).concat(this.mine.map((s) => Object.assign({ builtin: false, on: s.on !== false, when: s.when || "chat" }, s))); } |
| 246 | get(name) { return this.list().find((s) => s.name === name) || null; } |
| 247 | add(s) { |
| 248 | const skill = { name: String(s.name || "").trim().toLowerCase(), description: String(s.description || "").trim(), when: s.when || "chat", instructions: String(s.instructions || "").trim(), on: s.on !== false }; |
| 249 | const bad = skillProblem(skill); |
| 250 | if (bad) throw new Error(bad); |
| 251 | this.mine = this.mine.filter((x) => x.name !== skill.name).concat([skill]); |
| 252 | this.save(); |
| 253 | return skill; |
| 254 | } |
| 255 | remove(name) { const before = this.mine.length; this.mine = this.mine.filter((s) => s.name !== name); this.save(); return before !== this.mine.length; } |
| 256 | toggle(name, on) { const s = this.mine.find((x) => x.name === name); if (s) { s.on = on; this.save(); } return !!s; } |
| 257 | import(text) { return parseSkills(text).map((s) => this.add(s)); } |
| 258 | export() { return JSON.stringify({ "pyrtan-skills": 1, skills: this.mine }, null, 2); } |
| 259 | // The instructions for a job: the built-in one, then the person's that are on. |
| 260 | for(job) { |
| 261 | const own = BUILTIN.find((s) => s.when === job); |
| 262 | const extra = this.mine.filter((s) => s.on !== false && (s.when === job || s.when === "always")); |
| 263 | return [own ? own.instructions : ""].concat(extra.map((s) => `Skill ${s.name}${s.description ? " (" + s.description + ")" : ""}: ${s.instructions}`)).filter(Boolean).join("\n\n"); |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | // ------------------------------------------------------------ reading, as the swarm reads |
| 268 | function candidatesOf(caps, extra) { |
| 269 | const out = []; |
| 270 | for (const t of caps.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { |
| 271 | let lang; |
| 272 | try { lang = Pyrtan.intent.language(t.language || null); } catch (e) { lang = undefined; } |
| 273 | const fixed = t.trigger_inputs || {}; |
| 274 | const patterns = (t.triggers || []).concat(extra && extra.tree === t.name ? [extra.pattern] : []); |
| 275 | for (const p of patterns) { try { out.push([t.name, new Pyrtan.intent.Trigger(p, fixed[p] || {}, lang)]); } catch (e) { /* a pattern the page cannot read is the swarm's problem */ } } |
| 276 | } |
| 277 | return out; |
| 278 | } |
| 279 | function brief(t) { |
| 280 | const inputs = Object.entries(t.inputs || {}).map(([n, s]) => `${n} (${s.type || "str"}${s.required ? ", required" : ""}${s.description ? ": " + s.description : ""})`); |
| 281 | return [`Tree ${t.name}: ${t.description || "no description"}`, `Answers to: ${(t.triggers || []).map((p) => JSON.stringify(p)).join(", ") || "nothing yet"}`, `Inputs: ${inputs.join("; ") || "none"}`].join("\n"); |
| 282 | } |
| 283 | const pathOf = (out) => (out && out.trace && out.trace.steps ? out.trace.steps.filter((s) => !s.depth).map((s) => s.node) : []); |
| 284 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 285 | const slug = (text, n) => String(text || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, n || 40).replace(/-+$/, ""); |
| 286 | |
| 287 | // ------------------------------------------------------------ the agent |
| 288 | class Agent { |
| 289 | // opts.hub(method, path, body) -> payload: the swarm, through the console's own calls. |
| 290 | // opts.raw(name) -> a tree's table, when the page holds it (a browser that is its own hub). |
| 291 | constructor(opts) { |
| 292 | opts = opts || {}; |
| 293 | if (!opts.engine) throw new Error("an agent needs an engine"); |
| 294 | if (!opts.hub) throw new Error("an agent needs a hub to ask"); |
| 295 | this.name = slug(opts.name || "scout", 32) || "scout"; |
| 296 | this.engine = opts.engine; this.hub = opts.hub; this.raw = opts.raw || (() => null); |
| 297 | this.storage = opts.storage || memoryStorage(); |
| 298 | this.clock = opts.clock || (() => new Date()); |
| 299 | this.memory = new Memory(this.storage, `agent:${this.name}`, this.clock); |
| 300 | this.skills = opts.skills || new Skills(this.storage); |
| 301 | this.limits = Object.assign({ probes: 6, runs: 4, gaps: 6, unanswered: 4, actions: 3, pause: 300 }, opts.limits || {}); |
| 302 | this.onEvent = opts.onEvent || (() => {}); |
| 303 | this.caps = null; this.suggestions = []; this.stopping = false; this.seq = 0; |
| 304 | } |
| 305 | by() { return { agent: this.name, model: String(this.engine.model || ""), engine: String(this.engine.name || "") }; } |
| 306 | emit(type, data) { try { this.onEvent(Object.assign({ type, agent: this.name }, data || {})); } catch (e) {} } |
| 307 | stop() { this.stopping = true; } |
| 308 | |
| 309 | async trees(fresh) { |
| 310 | if (!this.caps || fresh) { const c = await this.hub("GET", "capabilities"); this.caps = (c && c.trees) || []; } |
| 311 | return this.caps; |
| 312 | } |
| 313 | tree(name) { return (this.caps || []).find((t) => t.name === name) || null; } |
| 314 | // How the swarm reads a sentence, here: {tree (null for none, "tie" for a tie), inputs}. |
| 315 | read(text, extra) { |
| 316 | const r = Pyrtan.read(text, candidatesOf(this.caps || [], extra), this.clock()); |
| 317 | const w = r.winner; |
| 318 | return { tree: w ? w.tree : r.matches.length ? "tie" : null, inputs: w ? w.inputs : {}, cleaned: r.cleaned, ties: w ? [] : r.matches.map((m) => m.tree) }; |
| 319 | } |
| 320 | |
| 321 | // One call to the model for a job, with the job's skills and what it remembers. |
| 322 | async think(job, task, opts) { |
| 323 | opts = opts || {}; |
| 324 | const notes = this.memory.recall(task, 6); |
| 325 | const system = [ |
| 326 | `You are ${this.name}, an agent beside a Pyrtan swarm: a set of deterministic decision trees that answer sentences. You are a model (${this.engine.model || "open weights"}); the trees are not, and you never answer for them. You suggest; a person decides.`, |
| 327 | this.skills.for(job), |
| 328 | notes.length ? "What you remember:\n" + notes.map((n) => `- ${n.text}`).join("\n") : "", |
| 329 | opts.json ? "Reply with JSON only, no other text." : "", |
| 330 | ].filter(Boolean).join("\n\n"); |
| 331 | const messages = [{ role: "system", content: system }].concat(opts.history || [], [{ role: "user", content: task }]); |
| 332 | this.emit("thinking", { job }); |
| 333 | const text = await this.engine.complete(messages, { json: !!opts.json, temperature: opts.temperature, maxTokens: opts.maxTokens }); |
| 334 | return opts.json ? parseJSON(text) : stripThinking(text); |
| 335 | } |
| 336 | |
| 337 | // A suggestion that passed the rules, kept for the person to accept or dismiss. |
| 338 | offer(s, preview) { |
| 339 | const key = Pyrtan.grow.key(s); |
| 340 | if (this.memory.decided(key)) return null; |
| 341 | const found = this.suggestions.find((x) => x.key === key && x.status === "new"); |
| 342 | if (found) return found; |
| 343 | const item = { id: ++this.seq, key, status: "new", suggestion: s, preview: preview || null }; |
| 344 | this.suggestions.push(item); |
| 345 | this.emit("suggestion", { item }); |
| 346 | return item; |
| 347 | } |
| 348 | refuse(what, why) { this.memory.remember(`refused: ${what}: ${why}`, "rule"); this.emit("refused", { what, why }); return { refused: what, why }; } |
| 349 | decide(id, verdict, why) { |
| 350 | const item = this.suggestions.find((x) => x.id === id); |
| 351 | if (!item) return null; |
| 352 | item.status = verdict; |
| 353 | this.memory.verdict(item.key, verdict, why); |
| 354 | const s = item.suggestion; |
| 355 | const what = s.kind === "branch" ? `the meaning "${s.meaning.say}" for ${s.tree}.${s.node} = ${JSON.stringify(s.observed)}` : s.kind === "trigger" ? `the pattern "${s.pattern}" for ${s.tree}` : `a new tree for "${s.text}"`; |
| 356 | this.memory.remember(`the person ${verdict} ${what}${why ? ": " + why : ""}`, "rule"); |
| 357 | return item; |
| 358 | } |
| 359 | async send(items) { |
| 360 | items = (items || this.suggestions.filter((x) => x.status === "accepted")).filter(Boolean); |
| 361 | if (!items.length) return { kept: 0, refused: [] }; |
| 362 | const out = await this.hub("POST", "suggestions", { suggestions: items.map((x) => x.suggestion) }); |
| 363 | for (const x of items) if (!(out.refused || []).some((r) => r.index === items.indexOf(x))) x.sent = true; |
| 364 | return out; |
| 365 | } |
| 366 | |
| 367 | // ---- probing a tree |
| 368 | async probe(name, count) { |
| 369 | await this.trees(); |
| 370 | const t = this.tree(name); |
| 371 | if (!t) throw new Error(`no tree named ${name} here`); |
| 372 | count = Math.max(1, Math.min(Number(count) || this.limits.probes, 12)); |
| 373 | const tried = this.memory.tried(name); |
| 374 | const task = `${brief(t)}\n\n${tried.length ? "Already tried, do not repeat:\n" + tried.slice(-20).map((s) => "- " + s).join("\n") + "\n\n" : ""}Write ${count} new sentences for this tree. Reply as {"sentences": ["...", "..."]}.`; |
| 375 | const got = await this.think("probe", task, { json: true, temperature: 0.9 }); |
| 376 | const seen = new Set(tried.map((s) => s.toLowerCase())); |
| 377 | const sentences = (got && Array.isArray(got.sentences) ? got.sentences : []).filter((s) => typeof s === "string").map((s) => s.trim()).filter((s) => s && s.length <= 200 && !seen.has(s.toLowerCase()) && seen.add(s.toLowerCase())).slice(0, count); |
| 378 | if (!sentences.length) { this.emit("note", { text: `the model wrote no usable sentences for ${name}` }); return { tree: name, results: [], suggestions: [] }; } |
| 379 | const results = [], gaps = []; |
| 380 | let runs = 0; |
| 381 | for (const text of sentences) { |
| 382 | if (this.stopping) break; |
| 383 | const r = Object.assign({ text }, this.read(text)); |
| 384 | r.read = r.tree; delete r.tree; |
| 385 | if (r.read === name && runs < this.limits.runs) { |
| 386 | runs++; |
| 387 | let out; |
| 388 | try { out = await this.hub("POST", "intent", { text }); } catch (e) { out = { status: "error", reason: "unreachable", say: String(e.message || e) }; } |
| 389 | Object.assign(r, { status: out.status, reason: out.reason || null, say: out.say || "", path: pathOf(out) }); |
| 390 | for (const g of (out.trace && out.trace.gaps) || []) if (g.tree === name) gaps.push(g); |
| 391 | if (this.limits.pause) await sleep(this.limits.pause); |
| 392 | } |
| 393 | this.memory.probed(name, { text, read: r.read, status: r.status || null, reason: r.reason || null }); |
| 394 | results.push(r); |
| 395 | this.emit("probe", { tree: name, result: r }); |
| 396 | } |
| 397 | const offered = []; |
| 398 | const unread = results.filter((r) => r.read === null).map((r) => r.text); |
| 399 | for (const r of results) if (r.read && r.read !== name) this.memory.remember(`"${r.text}" was written for ${name} but reads as ${r.read === "tie" ? "a tie between " + r.ties.join(" and ") : r.read}`, "rule"); |
| 400 | if (unread.length && !this.stopping) offered.push(...await this.triggerFor(name, unread)); |
| 401 | for (const g of gaps) { if (this.stopping) break; const m = await this.meaning(g); if (m && m.id) offered.push(m); } |
| 402 | return { tree: name, results, suggestions: offered }; |
| 403 | } |
| 404 | |
| 405 | // ---- a pattern for sentences a tree missed, kept only if the reading says it works |
| 406 | async triggerFor(name, sentences) { |
| 407 | await this.trees(); |
| 408 | const t = this.tree(name); |
| 409 | if (!t) return []; |
| 410 | const inputs = Object.keys(t.inputs || {}); |
| 411 | const task = `${brief(t)}\n\nThese sentences were meant for this tree, but no trigger reads them:\n${sentences.map((s) => "- " + s).join("\n")}\n\nWrite at most 2 patterns that would. Slots may only be: ${inputs.map((n) => "{" + n + "}").join(", ") || "none (write literal words only)"}. Reply as {"patterns": [{"pattern": "...", "covers": ["...the sentences it reads..."]}], "why": "..."}.`; |
| 412 | const got = await this.think("trigger", task, { json: true, temperature: 0.3 }); |
| 413 | const out = []; |
| 414 | for (const p of (got && Array.isArray(got.patterns) ? got.patterns : []).slice(0, 2)) { |
| 415 | const pattern = p && typeof p.pattern === "string" ? p.pattern.trim() : ""; |
| 416 | const why = got && typeof got.why === "string" ? got.why.slice(0, 500) : ""; |
| 417 | const checked = this.checkTrigger(name, pattern, sentences); |
| 418 | if (checked.why) { out.push(this.refuse(`the pattern "${pattern}" for ${name}`, checked.why)); continue; } |
| 419 | const s = { kind: "trigger", tree: name, pattern, covers: checked.covers, why, by: this.by(), evidence: checked.covers.map((text) => ({ text, read: null, inputs: checked.inputs[text] })) }; |
| 420 | const bad = Pyrtan.grow.problem(s); |
| 421 | if (bad) { out.push(this.refuse(`the pattern "${pattern}" for ${name}`, bad)); continue; } |
| 422 | const item = this.offer(s, { text: `[[triggers]]\npattern = ${JSON.stringify(pattern)}`, reads: checked.inputs }); |
| 423 | if (item) out.push(item); |
| 424 | } |
| 425 | return out; |
| 426 | } |
| 427 | // The rules a pattern must pass: it compiles, names only the tree's inputs, reads at least one sentence to the tree, and takes no sentence from another tree. |
| 428 | checkTrigger(name, pattern, sentences) { |
| 429 | const t = this.tree(name); |
| 430 | if (!pattern) return { why: "no pattern" }; |
| 431 | let trig; |
| 432 | try { trig = new Pyrtan.intent.Trigger(pattern, {}, Pyrtan.intent.language(t.language || null)); } catch (e) { return { why: e.message }; } |
| 433 | const unknown = Object.keys(trig.slots).filter((s) => !(s in (t.inputs || {}))); |
| 434 | if (unknown.length) return { why: `it names ${unknown.join(", ")}, which ${name} does not take as inputs` }; |
| 435 | if ((t.triggers || []).includes(pattern)) return { why: `${name} answers to it already` }; |
| 436 | const extra = { tree: name, pattern }; |
| 437 | const covers = [], inputs = {}; |
| 438 | for (const s of sentences) { const r = this.read(s, extra); if (r.tree === name) { covers.push(s); inputs[s] = r.inputs; } } |
| 439 | if (!covers.length) return { why: "with it, none of the sentences reads to " + name }; |
| 440 | for (const [text, tree] of this.memory.known()) { |
| 441 | const r = this.read(text, extra); |
| 442 | if (r.tree !== tree && this.read(text).tree === tree) return { why: `it would take "${text}" from ${tree}` }; |
| 443 | } |
| 444 | return { covers, inputs }; |
| 445 | } |
| 446 | |
| 447 | // ---- what a gap's value means |
| 448 | async meaning(gap) { |
| 449 | await this.trees(); |
| 450 | const t = this.tree(gap.tree); |
| 451 | if (!t) return null; |
| 452 | const base = { kind: "branch", tree: gap.tree, node: gap.node, on: gap.on || null, observed: gap.observed === undefined ? null : gap.observed }; |
| 453 | if (this.memory.decided(Pyrtan.grow.key(base)) || this.suggestions.some((x) => x.key === Pyrtan.grow.key(base))) return null; |
| 454 | const raw = this.raw(gap.tree); |
| 455 | const draft = raw ? Pyrtan.grow.draftBranch(raw, gap) : null; |
| 456 | if (raw && !draft) return null; // a branch covers it now |
| 457 | let context = ""; |
| 458 | if (raw && raw.nodes && raw.nodes[gap.node]) { |
| 459 | const lines = (raw.nodes[gap.node].branches || []).map((b) => { |
| 460 | const target = raw.nodes[b.then || b.else] || {}; |
| 461 | return `- ${b.else ? "anything else" : JSON.stringify(b.when)} -> ${b.then || b.else} (${target.kind || "?"}${target.say ? ': "' + target.say + '"' : ""})`; |
| 462 | }); |
| 463 | context = `\nIts branches now:\n${lines.join("\n")}`; |
| 464 | } |
| 465 | const task = `${brief(t)}\n\nThe decision "${gap.node}" reads ${gap.on} and met ${JSON.stringify(gap.observed)}, which no branch covers.${context}\n\nSay what ${JSON.stringify(gap.observed)} means here. The say may use {{ inputs.NAME }} for these inputs: ${Object.keys(t.inputs || {}).join(", ") || "none"}${raw ? ", and {{ state.NAME }} for what the tree observed" : ""}; nothing else. Reply as {"kind": "done" or "fail", "say": "...", "reason": "a-slug", "why": "..."}.`; |
| 466 | const got = await this.think("meaning", task, { json: true, temperature: 0.3 }); |
| 467 | if (!got || typeof got !== "object") return this.refuse(`a meaning for ${gap.tree}.${gap.node} = ${JSON.stringify(gap.observed)}`, "the model's reply was not JSON"); |
| 468 | const meaning = { kind: got.kind === "done" ? "done" : "fail", say: typeof got.say === "string" ? got.say.trim().slice(0, 300) : "" }; |
| 469 | if (meaning.kind === "fail") meaning.reason = slug(got.reason || got.say, 40) || "unhandled"; |
| 470 | const s = Object.assign({}, base, { meaning, why: typeof got.why === "string" ? got.why.slice(0, 500) : "", by: this.by(), evidence: [{ gap: { node: gap.node, on: gap.on, observed: gap.observed } }] }); |
| 471 | const bad = Pyrtan.grow.problem(s); |
| 472 | if (bad) return this.refuse(`the meaning "${meaning.say}" for ${gap.tree}.${gap.node}`, bad); |
| 473 | let preview = null; |
| 474 | if (draft) { |
| 475 | try { new Pyrtan.Tree(Pyrtan.grow.growTree(raw, draft, meaning)); } catch (e) { return this.refuse(`the meaning for ${gap.tree}.${gap.node}`, `the tree would not load: ${e.message}`); } |
| 476 | preview = { draft, text: `# in [nodes.${draft.node}], before the else:\n ${draft.branch}\n\n[nodes.${draft.then}]\nkind = "${meaning.kind}"${meaning.reason ? `\nreason = "${meaning.reason}"` : ""}\nsay = ${JSON.stringify(meaning.say)}` }; |
| 477 | } |
| 478 | return this.offer(s, preview); |
| 479 | } |
| 480 | async meanings(limit) { |
| 481 | const g = await this.hub("GET", "gaps"); |
| 482 | const out = []; |
| 483 | for (const gap of ((g && g.gaps) || []).slice(0, limit || this.limits.gaps)) { if (this.stopping) break; const m = await this.meaning(gap); if (m) out.push(m); } |
| 484 | return out; |
| 485 | } |
| 486 | |
| 487 | // ---- sentences nobody answered |
| 488 | async unanswered(limit) { |
| 489 | await this.trees(); |
| 490 | const u = await this.hub("GET", "unanswered"); |
| 491 | const out = []; |
| 492 | for (const e of ((u && u.unanswered) || []).slice(0, limit || this.limits.unanswered)) { |
| 493 | if (this.stopping) break; |
| 494 | const asTree = { kind: "tree", text: e.text }; |
| 495 | if (this.memory.decided(Pyrtan.grow.key(asTree))) continue; |
| 496 | const list = (this.caps || []).map((t) => `- ${t.name}: ${t.description || ""}`).join("\n"); |
| 497 | const got = await this.think("tree", `The trees:\n${list || "(none)"}\n\nA person typed: ${JSON.stringify(e.text)} (${e.count || 1} times). No trigger read it. Reply as {"tree": "a name above, or null", "description": "if null: one line for the tree that would", "hosts": ["..."], "why": "..."}.`, { json: true, temperature: 0.3 }); |
| 498 | if (!got) { out.push(this.refuse(`a tree for "${e.text}"`, "the model's reply was not JSON")); continue; } |
| 499 | if (got.tree && this.tree(String(got.tree))) { out.push(...await this.triggerFor(String(got.tree), [e.text])); continue; } |
| 500 | const s = { kind: "tree", text: e.text, description: typeof got.description === "string" ? got.description.trim().slice(0, 200) : "", hosts_hint: (Array.isArray(got.hosts) ? got.hosts : []).filter((h) => typeof h === "string").map((h) => h.trim().slice(0, 100)).slice(0, 5), why: typeof got.why === "string" ? got.why.slice(0, 500) : "", by: this.by(), evidence: [{ text: e.text, count: e.count || 1 }] }; |
| 501 | const bad = Pyrtan.grow.problem(s); |
| 502 | if (bad) { out.push(this.refuse(`a tree for "${e.text}"`, bad)); continue; } |
| 503 | const p = Pyrtan.propose(e.text); |
| 504 | const item = this.offer(s, { text: `${p.scaffold}\n# name ${p.name}, answering "${p.trigger}"${s.description ? "\n# " + s.description : ""}` }); |
| 505 | if (item) out.push(item); |
| 506 | } |
| 507 | return out; |
| 508 | } |
| 509 | |
| 510 | // ---- everything, once: each tree probed, then the gaps, then the unanswered |
| 511 | async patrol(opts) { |
| 512 | opts = opts || {}; |
| 513 | this.stopping = false; |
| 514 | await this.trees(true); |
| 515 | const names = (opts.trees || this.caps.map((t) => t.name)).slice(0, opts.maxTrees || 12); |
| 516 | const report = { agent: this.name, model: this.by().model, started: Math.floor(this.clock().getTime() / 1000), trees: [], suggestions: [], refused: [] }; |
| 517 | const take = (list) => { for (const x of list || []) { if (x && x.refused) report.refused.push(x); else if (x) report.suggestions.push(x); } }; |
| 518 | for (const name of names) { |
| 519 | if (this.stopping) break; |
| 520 | this.emit("patrol", { step: "probe", tree: name }); |
| 521 | try { const p = await this.probe(name, opts.probes); report.trees.push({ tree: name, results: p.results }); take(p.suggestions); } |
| 522 | catch (e) { report.trees.push({ tree: name, error: String(e.message || e) }); } |
| 523 | } |
| 524 | if (!this.stopping) { this.emit("patrol", { step: "gaps" }); try { take(await this.meanings()); } catch (e) { report.error = String(e.message || e); } } |
| 525 | if (!this.stopping) { this.emit("patrol", { step: "unanswered" }); try { take(await this.unanswered()); } catch (e) { report.error = String(e.message || e); } } |
| 526 | report.stopped = this.stopping; this.stopping = false; |
| 527 | const tally = report.trees.reduce((a, t) => { for (const r of t.results || []) { a.sentences++; if (r.read === t.tree) a.read++; if (r.read === null) a.unread++; if (r.read && r.read !== t.tree) a.misread++; if (r.reason === "gap") a.gaps++; } return a; }, { sentences: 0, read: 0, unread: 0, misread: 0, gaps: 0 }); |
| 528 | report.tally = tally; |
| 529 | this.memory.remember(`patrol: ${tally.sentences} sentences over ${report.trees.length} trees; ${tally.read} read, ${tally.unread} unread, ${tally.misread} misread, ${tally.gaps} gaps; ${report.suggestions.length} suggestions`, "rule"); |
| 530 | this.emit("patrol", { step: "done", report }); |
| 531 | return report; |
| 532 | } |
| 533 | |
| 534 | // ---- talking with the person |
| 535 | // "/command args" is done by rules; anything else goes to the model, |
| 536 | // which may end its reply with lines the agent acts on: ASK: a sentence |
| 537 | // for the swarm, REMEMBER: a note, PROBE: a tree. |
| 538 | async chat(text) { |
| 539 | text = String(text || "").trim(); |
| 540 | if (!text) return { say: "" }; |
| 541 | if (text.startsWith("/")) return this.command(text); |
| 542 | return this.converse(text, null); |
| 543 | } |
| 544 | async converse(text, skill) { |
| 545 | await this.trees(); |
| 546 | const job = "chat"; |
| 547 | const list = (this.caps || []).slice(0, 30).map((t) => `- ${t.name}: ${t.description || ""} (e.g. ${JSON.stringify((t.triggers || [])[0] || "")})`).join("\n"); |
| 548 | const task = [ |
| 549 | `The trees this swarm holds:\n${list || "(none yet)"}`, |
| 550 | skill ? `Use the skill ${skill.name} now: ${skill.instructions}` : "", |
| 551 | `To act, end your reply with lines of their own (at most ${this.limits.actions}):\nASK: <a sentence for the swarm, when a tree can answer it>\nREMEMBER: <a short note worth keeping across conversations>\nPROBE: <a tree name, to test it with sentences of your own>`, |
| 552 | `The person says: ${text}`, |
| 553 | ].filter(Boolean).join("\n\n"); |
| 554 | const history = this.memory.chat.slice(-12).map((m) => ({ role: m.role === "you" ? "user" : "assistant", content: m.content })); |
| 555 | this.memory.say("you", text); |
| 556 | const reply = await this.think(job, task, { history, temperature: 0.5, maxTokens: 400 }); |
| 557 | const actions = []; |
| 558 | const say = reply.split("\n").filter((line) => { |
| 559 | const m = /^\s*(ASK|REMEMBER|PROBE)\s*:\s*(.+?)\s*$/i.exec(line); |
| 560 | if (!m) return true; |
| 561 | if (actions.length < this.limits.actions) actions.push({ kind: m[1].toLowerCase(), input: m[2].replace(/^["']|["']$/g, "") }); |
| 562 | return false; |
| 563 | }).join("\n").trim(); |
| 564 | const answers = []; |
| 565 | for (const a of actions) { |
| 566 | if (a.kind === "remember") { a.result = this.memory.remember(a.input, "agent"); } |
| 567 | else if (a.kind === "ask") { |
| 568 | try { const out = await this.hub("POST", "intent", { text: a.input }); a.result = { status: out.status, tree: out.tree, reason: out.reason || null, say: out.say || "", path: pathOf(out) }; } catch (e) { a.result = { status: "error", say: String(e.message || e) }; } |
| 569 | answers.push(`${JSON.stringify(a.input)} -> ${a.result.tree || "no tree"} (${a.result.status}${a.result.reason ? ", " + a.result.reason : ""}): ${a.result.say}`); |
| 570 | } else if (a.kind === "probe") { |
| 571 | if (this.tree(a.input)) { const p = await this.probe(a.input); a.result = { probed: p.results.length, suggestions: p.suggestions.filter((x) => x && x.id).length }; } |
| 572 | else a.result = { error: `no tree named ${a.input}` }; |
| 573 | } |
| 574 | this.emit("action", { action: a }); |
| 575 | } |
| 576 | let final = say; |
| 577 | if (answers.length) { |
| 578 | final = await this.think(job, `You asked the swarm, and its trees answered (these are their words, not yours):\n${answers.join("\n")}\n\nThe person had said: ${text}\nYou had said: ${say || "(nothing)"}\nNow answer the person in one to three sentences. Quote a tree's answer as it is; if no tree answered, say so.`, { history, temperature: 0.3, maxTokens: 300 }); |
| 579 | final = final.split("\n").filter((l) => !/^\s*(ASK|REMEMBER|PROBE)\s*:/i.test(l)).join("\n").trim(); |
| 580 | } |
| 581 | this.memory.say("agent", final || "(nothing)"); |
| 582 | return { say: final, actions }; |
| 583 | } |
| 584 | async command(text) { |
| 585 | const [, cmd, rest] = /^\/(\S+)\s*([\s\S]*)$/.exec(text) || [null, "", ""]; |
| 586 | const arg = rest.trim(); |
| 587 | const done = (say, extra) => Object.assign({ say, command: cmd }, extra || {}); |
| 588 | switch (cmd) { |
| 589 | case "help": return done(["/ask SENTENCE: ask the swarm (a tree answers, not me)", "/read SENTENCE: how the swarm reads it, without running anything", "/probe TREE [N]: test a tree with sentences of my own", "/gaps: say what each gap's value means", "/unanswered: find the tree each unanswered sentence needs", "/patrol: all of it, every tree", "/suggestions: what I have suggested", "/remember NOTE, /memory, /forget ID|all: what I keep", "/skills: my skills; /NAME TEXT uses a chat skill of yours", "/clear: forget this conversation (notes stay)"].join("\n")); |
| 590 | case "ask": { |
| 591 | if (!arg) return done("Ask what? /ask what's the weather in Oslo"); |
| 592 | const out = await this.hub("POST", "intent", { text: arg }); |
| 593 | return done(`${out.tree || "no tree"} (${out.status}${out.reason ? ", " + out.reason : ""}): ${out.say || ""}`, { outcome: out }); |
| 594 | } |
| 595 | case "read": { |
| 596 | await this.trees(); |
| 597 | const r = this.read(arg); |
| 598 | return done(r.tree === null ? `No trigger reads ${JSON.stringify(arg)} (read as ${JSON.stringify(r.cleaned)}).` : r.tree === "tie" ? `A tie between ${r.ties.join(" and ")}.` : `${r.tree}, with ${JSON.stringify(r.inputs)} (read as ${JSON.stringify(r.cleaned)}).`, { reading: r }); |
| 599 | } |
| 600 | case "probe": { |
| 601 | await this.trees(); |
| 602 | const [name, n] = arg.split(/\s+/); |
| 603 | if (!name || !this.tree(name)) return done(`Probe which tree? ${(this.caps || []).map((t) => t.name).join(", ")}`); |
| 604 | const p = await this.probe(name, n); |
| 605 | const read = p.results.filter((r) => r.read === name).length; |
| 606 | return done(`${p.results.length} sentences for ${name}: ${read} read to it, ${p.results.filter((r) => r.read === null).length} read to nothing, ${p.results.filter((r) => r.read && r.read !== name).length} to something else. ${p.suggestions.filter((x) => x && x.id).length} suggestions.`, { probe: p }); |
| 607 | } |
| 608 | case "gaps": { const m = await this.meanings(); return done(`${m.filter((x) => x && x.id).length} meanings suggested, ${m.filter((x) => x && x.refused).length} refused by the rules.`, { suggestions: m }); } |
| 609 | case "unanswered": { const m = await this.unanswered(); return done(`${m.filter((x) => x && x.id).length} suggestions, ${m.filter((x) => x && x.refused).length} refused by the rules.`, { suggestions: m }); } |
| 610 | case "patrol": { const r = await this.patrol(); return done(`Patrolled ${r.trees.length} trees: ${r.tally.sentences} sentences, ${r.tally.read} read, ${r.tally.unread} unread, ${r.tally.misread} misread, ${r.tally.gaps} gaps. ${r.suggestions.length} suggestions, ${r.refused.length} refused.`, { report: r }); } |
| 611 | case "suggestions": { const open = this.suggestions.filter((x) => x.status === "new"); return done(open.length ? open.map((x) => `#${x.id} ${x.suggestion.kind} ${x.suggestion.tree || x.suggestion.text}: ${x.suggestion.pattern || (x.suggestion.meaning && x.suggestion.meaning.say) || x.suggestion.description || ""}`).join("\n") : "Nothing waiting."); } |
| 612 | case "remember": { const n = this.memory.remember(arg, "you"); return done(n ? `Kept #${n.id}.` : "Remember what?"); } |
| 613 | case "memory": { const notes = this.memory.notes.slice(-30); return done(notes.length ? notes.map((n) => `#${n.id} (${n.source}) ${n.text}`).join("\n") : "Nothing kept yet."); } |
| 614 | case "forget": { const n = this.memory.forget(arg || "all"); return done(`Forgot ${n} note${n === 1 ? "" : "s"}.`); } |
| 615 | case "clear": { this.memory.chat = []; this.memory.save(); return done("This conversation is forgotten; the notes are kept."); } |
| 616 | case "skills": return done(this.skills.list().map((s) => `${s.builtin ? "built-in" : s.on ? "on" : "off"} ${s.name} (${s.when}): ${s.description || ""}`).join("\n")); |
| 617 | default: { |
| 618 | const skill = this.skills.get(cmd); |
| 619 | if (skill && !skill.builtin && skill.on && (skill.when === "chat" || skill.when === "always")) return this.converse(arg || skill.description || skill.name, skill); |
| 620 | return done(`No command or chat skill /${cmd}. /help lists them.`); |
| 621 | } |
| 622 | } |
| 623 | } |
| 624 | } |
| 625 | |
| 626 | return { Agent, Memory, Skills, WebLLMEngine, OpenAIEngine, ScriptedEngine, MODELS, DEFAULT_MODEL, WEBLLM_URL, BUILTIN, JOBS, parseJSON, parseSkills, skillProblem, browserStorage, memoryStorage, candidatesOf }; |
| 627 | }); |