eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// The conversation-flow engine: walks a flow definition (pure data — see
2// docs/conversation-scripts.md) and drives one of Eleven's scripted
3// conversations through an injected `io`. The engine owns control flow only;
4// FEEL (typing beats, the reply queue, persistence dedupe, spent chips) lives
5// in eleven.js behind io.say, and every side effect goes through io.verb — the
6// closed bridge-verb contract. Nothing here touches the DOM or the network, so
7// the whole engine runs (and is tested) in Node with a fake io.
8//
9// io = {
10// say(env, text, chips, extra) — one Eleven line (eleven.js makeSay shape)
11// ask(threadId, cb) — next composer message → cb(text, payload)
12// cancelAnswer(threadId) — disarm a pending ask
13// verb(name, args, env) — run a bridge verb; resolves to its result
14// record(step, label, value) — persist a completed step (the ✓ line)
15// done(step) — is a step already recorded done?
16// imgSource(token) — resolve an image token ("$serverAvatar"…)
17// delay(ms, fn) — a cancellable timer (probes); tests fire it
18// }
19
20// ---- input.confirm: an answer that reads as something else ---------------
21// A name step accepts anything — which is right (people are called all sorts)
22// but means an email address typed into "what should everyone here call you?"
23// sails through and becomes the name the whole space sees. `input.confirm`
24// asks first: nothing is recorded and no effect runs until the person says
25// yes, and the flow author writes the confirming step like any other.
26// Whole-string only, so "Ada @ the shop" stays a name.
27// Twin: looksLikeEmail in FlowEngine.swift (native runs the same flows).
28export function looksLikeEmail(s) {
29 return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test((s || "").trim());
30}
31
32// confirmNeeded: does this answer trip the step's confirm rule? An unknown
33// `when` never fires — a flow document from a newer build must degrade to
34// today's behaviour, not to a step nobody can leave.
35export function confirmNeeded(confirm, answer) {
36 if (!confirm || !confirm.goto) return false;
37 return confirm.when === "email" && looksLikeEmail(answer);
38}
39
40// ---- the name-step answer cleaner (input.clean === "name") ---------------
41// People answer a conversational "what should folks here call you?"
42// conversationally — "I'm Dermot", "hi, my name is Ana." — and the raw reply
43// used to become their screen name verbatim (a real member spent days as
44// "I'm Dermot"). Strip one leading greeting, one leading preamble, and any
45// trailing punctuation; if stripping eats everything, keep the trimmed
46// original — never clean a name to nothing. Prefix lists are matched on a
47// lowercased, straight-apostrophe copy (iOS keyboards type ’), longest
48// preamble first so "i'm called" wins over "i'm".
49// Twin: FlowEngine.cleanNameAnswer (native runs the same flows).
50const NAME_GREETINGS = ["hiya", "howdy", "hello", "hey", "hi"];
51const NAME_PREAMBLES = [
52 "you can call me", "everyone calls me", "people call me", "they call me",
53 "i'm called", "i am called", "my name is", "my name's", "the name's",
54 "name is", "name's", "call me", "i am", "i'm", "im", "it's", "its",
55];
56
57export function cleanNameAnswer(raw) {
58 const original = (raw || "").trim();
59 let s = original;
60 const lower = () => s.toLowerCase().replaceAll("’", "'");
61 for (const g of NAME_GREETINGS) {
62 if (!lower().startsWith(g)) continue;
63 let i = g.length; // greeting must end at punctuation-or-space ("hi," yes, "hilary" no)
64 if (i < s.length && ",.!".includes(s[i])) i++;
65 if (i < s.length && s[i] === " ") s = s.slice(i + 1).trim();
66 break;
67 }
68 const l = lower();
69 for (const p of NAME_PREAMBLES) {
70 if (!l.startsWith(p + " ")) continue;
71 s = s.slice(p.length + 1).trim();
72 break;
73 }
74 while (s && ".!,;:".includes(s[s.length - 1])) s = s.slice(0, -1);
75 s = s.trim();
76 return s || original;
77}
78
79// ---- tiny expression + template language ---------------------------------
80// Conditions are dotted paths into the env with an optional leading "!":
81// "vars.photoAlt", "!vars.backedUp", "space.nameSet", "bridge.enrolled",
82// "resumed", "done" (this step recorded). bridge.* resolves through env.bridge
83// (calling functions), everything else reads env / env.vars / env.space.
84
85function resolve(env, path) {
86 if (path === "done") return env.__done();
87 // "done.X" reads another step's recorded state — welcome's resumed check
88 // ("did access or name already happen?") needs cross-step lookups.
89 if (path.startsWith("done.")) return env.__doneOf(path.slice(5));
90 const parts = path.split(".");
91 let cur = env;
92 for (const p of parts) {
93 if (cur == null) return undefined;
94 cur = cur[p];
95 if (typeof cur === "function") cur = cur.call(env);
96 }
97 return cur;
98}
99
100// Conditions: a dotted path with optional "!", or the compound forms
101// {all:[...]} / {any:[...]} for the few spots that genuinely need AND/OR
102// (welcome's passkey-return check is enrolled AND not-yet-recorded).
103export function evalCond(env, expr) {
104 if (expr == null) return true;
105 if (typeof expr === "boolean") return expr;
106 if (typeof expr === "object") {
107 if (expr.all) return expr.all.every((e) => evalCond(env, e));
108 if (expr.any) return expr.any.some((e) => evalCond(env, e));
109 }
110 const neg = expr.startsWith("!");
111 const v = resolve(env, neg ? expr.slice(1) : expr);
112 return neg ? !v : !!v;
113}
114
115// "{path}" and "{path|fallback}" interpolation; "{ack?}" splices (and consumes)
116// the pending acknowledgement from the previous step, with its trailing gap.
117export function template(env, s) {
118 return String(s).replace(/\{([^}]+)\}/g, (_, body) => {
119 if (body === "ack?") {
120 const a = env.__ack;
121 env.__ack = "";
122 return a ? a + "\n\n" : "";
123 }
124 const [path, fallback] = body.split("|");
125 const v = resolve(env, path.trim());
126 return v == null || v === "" ? (fallback != null ? fallback : "") : String(v);
127 });
128}
129
130// A text value is a string, {if, then, else}, or an array of segments
131// (strings, or {if, text}) joined after filtering.
132export function renderText(env, t) {
133 if (t == null) return "";
134 if (typeof t === "string") return template(env, t);
135 if (Array.isArray(t)) {
136 return t
137 .map((seg) => (typeof seg === "string" ? template(env, seg) : evalCond(env, seg.if) ? template(env, seg.text) : ""))
138 .join("");
139 }
140 return template(env, evalCond(env, t.if) ? t.then : t.else || "");
141}
142
143// ---- the walker -----------------------------------------------------------
144
145// One-tap-wins step guard (mirrors eleven.js onceStep; local so the engine has
146// no import and runs bare in Node).
147function onceStep() {
148 const go = (fn) => {
149 if (go.taken) return;
150 go.taken = true;
151 return fn();
152 };
153 go.taken = false;
154 return go;
155}
156
157// Map a verb result onto a branch key: void/true → ok, false → error, strings
158// verbatim (setGroupName's "ok"/"invalid"/"error", adoptProfilePhoto's "nokey").
159const resultKey = (r) => (r === undefined || r === true ? "ok" : r === false ? "error" : String(r));
160
161export function runFlow(flow, env, io) {
162 const byId = {};
163 flow.steps.forEach((s, i) => (byId[s.id] = { step: s, next: flow.steps[i + 1] ? flow.steps[i + 1].id : null }));
164 env.vars = env.vars || {};
165 env.__ack = "";
166 env.__doneOf = (id) => io.done(id);
167
168 // The resumed re-greeting (a client-only line — history already shows the
169 // real bubbles). Flows that special-case the return leg (welcome's passkey
170 // ack) suppress it with resumeUnless. env.reentry marks a MID-SESSION
171 // re-derive (another client answered the parked step — apps/setup.js's
172 // folded hook), not a returning member: greeting them "Welcome back" once
173 // per foreign answer would spam the feed.
174 env.resumed = evalCond(env, flow.resumedIf || false);
175 // Consume the reentry flag: one re-derive suppresses one greeting. Left
176 // set (the pre-#1377 bug) every later genuine resume in the same page life
177 // lost its "Welcome back".
178 const reentry = env.reentry;
179 env.reentry = false;
180 if (env.resumed && !reentry
181 && !(flow.resumeUnless && evalCond(env, flow.resumeUnless)) && flow.resumeSay) {
182 io.say(env, flow.resumeSay, null, { local: true });
183 }
184
185 const setPaths = (sets) => {
186 for (const [path, value] of Object.entries(sets || {})) {
187 const parts = path.split(".");
188 let cur = env;
189 while (parts.length > 1) cur = cur[parts.shift()] || {};
190 cur[parts[0]] = typeof value === "string" ? valueOf(value) : value;
191 }
192 };
193 // "$answer"/"$answerText" read the captured input; anything else templates.
194 const valueOf = (v) =>
195 v === "$answer" ? env.vars.answer : v === "$answerText" ? env.vars.answerText : typeof v === "string" ? template(env, v) : v;
196
197 async function runActions(a, stepCtx) {
198 if (!a) return false;
199 if (a.set) setPaths(a.set);
200 // A record may name its step explicitly: the photo PREVIEW records "photo",
201 // the keys WAITING/CONFIRM steps record "keys" — resume guards check those.
202 if (a.record) await io.record(a.record.step || stepCtx.step.id, renderText(env, a.record.label), a.record.value != null ? valueOf(a.record.value) : "");
203 if (a.ack) env.__ack = renderText(env, a.ack);
204 if (a.say) io.say(env, renderText(env, a.say), null, a.local ? { local: true } : undefined);
205 if (a.ask) {
206 askIn(stepCtx, renderText(env, a.ask.text != null ? a.ask.text : a.ask));
207 return true;
208 }
209 if (a.gotoAsk) {
210 // Jump to another step's ASK portion with fresh prompt text, skipping its
211 // context bubbles (the preview's "Use a different photo" → the photo ask).
212 askIn(byId[a.gotoAsk.step], renderText(env, a.gotoAsk.text));
213 return true;
214 }
215 if (a.retry === "step") {
216 enter(stepCtx.step.id);
217 return true;
218 }
219 if (a.goto) {
220 enter(a.goto);
221 return true;
222 }
223 return false;
224 }
225
226 async function runEffect(stepCtx, eff, args) {
227 const r = await io.verb(eff.verb, args || (eff.args || []).map(valueOf), env);
228 const on = stepCtx.step.on || {};
229 const branch = on[resultKey(r)];
230 if (!branch) return false; // fire-and-forget verb (setMyName, savePasskey)
231 const moved = await runActions(branch, stepCtx);
232 if (!moved) {
233 // A branch with no movement re-arms in place (rejected email re-asks).
234 armInput(stepCtx);
235 }
236 return true;
237 }
238
239 function buildChips(stepCtx, go) {
240 return (stepCtx.step.chips || []).filter((c) => evalCond(env, c.if)).map((c) => ({
241 label: renderText(env, c.label),
242 done: c.spent,
243 // A trailing chip renders right-aligned — the quiet alternative path
244 // (the quick form) sits apart from the step's primary answers.
245 trailing: c.trailing ? true : undefined,
246 // Finish-line chips (advancing:false, no probe) stay live forever.
247 stale: c.advancing === false && !c.probe ? undefined : () => go.taken,
248 run: () => {
249 const act = async () => {
250 if (c.cancelInput) io.cancelAnswer(env.threadId);
251 // Sets land before the effect so its args can read them (the
252 // suggested-name chip sets vars.answer, then setMyName reads it).
253 if (c.set) setPaths(c.set);
254 if (c.effect) await io.verb(c.effect.verb, (c.effect.args || []).map(valueOf), env);
255 await runActions(c, stepCtx);
256 };
257 if (c.advancing === false) {
258 act();
259 // The probe: if the verb didn't take us away (the passkey ceremony
260 // stayed in place), fall through to the waiting step.
261 if (c.probe) io.delay(c.probe.afterMs, () => {
262 if (!evalCond(env, c.probe.unless)) go(() => enter(c.probe.goto));
263 });
264 } else {
265 go(act);
266 }
267 },
268 }));
269 }
270
271 // (Re-)arm the step's input without re-saying its bubbles.
272 function armInput(stepCtx) {
273 const { step } = stepCtx;
274 if (!step.input) return;
275 if (step.input.kind === "text") {
276 io.ask(env.threadId, (text) =>
277 stepCtx.go(async () => {
278 // Clean BEFORE the cap so a chatty preamble can't eat into it
279 // ("my name is " + 25 chars must keep the whole name).
280 let answer = (text || "").trim();
281 if (step.input.clean === "name") answer = cleanNameAnswer(answer);
282 env.vars.answer = answer.slice(0, step.input.maxLen || Infinity);
283 // A required answer left empty re-asks the whole step (say-dedupe by
284 // key means no duplicate bubbles — the input just re-arms).
285 if (step.input.required && !env.vars.answer) return enter(step.id);
286 // Confirm before accepting (input.confirm): the answer stays in
287 // vars.answer for the confirming step's chips to use or discard.
288 if (confirmNeeded(step.input.confirm, env.vars.answer)) {
289 return enter(step.input.confirm.goto);
290 }
291 if (step.effect) await runEffect(stepCtx, step.effect);
292 })
293 );
294 } else if (step.input.kind === "photo") {
295 const arm = () =>
296 io.ask(env.threadId, (text, payload) => {
297 const img = (((payload || {}).attachments) || []).find((a) => a && a.type && String(a.type).startsWith("image/"));
298 if (!img) {
299 io.say(env, renderText(env, step.input.reject.text), null, step.input.reject.local ? { local: true } : undefined);
300 return arm();
301 }
302 stepCtx.go(async () => {
303 env.vars.answerText = (text || "").trim();
304 await runEffect(stepCtx, step.effect, [img, env.vars.answerText]);
305 });
306 });
307 arm();
308 }
309 }
310
311 async function enter(id) {
312 const stepCtx = byId[id];
313 if (!stepCtx) throw new Error(`flow ${flow.flow}: unknown step "${id}"`);
314 const { step } = stepCtx;
315 env.__done = () => io.done(step.id);
316
317 // Entry branches: state that satisfies the step on arrival (an enrolled
318 // passkey, a name already set) — with once-only ack lines the first time.
319 for (const e of step.entries || []) {
320 if (!evalCond(env, e.if)) continue;
321 if (e.set) setPaths(e.set);
322 if (!io.done(step.id)) {
323 if (e.recordIfNew) await io.record(step.id, renderText(env, e.recordIfNew.label), e.recordIfNew.value || "");
324 if (e.sayIfNew) io.say(env, renderText(env, e.sayIfNew));
325 }
326 return enter(e.goto || stepCtx.next);
327 }
328 if ((step.skipIf || []).some((c) => evalCond(env, c))) return enter(step.skipTo || stepCtx.next);
329
330 // A step may persist itself on arrival (welcome's finish records its
331 // completion marker) — once only, so a host can tell a FINISHED
332 // conversation from a half-done one purely from recorded state.
333 if (step.record && !io.done(step.id)) {
334 await io.record(step.id, renderText(env, step.record.label), step.record.value != null ? step.record.value : "");
335 }
336
337 stepCtx.go = onceStep();
338 // Effects fire before the step speaks (markSetupComplete lands before the
339 // "you're all set" bubble, as the hand-written flow did).
340 if (step.effects) {
341 for (const eff of step.effects) if (evalCond(env, eff.if)) await io.verb(eff.verb, (eff.args || []).map(valueOf), env);
342 }
343 const bubbles = (step.bubbles || []).filter((b) => evalCond(env, b.if));
344 for (let i = 0; i < bubbles.length; i++) {
345 const b = bubbles[i];
346 const last = i === bubbles.length - 1;
347 const extra = {};
348 if (b.key) extra.key = template(env, b.key);
349 if (b.hint) extra.hint = b.hint;
350 if (b.attachHint) extra.attachHint = b.attachHint;
351 if (b.label) extra.label = b.label; // the composer's inset field label
352 if (b.local) extra.local = true;
353 if (b.img) {
354 // imgSource may be async (welcome's profile photo decrypts to an
355 // object URL); a falsy resolution drops the image, keeps the bubble.
356 const src = await io.imgSource(b.img);
357 if (src) {
358 extra.img = src;
359 extra.imgAlt = renderText(env, b.imgAlt || "");
360 if (b.imgRound) extra.imgRound = true;
361 }
362 }
363 // Chips ride the last bubble — the question the answer belongs to.
364 io.say(env, renderText(env, b.text), last ? orNull(buildChips(stepCtx, stepCtx.go)) : null, extra);
365 }
366 if (!bubbles.length && step.chips) {
367 // Chip-only continuation (no new bubble) — not used by setup, kept tiny.
368 io.say(env, "", buildChips(stepCtx, stepCtx.go), {});
369 }
370 armInput(stepCtx);
371 // A step may WAIT for a condition to become true on its own — the passkey
372 // ceremony returns asynchronously (a redirect back, or the user tabbing back
373 // from a system browser), so `bridge.enrolled` can flip true seconds after
374 // we've shown the pitch. Poll it while the step is on screen and, the moment
375 // it holds, re-enter the step so its entries fire (record the backup, advance)
376 // — no manual "Continue" tap. The shared onceStep guard means a chip tap still
377 // wins and stops the poll.
378 if (step.settle && !evalCond(env, step.settle.when)) {
379 const go = stepCtx.go;
380 const period = step.settle.everyMs || 700;
381 const tick = () => {
382 if (go.taken) return; // a chip/advance already took the step
383 if (evalCond(env, step.settle.when)) return go(() => enter(step.settle.goto || step.id));
384 io.delay(period, tick);
385 };
386 io.delay(period, tick);
387 }
388 // A bubble-only step may auto-advance (welcome's greeting → the passkey
389 // step); input/chip steps advance through answers instead. ("auto", not
390 // "then" — text conditionals already use then/else.)
391 if (step.auto) return enter(step.auto);
392 if (step.end) env.onEnd && env.onEnd();
393 }
394
395 // A retry that re-asks with new text: one fresh line carrying the step's
396 // chips + hint, then the input re-arms (askEmail/askPhoto recursion).
397 function askIn(stepCtx, text) {
398 stepCtx.go = onceStep();
399 const extra = {};
400 const src = stepCtx.step.bubbles && stepCtx.step.bubbles[stepCtx.step.bubbles.length - 1];
401 if (src && src.hint) extra.hint = src.hint;
402 if (src && src.attachHint) extra.attachHint = src.attachHint;
403 if (src && src.label) extra.label = src.label;
404 io.say(env, text, orNull(buildChips(stepCtx, stepCtx.go)), extra);
405 armInput(stepCtx);
406 }
407
408 const orNull = (chips) => (chips && chips.length ? chips : null);
409
410 return enter(flow.entry);
411}
412