eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Shared machinery for Eleven's scripted conversations (setup, welcome): the
2// paced reply queue, the per-step tap guards, and PERSISTENT lines — each new
3// line Eleven says is emitted as an app event (encrypted and stored like any
4// thread event) and folds into the chat view as a real bubble, so the
5// conversation survives reloads as history instead of vanishing. Only the
6// action chips and composer hints ride a client-only notice (they need the
7// running script's closures); on a resumed run a line already in history
8// re-arms its chips instantly, with no typing beat and no duplicate bubble.
9import { emitAppEvent, appStateFor, awaitAnswer } from "./apps.js";
10
11function sleep(ms) {
12 return new Promise((r) => setTimeout(r, ms));
13}
14
15// The per-step tap guard: first advancing tap (or typed answer) wins. `taken`
16// is readable so a step's chips can render themselves spent once it resolves.
17export function onceStep() {
18 const go = (fn) => {
19 if (go.taken) return;
20 go.taken = true;
21 fn();
22 };
23 go.taken = false;
24 return go;
25}
26
27// An advancing chip: consumes the step; `done` is its past-tense label once
28// tapped ("Skipped"), and stale ties its liveness to the step guard.
29export const advance = (go, label, fn, done) => ({ label, done, stale: () => go.taken, run: () => go(fn) });
30
31// Ask a typed question: the next composer message in this thread is the answer.
32export function ask(env, go, onText) {
33 awaitAnswer(env.threadId, (text) => go(() => onText(text.trim())));
34}
35
36// makeSay(appName, sayType): build the app's say(env, text, actions, extra).
37// Replies play through a queue with a "thinking" beat, then typing scaled to
38// the reply's length (with jitter, first message quickest). Each line is
39// persisted via a `sayType` event keyed by its text; the app's reducer records
40// the key in state.said and its chatMessage projects the text as a bubble.
41// extra: { hint, attachHint, label } ride the chips notice (composer
42// placeholder + the inset field label); { local: true } keeps a line
43// client-only (e.g. an image preview).
44export function makeSay(appName, sayType) {
45 let queue = Promise.resolve();
46 let first = true;
47 const said = (env, key) => {
48 const st = appStateFor(env.threadId, appName);
49 return !!(st && st.said && st.said[key]);
50 };
51 return function say(env, text, actions, extra) {
52 const opener = first;
53 first = false;
54 const opts = extra || {};
55 // Steps that fold an ack into their question ("Lovely to meet you! …")
56 // pass a stable key, so a resumed run recognises the question even though
57 // its ack prefix differs — otherwise the variant re-emits as a duplicate.
58 const key = opts.key || text;
59 queue = queue.then(async () => {
60 // The beat turns the indicator ON and holds it a readable moment (≥1s,
61 // scaling with reply length) — but does NOT turn it off: the caller drops
62 // it only once the bubble is actually in the feed, so the list never
63 // collapses and then re-grows between indicator and message.
64 const beat = async () => {
65 if (!env.typing) return;
66 await sleep(opener ? 150 : 300 + Math.random() * 250);
67 env.typing(true);
68 const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4);
69 await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3)));
70 };
71 const beatOff = () => env.typing && env.typing(false);
72 if (opts.local) {
73 await beat();
74 beatOff();
75 env.respond({ sender: env.sender, text, actions, ...opts });
76 return;
77 }
78 if (!said(env, key)) {
79 await beat();
80 // Emit ONCE it actually sends. The transport returns false while the
81 // socket is still connecting (the boot-time race the first line hits),
82 // so retry only until it reports the frame went out — never re-emit a
83 // frame that already sent, or a slow echo would persist the line twice.
84 let sent = false;
85 for (let attempt = 0; attempt < 12 && !sent && !said(env, key); attempt++) {
86 sent = await emitAppEvent(env.threadId, { type: sayType, key, text, ts: Date.now() });
87 if (!sent) await sleep(150);
88 }
89 // Then wait (passively) for the echo to fold into the transcript so the
90 // chips row below renders under the question it belongs to.
91 for (let i = 0; i < 30 && !said(env, key); i++) await sleep(100);
92 beatOff(); // the bubble is in the feed (or we're falling back) — indicator off AFTER it lands
93 if (!said(env, key)) {
94 // Persistence failed outright (offline?) — the member still sees the
95 // line, as a client-only notice; a later run re-says it for keeps.
96 env.respond({ sender: env.sender, text, actions, ...opts });
97 return;
98 }
99 }
100 if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) {
101 env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label });
102 }
103 });
104 return queue;
105 };
106}
107
108// makeLiveSay: makeSay for a REAL thread — the one-Eleven mode, where the
109// scripted conversation runs inside the live agent Eleven's DM instead of a
110// parallel app-thread. Lines persist as ordinary chat messages authored
111// "Eleven" (uid-less, so every client — including native — renders them as
112// the bot's bubbles, and the sidecar's agent-name rule keeps the real Eleven
113// from answering its own scripted half). Same pacing/queue/dedup contract as
114// makeSay; the host supplies `live.said(key)` (is this line already in the
115// transcript?) and `live.post(text, key)` (send it).
116export function makeLiveSay(live) {
117 let queue = Promise.resolve();
118 let first = true;
119 return function say(env, text, actions, extra) {
120 const opener = first;
121 first = false;
122 const opts = extra || {};
123 const key = opts.key || text;
124 queue = queue.then(async () => {
125 const beat = async () => {
126 if (!env.typing) return;
127 await sleep(opener ? 150 : 300 + Math.random() * 250);
128 env.typing(true);
129 const ms = Math.min(2000, (opener ? 350 : 600) + text.length * 4);
130 await sleep(Math.max(1000, ms * (0.85 + Math.random() * 0.3)));
131 };
132 const beatOff = () => env.typing && env.typing(false);
133 if (opts.local) {
134 await beat();
135 beatOff();
136 env.respond({ sender: env.sender, text, actions, ...opts });
137 return;
138 }
139 if (!live.said(key)) {
140 await beat();
141 let sent = false;
142 for (let attempt = 0; attempt < 12 && !sent && !live.said(key); attempt++) {
143 sent = await live.post(text, key);
144 if (!sent) await sleep(150);
145 }
146 for (let i = 0; i < 30 && !live.said(key); i++) await sleep(100);
147 beatOff();
148 if (!live.said(key)) {
149 env.respond({ sender: env.sender, text, actions, ...opts });
150 return;
151 }
152 }
153 if ((actions && actions.length) || opts.hint || opts.attachHint || opts.label) {
154 env.respond({ actions, hint: opts.hint, attachHint: opts.attachHint, label: opts.label });
155 }
156 });
157 return queue;
158 };
159}
160
161// The reducer entry for a say event and its chat-view projection — one line
162// each in the app's spec: `...sayEvents("welcome.say")` under `events`, and
163// `const line = sayBubble(e, "welcome.say"); if (line) return line;` first in
164// chatMessage.
165export function sayEvents(sayType) {
166 return {
167 [sayType]: (s, e) => (e.key ? { ...s, said: { ...(s.said || {}), [e.key]: true } } : s),
168 };
169}
170export function sayBubble(e, sayType) {
171 if (e.type === sayType && e.text) return { text: e.text };
172 return null;
173}
174