pyrtan / sourcev0.19.0 · main

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