eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Joiner onboarding — Eleven welcomes a NEW member the moment they arrive:
2// saving their access behind a passkey FIRST (so the round-trip back from the
3// ceremony lands mid-conversation with steps still to do, not on "all done"),
4// then name, profile photo, and an invitation to take the tour. Chat-first,
5// like the admin setup conversation (docs/conversational-onboarding.md §4):
6// claiming an invite lands straight in this conversation, no name form.
7//
8// The conversation itself is DATA: welcome.flow.js, interpreted by the flow
9// engine (flow.js — docs/conversation-scripts.md). This file is the app shell
10// + the host binding: the bridge-verb table and the io wiring the engine to
11// eleven.js's feel and shell.js's setup bridge. Like setup it must work with
12// no AI and no sidecar (the sidecar's welcome DM stood down in its favour).
13import { defineApp, namespace, setupBridge, awaitAnswer, cancelAnswer, hasAnswerWaiter, appStateFor, emitAppEvent, spaceInfo } from "../apps.js";
14import { makeSay, makeLiveSay, sayEvents, sayBubble } from "../eleven.js";
15import { runFlow } from "../flow.js";
16import WELCOME_FLOW from "../welcome.flow.js";
17import { startTour } from "../tutorial.js";
18// The run-scoped timer ledger + record-echo wait live with the setup host —
19// one implementation, both conversations (#1377).
20import { delayFor, clearRunTimers, waitForRecordEchoes } from "./setup.js";
21
22// One declaration of the app's prefix — N.ev("step") → the "welcome.step" wire type.
23const N = namespace("welcome");
24
25export const app = defineApp({
26 name: "welcome",
27 title: "Eleven",
28 glyph: "🕚",
29 avatar: "/static/icon-192.png", // Eleven wears the app icon, not a clock glyph
30 // Presents as a conversation with Eleven (BOT in the sidebar, chat-only
31 // surface) — the app machinery underneath is an implementation detail.
32 senderKind: "bot",
33 chatIntro: "This is the start of your chat with Eleven.",
34 chatOnly: true,
35 // System scope: never offered in the Apps menu — the host mounts it for a
36 // freshly-joined member (maybeStartWelcome in shell.js), exactly once.
37 places: [], // host-mounted conversation
38 initialState: () => ({ steps: {}, said: {} }),
39 events: {
40 [N.ev("step")]: (s, e) => (e.step ? { ...s, steps: { ...s.steps, [e.step]: { done: true, value: e.value || "" } } } : s),
41 ...sayEvents(N.ev("say")),
42 },
43 chatMessage(e) {
44 const line = sayBubble(e, N.ev("say"));
45 if (line) return line;
46 if (e.type !== N.ev("step") || !e.label) return null;
47 return { text: e.label, action: true };
48 },
49 // A step-record from ANOTHER client (a second device/tab answering this
50 // same conversation) landing while OUR engine sits parked on a question —
51 // the 2026-07-19 stale-composer bug, fixed for setup then and ported here
52 // (#1377): tear the parked run down and boot the flow again; every
53 // say/record dedups against the folded log, so the restart lands parked on
54 // the first genuinely-unanswered step.
55 folded(threadId, event) {
56 if (event.type !== N.ev("step") || !event.step) return;
57 const env = runningEnvs.get(threadId);
58 if (!env || !hasAnswerWaiter(threadId)) return; // not parked — mid-run or ended
59 const own = ownRecords.get(threadId);
60 if (own && own.has(event.step)) return;
61 cancelAnswer(threadId);
62 running.delete(threadId);
63 clearRunTimers(threadId); // the old run's settle/probe timers die with it
64 env.reentry = true;
65 startWelcome(env);
66 },
67});
68
69// ---- the host binding -------------------------------------------------------
70
71const running = new Set();
72const runningEnvs = new Map(); // threadId → env, for the folded reboot
73const ownRecords = new Map(); // threadId → Set(step) — own echoes aren't news
74const say = makeSay("welcome", N.ev("say"));
75
76const stepState = (threadId, step) => {
77 const st = appStateFor(threadId, "welcome");
78 return (st && st.steps && st.steps[step]) || null;
79};
80
81// The bridge-verb table: the CLOSED set of side effects the welcome flow may
82// name (docs/conversation-scripts.md § interpreter contract).
83function makeVerb(env) {
84 return function verb(name, args) {
85 const bridge = setupBridge();
86 switch (name) {
87 case "setMyName":
88 // The same cap the profile rename applies — enforced here so the
89 // suggested-name chip and the typed path can't diverge.
90 return bridge.setMyName(String(args[0]).slice(0, 32));
91 case "adoptProfilePhoto":
92 return bridge.adoptProfilePhoto(env.threadId, args[0], args[1]);
93 case "savePasskey":
94 // Wait for this run's record echoes before the same-tab navigation —
95 // the redirect must not outrun the step recorded just before the tap.
96 return waitForRecordEchoes(env.threadId, "welcome", ownRecords.get(env.threadId)).then(() => bridge.savePasskey());
97 case "deferBackupReminders":
98 return bridge.deferBackupReminders && bridge.deferBackupReminders();
99 case "markWelcomeDone":
100 return bridge.markWelcomeDone && bridge.markWelcomeDone();
101 case "openPendingThread":
102 return env.openPendingThread && env.openPendingThread();
103 case "openDiscover":
104 // "Browse open groups" on the finish step — the host supplies the
105 // directory surface (web: showDiscover; native: the Discover sheet).
106 return env.openDiscover && env.openDiscover();
107 case "openQuickForm":
108 // The access step's fast path — the host owns the form sheet (web:
109 // openWelcomeQuickForm in shell.js; native: the quick-form sheet).
110 return env.openQuickForm && env.openQuickForm();
111 case "startTour":
112 // Reached by "Take the tour" at the end of the welcome — the user has
113 // just been talking to Eleven, so skip the tour's own "fancy a tour?"
114 // greeting and go straight in.
115 return startTour(env.respond, env.typing, { skipIntro: true });
116 default:
117 throw new Error(`welcome: unknown verb ${name}`);
118 }
119 };
120}
121
122// startWelcome(env): env = { threadId, respond, typing, suggested, inviterName,
123// adminName, openPendingThread } — the host supplies the notice sink, the
124// typing beat, the inviter's suggested name (from the invite fragment, never
125// the server), who invited them (decrypted roster label, "" if unknown), and a
126// callback opening the DM the invite created (null if none).
127// One-Eleven mode: when env.live is supplied (the roster has a real agent
128// Eleven), the flow runs INSIDE that agent's DM — scripted lines persist as
129// ordinary "Eleven" chat messages, and env.live.flowState mutes the sidecar
130// brain for the duration so the script and the model never talk over each
131// other. Without env.live (no sidecar — a fresh instance), the app-thread
132// fallback below is unchanged: onboarding never depends on a bot existing.
133export function startWelcome(env) {
134 const bridge = setupBridge();
135 if (!bridge || running.has(env.threadId)) return;
136 running.add(env.threadId);
137 runningEnvs.set(env.threadId, env);
138 env.sender = { name: "Eleven", kind: "bot" };
139 env.bridge = {
140 myName: () => setupBridge().myName(),
141 enrolled: () => setupBridge().enrolled(),
142 // Whether signing in is possible at all — self-hosted spaces have no home
143 // server, and the flow skips the un-actionable ask (#1377).
144 canEnrol: () => !!window.__homeOrigin,
145 };
146 env.hasPendingThread = !!env.openPendingThread;
147 // Backed-up is real state, not run-local memory: enrolled now, or the access
148 // step recorded a passkey in an earlier session — or "native", where access
149 // is secured by Sign in with Eleven and there's no browser key to back up.
150 env.accessBackedUp = () => {
151 const s = stepState(env.threadId, "access");
152 return setupBridge().enrolled() || !!(s && (s.value === "passkey" || s.value === "native"));
153 };
154 env.onEnd = () => {
155 running.delete(env.threadId);
156 runningEnvs.delete(env.threadId);
157 ownRecords.delete(env.threadId);
158 clearRunTimers(env.threadId);
159 if (env.live) env.live.flowState(false); // un-mute the real Eleven
160 };
161 if (env.live) env.live.flowState(true); // the script has the floor
162 const io = {
163 say: env.live ? makeLiveSay(env.live) : say,
164 ask: awaitAnswer,
165 cancelAnswer,
166 verb: makeVerb(env),
167 record: (step, label, value) => {
168 // Ledger our own record BEFORE the emit — its socket echo folds back
169 // through the `folded` hook and must read as ours, not a foreign answer.
170 let own = ownRecords.get(env.threadId);
171 if (!own) ownRecords.set(env.threadId, (own = new Set()));
172 own.add(step);
173 return emitAppEvent(env.threadId, { type: N.ev("step"), step, label, value: value || "" });
174 },
175 done: (step) => {
176 const s = stepState(env.threadId, step);
177 return !!(s && s.done);
178 },
179 imgSource: (token) => (token === "$myAvatar" ? setupBridge().myAvatarURL().catch(() => null) : token),
180 delay: delayFor(env.threadId),
181 };
182 spaceInfo()
183 .then((info) => {
184 env.space = info || {};
185 })
186 .catch(() => {
187 env.space = {};
188 })
189 .finally(() => runFlow(WELCOME_FLOW, env, io));
190}
191