@7ots/cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -20
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +122 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +28 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +365 -22
  14. package/cli/lib/screen.mjs +125 -0
  15. package/cli/lib/stt-local.py +14 -0
  16. package/cli/lib/stt.mjs +68 -0
  17. package/cli/lib/tts-local.mjs +110 -0
  18. package/cli/lib/tts-local.py +18 -0
  19. package/cli/lib/vm.mjs +309 -0
  20. package/cli/lib/watchers.mjs +151 -0
  21. package/cli/pet/card.html +294 -0
  22. package/cli/pet/electron/director.cjs +175 -0
  23. package/cli/pet/electron/main.cjs +515 -59
  24. package/cli/pet/pet.html +414 -21
  25. package/cli/pet/settings.html +991 -57
  26. package/dist/7ots.esm.js +3 -3
  27. package/dist/7ots.esm.js.map +3 -3
  28. package/dist/7ots.iife.js +3 -3
  29. package/dist/7ots.iife.js.map +3 -3
  30. package/llms.txt +31 -17
  31. package/package.json +1 -1
  32. package/server/llm.mjs +2 -0
  33. package/server/platform/connect.mjs +3 -1
  34. package/server/platform/db.mjs +77 -0
  35. package/server/platform/device.mjs +125 -0
  36. package/server/platform/orquesta.mjs +5 -0
  37. package/server/platform/routes.mjs +100 -1
  38. package/server/platform/sites.mjs +146 -0
  39. package/server/platform/store.mjs +3 -1
  40. package/server/platform/vm.mjs +386 -0
  41. package/src/character3d/index.js +2 -1
  42. package/src/character3d/stage.js +15 -4
  43. package/src/i18n/messages/character.js +3 -3
  44. package/src/i18n/messages/platform.js +42 -3
package/cli/pet/pet.html CHANGED
@@ -2,6 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
5
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
7
  <title>7ots</title>
7
8
  <style>
@@ -114,21 +115,41 @@
114
115
  .ask input:focus { border-color: var(--accent); }
115
116
  /* ── desktop: transparent frameless window walking along the bottom of the screen ── */
116
117
  html:has(body.desktop:not(.opaque)) { background: transparent; } /* html also paints --bg: without this the window is a white box */
117
- body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden; -webkit-app-region: drag; user-select: none; }
118
+ body.desktop { background: transparent; padding: 0; place-items: end center; height: 100vh; overflow: hidden; user-select: none; } /* dragged by hand (see 'drag' below): a drag region would swallow clicks and double clicks */
118
119
  body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
119
120
  body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
120
121
  body.desktop .hero { background: none; padding: 0 0 6px; }
121
122
  body.desktop .stage { width: 140px; height: 148px; }
122
123
  body.desktop .stage.flip .face { transform: scaleX(-1); }
124
+ /* scenes with other ots (electron/director.cjs): the acts move .act; props (fireballs, beams, sabers, bullets) */
125
+ .actor { position: absolute; inset: 0; z-index: 1; transform-origin: 50% 92%; }
126
+ .props { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: visible; }
127
+ .props > * { position: absolute; }
128
+ .props .ball { left: 50%; top: 42%; width: 34px; height: 34px; margin: -17px; border-radius: 50%; background: radial-gradient(circle at 40% 40%, #fff 0 18%, #8fe3ff 30%, #2a7bff 62%, transparent 72%); box-shadow: 0 0 22px 8px #4cb8ff; }
129
+ .props .beam { left: 50%; top: 40%; height: 26px; margin-top: -13px; border-radius: 13px; background: linear-gradient(180deg, #bfefff, #fff 45%, #fff 55%, #7fd6ff); box-shadow: 0 0 24px 10px #49b6ff, 0 0 60px 18px #1f6bff88; }
130
+ .props .bullet { top: 34%; width: 14px; height: 6px; margin-top: -3px; border-radius: 3px; background: linear-gradient(90deg, #6b5a2b, #e9cf7a); }
131
+ .props .ring { top: 34%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; border: 2px solid rgb(180 220 255 / .8); }
132
+ .props .saber { left: 50%; top: 50%; width: 6px; height: 92px; margin-left: -3px; transform-origin: 50% 100%; border-radius: 4px; background: #fff; }
133
+ .props .flash { top: 44%; width: 30px; height: 30px; margin: -15px; border-radius: 50%; background: radial-gradient(#fff, #ffd35a 40%, #ff7a1a 60%, transparent 70%); }
134
+ .props .pow { left: 50%; top: 6%; font: 900 22px/1 system-ui, sans-serif; color: #ffd400; -webkit-text-stroke: 2px #1a1020; transform: translateX(-50%); white-space: nowrap; }
135
+ /* a visiting ot: its own little menu (play, say goodbye, settings, move); feeding, talking and sleep are the host's */
136
+ body.guest .ask, body.guest #talkBtn, body.guest #micBtn, body.guest #sleepBtn { display: none !important; }
137
+ .bye { display: none; }
138
+ body.desktop.guest .bye { display: flex; }
123
139
  body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
124
- body.desktop .actions { position: absolute; right: 8px; bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
140
+ body.desktop .actions { position: absolute; right: auto; left: calc(50% + 66px); bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
125
141
  background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
126
- opacity: 0; transition: opacity .18s, transform .18s; -webkit-app-region: no-drag; z-index: 3; }
127
- body.desktop:hover .actions { opacity: 1; transform: none; }
142
+ opacity: 0; transition: none; /* a transition here never finished painting in the transparent window: the menu stayed invisible */ -webkit-app-region: no-drag; z-index: 5; } /* above the 3D stage canvas (z 3) */
143
+ body.desktop:hover .actions, body.desktop.hover .actions, body.desktop.menu .actions { opacity: 1; transform: none; } /* .hover: set by the shell, which sees the cursor even when no mouse event reaches the page */
128
144
  body.desktop .act { width: 32px; height: 32px; padding: 0; border-radius: 50%; border: 0; background: transparent; }
129
145
  body.desktop .act span { display: none; }
146
+ .grip { display: none; }
147
+ body.desktop .grip { display: flex; cursor: grab; touch-action: none; }
148
+ body.desktop .grip:active { cursor: grabbing; }
130
149
  body.desktop .act.primary { background: var(--accent); }
131
150
  body.desktop .badge { display: flex; }
151
+ body.listening #micBtn { background: var(--err); color: #fff; animation: rec 1s ease-in-out infinite; }
152
+ @keyframes rec { 50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--err) 30%, transparent); } }
132
153
  body.desktop .ask { display: none; position: absolute; left: 6px; right: 6px; top: 6px; margin: 0; z-index: 4; -webkit-app-region: no-drag; }
133
154
  body.desktop.talking .ask { display: flex; }
134
155
  .badge { display: none; position: absolute; z-index: 2; left: -6px; bottom: 18px; min-width: 22px; height: 22px; padding: 0 6px; border-radius: 99px; align-items: center; justify-content: center;
@@ -152,7 +173,8 @@
152
173
  <div class="stage" id="stage">
153
174
  <div class="bubble" id="bubble" role="status" aria-live="polite"></div>
154
175
  <span class="zz" aria-hidden="true">z</span>
155
- <div class="face" id="face"></div>
176
+ <div class="actor" id="act"><div class="face" id="face"></div></div>
177
+ <div class="props" id="props" aria-hidden="true"></div>
156
178
  <div class="floor" aria-hidden="true"></div>
157
179
  <span class="badge" id="badge"></span>
158
180
  </div>
@@ -173,9 +195,11 @@
173
195
  <nav class="actions">
174
196
  <button class="act primary" data-act="feed"><svg viewBox="0 0 24 24"><path d="M12 7c-1.5-2.5-6-2.5-7 1.5S7 20 9.5 20c1.2 0 1.6-.6 2.5-.6s1.3.6 2.5.6C17 20 20 12.5 19 8.5S13.5 4.5 12 7z"/><path d="M12 7c0-2 1-3.5 3-4"/></svg><span data-t="feed"></span></button>
175
197
  <button class="act" id="talkBtn" type="button"><svg viewBox="0 0 24 24"><path d="M5 6h14v9H9l-4 3z"/></svg><span data-t="talk"></span></button>
176
- <button class="act" data-act="play"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M5 8.5c3 1.5 11 1.5 14 0M5 15.5c3-1.5 11-1.5 14 0"/></svg><span data-t="play"></span></button>
198
+ <button class="act" id="micBtn" type="button"><svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21"/></svg><span data-t="listen"></span></button>
177
199
  <button class="act" data-act="sleep" id="sleepBtn"><svg viewBox="0 0 24 24"><path d="M20 14.5A8 8 0 1 1 9.5 4a6.5 6.5 0 0 0 10.5 10.5z"/></svg><span data-t="sleep"></span></button>
178
200
  <a class="act" id="setBtn" target="_blank" rel="noopener"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M5.3 18.7l2.1-2.1M16.6 7.4l2.1-2.1"/></svg><span data-t="settings"></span></a>
201
+ <button class="act bye" id="byeBtn" type="button"><svg viewBox="0 0 24 24"><path d="M15 4h4v16h-4M10 8l-4 4 4 4M6 12h10"/></svg></button>
202
+ <button class="act grip" id="moveBtn" type="button" title="⇔"><svg viewBox="0 0 24 24"><path d="M12 3v18M3 12h18M12 3l-3 3M12 3l3 3M12 21l-3-3M12 21l3-3M3 12l3-3M3 12l3 3M21 12l-3-3M21 12l-3 3"/></svg></button>
179
203
  </nav>
180
204
 
181
205
  <section class="feed" aria-labelledby="feed-h">
@@ -189,17 +213,17 @@
189
213
  <script type="module">
190
214
  const BOOT = __BOOT__;
191
215
  const T = {
192
- en: { settings: 'Settings', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
216
+ en: { settings: 'Settings', dismiss: 'Send home', listen: 'Listen', listening: 'Listening… 🎙️', noStt: 'Voice control needs an OpenAI or ElevenLabs key (Settings).', noMic: 'I can’t reach the microphone.', sttErr: 'I didn’t catch that.', talk: 'Talk', askPh: 'Remind me to check email in 10 min…', feed: 'Treat', play: 'Play', sleep: 'Sleep', wake: 'Wake', food: 'Food', energy: 'Energy', fun: 'Fun', level: 'Level {l}', xp: '{x} / {n} xp', live: 'Live', off: 'Reconnecting…',
193
217
  activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
194
218
  brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
195
219
  moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
196
220
  kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
197
- es: { settings: 'Ajustes', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Comida', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
221
+ es: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Escuchar', listening: 'Te escucho… 🎙️', noStt: 'Para hablarme por voz falta una key de OpenAI o ElevenLabs (Ajustes).', noMic: 'No puedo usar el micrófono.', sttErr: 'No te entendí.', talk: 'Hablar', askPh: 'Recuérdame ver el correo en 10 min…', feed: 'Mimar', play: 'Jugar', sleep: 'Dormir', wake: 'Despertar', food: 'Comida', energy: 'Energía', fun: 'Diversión', level: 'Nivel {l}', xp: '{x} / {n} xp', live: 'En vivo', off: 'Reconectando…',
198
222
  activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
199
223
  brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
200
224
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
201
225
  kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
202
- pt: { settings: 'Ajustes', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Comida', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
226
+ pt: { settings: 'Ajustes', dismiss: 'Despedir', listen: 'Ouvir', listening: 'Estou ouvindo… 🎙️', noStt: 'Para falar por voz falta uma key da OpenAI ou ElevenLabs (Ajustes).', noMic: 'Não consigo usar o microfone.', sttErr: 'Não entendi.', talk: 'Falar', askPh: 'Me lembre de ver o e-mail em 10 min…', feed: 'Mimar', play: 'Brincar', sleep: 'Dormir', wake: 'Acordar', food: 'Comida', energy: 'Energia', fun: 'Diversão', level: 'Nível {l}', xp: '{x} / {n} xp', live: 'Ao vivo', off: 'Reconectando…',
203
227
  activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
204
228
  brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
205
229
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
@@ -211,6 +235,8 @@
211
235
  const $ = (s) => document.querySelector(s);
212
236
  const id = BOOT.identity;
213
237
  const desktop = new URLSearchParams(location.search).has('desktop');
238
+ const guest = BOOT.guest || ''; // a visiting ot: no state, no voice, only the scenes
239
+ document.body.classList.toggle('guest', !!guest);
214
240
 
215
241
  document.documentElement.lang = lang;
216
242
  document.body.classList.toggle('desktop', desktop);
@@ -237,7 +263,7 @@
237
263
  // three comes from the CDN; without WebGL or network the 2D ot takes over
238
264
  try {
239
265
  const m = await loadCharacter3D();
240
- ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, stage: { key: 'pet', zIndex: 3, maxDpr: 2 } });
266
+ ch = m.createCharacter3D($('#face'), spec, { material: 'plush', backdrop: !desktop, orbit: !desktop, bleed: desktop ? 'window' : 0, stage: { key: 'pet', zIndex: 3, maxDpr: 2, minDpr: 1.25, supersample: desktop } });
241
267
  if (ch.fallback) console.warn('[7ots pet] no WebGL: 2D ot');
242
268
  } catch (err) {
243
269
  console.warn('[7ots pet] 3D ot unavailable, using 2D:', err?.message || err);
@@ -246,13 +272,106 @@
246
272
  }
247
273
  ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
248
274
  const is3d = !!ch.character;
249
- addEventListener('pointermove', (e) => ch.lookAtPoint(e.clientX, e.clientY));
275
+ // While a gesture/effect/morph plays the ot needs room around it (wings, hearts, confetti): the desktop
276
+ // shell reads window.__need() and sizes the window to what is really on screen.
277
+ let fxUntil = 0;
278
+ let actUntil = 0; // a scene act is playing: it needs actRoom px on each side (a beam, a saber swing)
279
+ let actRoom = 0;
280
+ for (const k of ['gesture', 'effect', 'morph']) {
281
+ const f = ch[k]?.bind(ch);
282
+ if (!f) continue;
283
+ try {
284
+ ch[k] = (n, o) => ((fxUntil = Math.max(fxUntil, Date.now() + Math.max(2600, o?.ms || 0) + 400)), tellNeed(), f(n, o));
285
+ } catch {}
286
+ }
287
+ // Effects the character starts on its own (a mood's steam, a gesture's sparkles) skip the wrapper: they show
288
+ // up as children of its fx layers, so those count as "playing" too.
289
+ const fxLayers = () => $('#face').querySelectorAll('[data-p="fx"], [data-p="fxback"], [data-p="fxface"]');
290
+ const fxOn = () => Date.now() < fxUntil || [...fxLayers()].some((g) => g.childElementCount);
291
+ // What the figure really covers (arms out, a raised hand, a hop): the union of the drawn parts, which can
292
+ // stick out of the stage. The window is centred on the ot, so the width is twice the widest side.
293
+ const drawn = () => {
294
+ const parts = $('#face').querySelectorAll('svg > g, svg > use, svg > path');
295
+ let l = Infinity, t = Infinity, r = -Infinity;
296
+ for (const e of parts) {
297
+ const b = e.getBoundingClientRect();
298
+ if (!b.width && !b.height) continue;
299
+ l = Math.min(l, b.left), t = Math.min(t, b.top), r = Math.max(r, b.right);
300
+ }
301
+ return l === Infinity ? null : { l, t, r };
302
+ };
303
+ window.__need = () => {
304
+ const st = $('#stage').getBoundingClientRect();
305
+ // playing: room up front (hearts rise, confetti flies), not grown pixel by pixel as they move
306
+ const on = fxOn();
307
+ let w = st.width + (on ? 72 : 12) * 2;
308
+ let h = st.height + 6 + (on ? 96 : 12);
309
+ const d = drawn();
310
+ if (d) {
311
+ const cx = st.left + st.width / 2;
312
+ w = Math.max(w, 2 * Math.max(cx - d.l, d.r - cx) + 24);
313
+ h = Math.max(h, innerHeight - d.t + 12);
314
+ }
315
+ const b = $('#bubble');
316
+ if (b.classList.contains('on')) {
317
+ w = Math.max(w, b.offsetWidth + 24);
318
+ h = Math.max(h, st.height + 6 + b.offsetHeight + 16);
319
+ }
320
+ if (document.body.classList.contains('talking')) (w = Math.max(w, 240)), (h = Math.max(h, st.height + 60));
321
+ if (Date.now() < actUntil) (w = Math.max(w, actRoom * 2)), (h = Math.max(h, st.height + 70));
322
+ return { w: Math.ceil(w), h: Math.ceil(h) };
323
+ };
324
+ // The shell polls __need every 250 ms; when an effect starts it's told at once (a console line it listens
325
+ // for), so the window is big before the first frame that needs it instead of cutting it off.
326
+ let toldNeed = '';
327
+ function tellNeed() {
328
+ if (!desktop) return;
329
+ requestAnimationFrame(() => {
330
+ const n = JSON.stringify(window.__need());
331
+ if (n !== toldNeed) (toldNeed = n), console.debug('\u00a77need' + n);
332
+ });
333
+ }
334
+ if (!is3d) {
335
+ const mo = new MutationObserver(tellNeed);
336
+ for (const g of fxLayers()) mo.observe(g, { childList: true });
337
+ }
338
+ // Drag: the page tells the shell when the button goes down and up on the ot; the shell moves the window
339
+ // with the cursor in between. A click that doesn't move stays a click.
340
+ const onOt = (e) => {
341
+ const r = $('#stage').getBoundingClientRect();
342
+ return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
343
+ };
344
+ if (desktop) {
345
+ let down = false;
346
+ const up = () => down && ((down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false })));
347
+ // on the document: in 3D the ot is drawn on a canvas above the page, so events don't target #stage
348
+ addEventListener('pointerdown', (e) => {
349
+ // on the ot itself, or on the move handle in the menu (a sure grip while the ot is mid-animation)
350
+ const grip = e.target.closest?.('#moveBtn');
351
+ if (e.button !== 0 || (!grip && (e.target.closest?.('.actions, .ask, a, button') || !onOt(e)))) return;
352
+ down = true;
353
+ document.documentElement.setPointerCapture?.(e.pointerId);
354
+ console.debug('\u00a77drag' + JSON.stringify({ on: true }));
355
+ });
356
+ for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) addEventListener(ev, up);
357
+ addEventListener('blur', up);
358
+ }
359
+ let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
360
+ addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
250
361
  $('#face').addEventListener('click', () => ch.gesture('bounce'));
251
362
 
252
363
  const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
253
364
  let asleep = false;
365
+ // something urgent while asleep: it wakes up for a moment (says it, walks over), then dozes off again
366
+ let stirUntil = 0, stirT = 0;
367
+ function stir() {
368
+ stirUntil = Date.now() + 30000;
369
+ document.body.classList.remove('asleep');
370
+ clearTimeout(stirT);
371
+ stirT = setTimeout(() => last && render(last), 30500);
372
+ }
254
373
  document.querySelectorAll('[data-act]').forEach((b) =>
255
- b.addEventListener('click', () => post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
374
+ b.addEventListener('click', () => guest ? (b.dataset.act === 'feed' && postMessage({ gesture: 'celebrate' })) : post('/event', { type: b.dataset.act === 'sleep' && asleep ? 'wake' : b.dataset.act })),
256
375
  );
257
376
 
258
377
  // ── state ──
@@ -276,8 +395,9 @@
276
395
  function render(s) {
277
396
  last = s;
278
397
  asleep = s.asleep;
279
- document.body.classList.toggle('asleep', asleep);
280
- ch.mood(s.mood);
398
+ const stirred = asleep && Date.now() < stirUntil;
399
+ document.body.classList.toggle('asleep', asleep && !stirred);
400
+ ch.mood(stirred ? 'neutral' : s.mood);
281
401
  paintMood();
282
402
  $('#sleepBtn [data-t]').textContent = t(asleep ? 'wake' : 'sleep');
283
403
  $('#sleepBtn').setAttribute('aria-label', t(asleep ? 'wake' : 'sleep'));
@@ -386,8 +506,35 @@
386
506
  clearTimeout(hideT);
387
507
  if (ms) hideT = setTimeout(() => bubble.classList.remove('on'), ms);
388
508
  }
389
- function say(text) {
509
+ // What it says shows on its face too: a gesture/effect picked from the words (when the server sent none).
510
+ const REACT = [
511
+ [/(ja){2,}|(ha){2,}|jeje|lol|kkk|😂|🤣/i, 'laugh', null],
512
+ [/gracias|thank|obrigad|te quiero|love you|❤|💜|lindo|cute/i, 'bow', 'hearts'],
513
+ [/error|fail|fall[óo]|rompi|broke|oops|ups|ay no|uh.?oh/i, 'shake', 'sweat'],
514
+ [/listo|done|logrado|genial|great|bravo|yay|bien hecho|nice|perfect|✓|✅|🎉/i, 'celebrate', 'confetti'],
515
+ [/hambre|hungry|fome|comida|food|🍎/i, 'point', 'thought'],
516
+ [/sueño|tired|cansad|dormir|sleep|nap|zz/i, 'yawn', null],
517
+ [/miedo|scary|assust|fear|😱/i, 'shiver', 'sweat'],
518
+ [/enojad|angry|bravo con|grr|😠/i, 'stomp', 'anger'],
519
+ [/hola|hello|hi\b|hey|oi\b|buen(os|as) d[ií]as|good morning/i, 'wave', null],
520
+ [/[?¿]\s*$/, 'think', 'question'],
521
+ [/!\s*$/, 'bounce', 'exclaim'],
522
+ ];
523
+ function react(text) {
524
+ for (const [re, g, fx] of REACT) {
525
+ if (!re.test(text)) continue;
526
+ if (g) ch.gesture(g);
527
+ if (fx) ch.effect?.(fx);
528
+ return;
529
+ }
530
+ ch.gesture(Math.random() < 0.5 ? 'nod' : 'wiggle');
531
+ }
532
+ function say(text, withFace = false) {
390
533
  showBubble(document.createTextNode(text), Math.max(4500, text.length * 90));
534
+ talk(true, text); // the mouth moves with the words even without a voice; a real voice re-syncs it
535
+ clearTimeout(say.t);
536
+ say.t = setTimeout(() => talk(false), 500 + text.length * 60);
537
+ if (withFace) react(text);
391
538
  speak(text);
392
539
  }
393
540
  function setThinking(on) {
@@ -404,19 +551,44 @@
404
551
 
405
552
  // ── live updates ──
406
553
  const live = $('#live');
554
+ // The daemon restarts (another ot): wait for it, then reload with the new identity.
555
+ async function waitAndReload() {
556
+ for (let i = 0; i < 60; i++) {
557
+ await new Promise((r) => setTimeout(r, 500));
558
+ // the new daemon, not the one still letting go of the port (it would serve the old ot again)
559
+ const h = await fetch('/health').then((r) => (r.ok ? r.json() : null), () => null);
560
+ if (h && h.pid !== BOOT.pid) return location.reload();
561
+ }
562
+ }
563
+ // The view is in the URL (?3d): on the desktop the shell reloads it (and relaunches when the 3D ot needs
564
+ // the GPU it started without); in a browser tab the page reloads itself with the new URL.
565
+ function setView(on) {
566
+ const q = new URLSearchParams(location.search);
567
+ if (q.has('3d') === on) return false;
568
+ if (desktop) return console.debug('§7view' + (on ? '3d' : '2d')), true;
569
+ on ? q.set('3d', '1') : q.delete('3d');
570
+ location.replace(`${location.pathname}?${q}`);
571
+ return true;
572
+ }
407
573
  function connect() {
408
574
  const es = new EventSource(`/events?token=${encodeURIComponent(BOOT.token)}`);
409
575
  es.onopen = () => { live.classList.add('on'); live.textContent = t('live'); };
410
576
  es.onerror = () => { live.classList.remove('on'); live.textContent = t('off'); };
411
577
  es.onmessage = (e) => {
412
578
  const m = JSON.parse(e.data);
579
+ if (m.reload) return location.reload(); // e.g. the voice changed in the settings
580
+ if (m.restart) return waitAndReload(); // it became another ot: a new daemon is starting
581
+ if (m.view && setView(m.view === '3d')) return; // 2D ↔ 3D changed in the settings
582
+ if (guest) return; // the host's events are the host's
583
+ if (m.stir || (m.say && asleep && Date.now() < stirUntil)) stir(); // awake until it has said it
413
584
  if (m.state) render(m.state);
414
585
  if ('thinking' in m) setThinking(!!m.thinking);
415
586
  if (m.activity) addActivity(m.activity);
416
587
  if (m.gesture) ch.gesture(m.gesture);
417
588
  if (m.morph) ch.morph?.(m.morph);
418
589
  if (m.effect) ch.effect?.(m.effect);
419
- if (m.say) say(m.say);
590
+ if (m.say) say(m.say, !m.gesture);
591
+ if (m.point && desktop) window.__goal = m.point; // the desktop shell walks it over there
420
592
  };
421
593
  }
422
594
  live.textContent = t('off');
@@ -428,12 +600,23 @@
428
600
  askIn.placeholder = t('askPh');
429
601
  $('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
430
602
  $('#setBtn').title = t('settings');
603
+ $('#byeBtn').title = t('dismiss');
604
+ $('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
431
605
  const talking = (on) => {
432
606
  document.body.classList.toggle('talking', on);
433
607
  if (on) askIn.focus();
434
608
  };
435
609
  $('#talkBtn').addEventListener('click', () => talking(!document.body.classList.contains('talking') || !desktop));
436
- $('#face').addEventListener('dblclick', () => talking(true));
610
+ // double click: the menu stays out (for a while, or until another double click); talking is its 💬 button
611
+ let menuT = 0;
612
+ addEventListener('dblclick', (e) => {
613
+ if (!onOt(e) || e.target.closest?.('.actions, .ask')) return;
614
+ console.log('[7ots] menu');
615
+ const on = !document.body.classList.contains('menu');
616
+ document.body.classList.toggle('menu', on);
617
+ clearTimeout(menuT);
618
+ if (on) menuT = setTimeout(() => document.body.classList.remove('menu'), 8000);
619
+ });
437
620
  askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
438
621
  ask.addEventListener('submit', async (e) => {
439
622
  e.preventDefault();
@@ -446,12 +629,222 @@
446
629
  });
447
630
  addEventListener('message', (e) => e.data?.talk && talking(true));
448
631
 
449
- // The desktop shell tells us which way we walk.
632
+ // ── voice: record until you stop talking, transcribe (/stt), then it's like typing it (/ask) ──
633
+ let rec = null;
634
+ async function listen() {
635
+ if (rec) return rec.state === 'recording' && rec.stop();
636
+ if (!BOOT.stt) return say(t('noStt'));
637
+ let stream;
638
+ try {
639
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
640
+ } catch {
641
+ return say(t('noMic'));
642
+ }
643
+ const type = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : '';
644
+ const chunks = [];
645
+ rec = new MediaRecorder(stream, type ? { mimeType: type } : {});
646
+ rec.ondataavailable = (e) => e.data.size && chunks.push(e.data);
647
+ const ac = new AudioContext();
648
+ const an = ac.createAnalyser();
649
+ an.fftSize = 1024;
650
+ ac.createMediaStreamSource(stream).connect(an);
651
+ const buf = new Float32Array(an.fftSize);
652
+ const t0 = Date.now();
653
+ let heard = false;
654
+ let quiet = t0;
655
+ let peak = 0;
656
+ const iv = setInterval(() => {
657
+ an.getFloatTimeDomainData(buf);
658
+ let sum = 0;
659
+ for (const x of buf) sum += x * x;
660
+ const now = Date.now();
661
+ const level = Math.sqrt(sum / buf.length);
662
+ if (level > 0.015) (heard = true), (quiet = now);
663
+ peak = Math.max(peak, level);
664
+ // stops after 1.3 s of silence, 6 s without a word, or 15 s in all
665
+ if ((heard && now - quiet > 1300) || (!heard && now - t0 > 6000) || now - t0 > 15000) rec?.state === 'recording' && rec.stop();
666
+ }, 80);
667
+ rec.onstop = async () => {
668
+ clearInterval(iv);
669
+ stream.getTracks().forEach((x) => x.stop());
670
+ ac.close().catch(() => {});
671
+ rec = null;
672
+ document.body.classList.remove('listening');
673
+ console.log('[7ots voice] stopped', { heard, peak: peak.toFixed(3), chunks: chunks.length, ms: Date.now() - t0 });
674
+ if (!chunks.length) return bubble.classList.remove('on');
675
+ if (!heard && peak < 0.004) return say(t('noMic')); // silence all along: wrong or muted microphone
676
+ setThinking(true);
677
+ const blob = new Blob(chunks, { type: chunks[0].type || 'audio/webm' });
678
+ const r = await fetch('/stt', { method: 'POST', headers: { 'Content-Type': blob.type, 'X-7ots-Token': BOOT.token }, body: blob })
679
+ .then((x) => x.json())
680
+ .catch(() => null);
681
+ setThinking(false);
682
+ const text = r?.text?.trim();
683
+ if (!text) return say(r?.error && !/stt/.test(r.error) ? t('noStt') : t('sttErr'));
684
+ showBubble(document.createTextNode(`🎙️ “${text}”`), 2500);
685
+ post('/ask', { text });
686
+ };
687
+ rec.onerror = (e) => console.warn('[7ots voice] recorder', e.error?.message || e);
688
+ const hard = setTimeout(() => rec?.state === 'recording' && rec.stop(), 15000); // whatever the level meter says
689
+ rec.addEventListener('stop', () => clearTimeout(hard));
690
+ ac.resume().catch(() => {});
691
+ rec.start(250);
692
+ console.log('[7ots voice] listening', rec.mimeType, ac.state);
693
+ document.body.classList.add('listening');
694
+ showBubble(document.createTextNode(t('listening')), 0);
695
+ ch.gesture('nod');
696
+ lookFree = Date.now() + 2000;
697
+ ch.lookAt(0, 0);
698
+ }
699
+ $('#micBtn').addEventListener('click', listen);
700
+
701
+ // The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
702
+ addEventListener('message', (e) => {
703
+ const d = e.data || {};
704
+ if (d.walk != null) {
705
+ if (is3d) ch.character.yaw = -d.walk * 0.9; // turns toward where it walks, smoothly
706
+ else $('#stage').classList.toggle('flip', d.walk < 0);
707
+ }
708
+ if (d.hop) ch.gesture('bounce');
709
+ if (d.errand) ch.effect?.('exclaim');
710
+ if (d.point) {
711
+ lookFree = Date.now() + 3500;
712
+ ch.lookAtPoint(d.at[0], d.at[1]);
713
+ ch.gesture('point', { ms: 2200 });
714
+ }
715
+ if (d.listen) listen();
716
+ if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
717
+ if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
718
+ if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
719
+ });
720
+
721
+ // ── scenes with other ots: the shell's director sends lines, gestures and acts to each one ──
722
+ const act = $('#act');
723
+ const props = $('#props');
724
+ const prop = (cls, css = {}, text = '') => {
725
+ const e = Object.assign(document.createElement('div'), { className: cls, textContent: text });
726
+ Object.assign(e.style, css);
727
+ props.append(e);
728
+ return e;
729
+ };
730
+ const gone = (e, a) => a.finished.then(() => e.remove(), () => e.remove());
731
+ const ACTS = {
732
+ guard: () => act.animate([{ transform: 'none' }, { transform: 'translateY(-5px) rotate(-3deg)' }, { transform: 'none' }, { transform: 'translateY(-5px) rotate(3deg)' }, { transform: 'none' }], { duration: 700, iterations: 3 }),
733
+ jab: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 30}px) rotate(${d * 7}deg) scaleX(1.06)`, offset: 0.35 }, { transform: 'none' }], { duration: 280, easing: 'ease-out' }),
734
+ uppercut: (d) => act.animate([{ transform: 'none' }, { transform: `translate(${-d * 6}px, 8px) rotate(${-d * 8}deg)`, offset: 0.3 }, { transform: `translate(${d * 22}px, -30px) rotate(${d * 16}deg)`, offset: 0.6 }, { transform: 'none' }], { duration: 650, easing: 'ease-out' }),
735
+ hit: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 16}px) rotate(${-d * 12}deg)`, offset: 0.25 }, { transform: 'none' }], { duration: 420 }),
736
+ pow: (d, o) => {
737
+ const e = prop('pow', {}, o?.text || 'POW!');
738
+ return gone(e, e.animate([{ opacity: 0, transform: 'translateX(-50%) scale(.3)' }, { opacity: 1, transform: 'translateX(-50%) scale(1.25) rotate(-8deg)', offset: 0.3 }, { opacity: 0, transform: 'translate(-50%, -16px) scale(1)' }], { duration: 900 }));
739
+ },
740
+ // Matrix: bending back in slow motion while the bullets go by
741
+ lean: (d) => {
742
+ const a = act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 74}deg) translateX(${-d * 8}px)`, offset: 0.22 }, { transform: `rotate(${-d * 80}deg) translateX(${-d * 10}px)`, offset: 0.78 }, { transform: 'none' }], { duration: 3200, easing: 'cubic-bezier(.3,.7,.3,1)' });
743
+ for (let i = 0; i < 3; i++) {
744
+ const y = `${26 + i * 7}%`;
745
+ const from = d > 0 ? `calc(100% + ${actRoom}px)` : `-${actRoom}px`;
746
+ const to = d > 0 ? `-${actRoom}px` : `calc(100% + ${actRoom}px)`;
747
+ const b = prop('bullet', { top: y, left: from });
748
+ gone(b, b.animate([{ left: from }, { left: to }], { duration: 2400, delay: 500 + i * 260, easing: 'linear', fill: 'both' }));
749
+ const r = prop('ring', { top: y, left: from, opacity: 0 });
750
+ gone(r, r.animate([{ left: from, opacity: 0 }, { left: '50%', opacity: 1, transform: 'scale(1)' }, { left: to, opacity: 0, transform: 'scale(2.6)' }], { duration: 2400, delay: 500 + i * 260, fill: 'both' }));
751
+ }
752
+ return a;
753
+ },
754
+ fall: (d) => act.animate([{ transform: 'none' }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.2 }, { transform: `rotate(${-d * 90}deg) translateY(10px)`, offset: 0.8 }, { transform: 'none' }], { duration: 2600, easing: 'ease-in-out' }),
755
+ charge: () => act.animate([{ transform: 'none', filter: 'none' }, { transform: 'translate(-2px,1px)', filter: 'drop-shadow(0 0 10px #59c4ff)' }, { transform: 'translate(2px,-1px)', filter: 'drop-shadow(0 0 18px #59c4ff)' }, { transform: 'none', filter: 'none' }], { duration: 160, iterations: 9 }),
756
+ hadouken: (d) => {
757
+ const b = prop('ball');
758
+ gone(b, b.animate([{ transform: 'translateX(0) scale(.4)' }, { transform: `translateX(${d * (actRoom - 10)}px) scale(1.15)` }], { duration: 650, easing: 'ease-in' }));
759
+ return act.animate([{ transform: 'none' }, { transform: `translateX(${d * 12}px)`, offset: 0.2 }, { transform: 'none' }], { duration: 700 });
760
+ },
761
+ beam: (d) => {
762
+ const b = prop('beam', d > 0 ? { left: '50%' } : { right: '50%', left: 'auto' });
763
+ gone(b, b.animate([{ width: '0px', opacity: 1 }, { width: `${actRoom}px`, opacity: 1, offset: 0.25 }, { width: `${actRoom}px`, opacity: 1, offset: 0.8 }, { width: `${actRoom}px`, opacity: 0 }], { duration: 1800 }));
764
+ return act.animate([{ transform: 'none' }, { transform: `translateX(${-d * 6}px)` }], { duration: 120, iterations: 14, direction: 'alternate' });
765
+ },
766
+ saber: (d, o) => {
767
+ const c = o?.color || '#ff3b3b';
768
+ const s = prop('saber', { background: `linear-gradient(90deg, ${c}, #fff 40%, #fff 60%, ${c})`, boxShadow: `0 0 12px 4px ${c}`, left: `calc(50% + ${d * 34}px)` });
769
+ return gone(s, s.animate([{ transform: `rotate(${-d * 40}deg)` }, { transform: `rotate(${d * 50}deg)` }, { transform: `rotate(${-d * 20}deg)` }, { transform: `rotate(${d * 70}deg)` }, { transform: `rotate(${-d * 40}deg)` }], { duration: 2400, easing: 'ease-in-out' }));
770
+ },
771
+ bang: (d) => {
772
+ const f = prop('flash', { left: `calc(50% + ${d * 46}px)` });
773
+ return gone(f, f.animate([{ opacity: 1, transform: 'scale(.4)' }, { opacity: 0, transform: 'scale(1.6)' }], { duration: 260 }));
774
+ },
775
+ twist: () => act.animate([{ transform: 'rotate(-12deg) translateX(-6px)' }, { transform: 'rotate(12deg) translateX(6px)' }], { duration: 330, iterations: 10, direction: 'alternate', easing: 'ease-in-out' }),
776
+ shove: (d) => act.animate([{ transform: 'none' }, { transform: `translateX(${d * 24}px) scaleX(1.1)`, offset: 0.4 }, { transform: 'none' }], { duration: 380 }),
777
+ };
450
778
  addEventListener('message', (e) => {
451
- if (e.data?.walk == null) return;
452
- if (is3d) ch.character.yaw = e.data.walk ? -e.data.walk * 0.9 : 0; // turns toward where it walks
453
- else $('#stage').classList.toggle('flip', e.data.walk < 0);
779
+ const d = e.data || {};
780
+ if (d.hush) (bubble.classList.remove('on'), clearTimeout(say.t), talk(false)); // the other ot's turn to talk
781
+ if (d.line) {
782
+ showBubble(document.createTextNode(d.line), d.ms || Math.max(2200, d.line.length * 70));
783
+ talk(true, d.line);
784
+ clearTimeout(say.t);
785
+ say.t = setTimeout(() => talk(false), 400 + d.line.length * 55);
786
+ }
787
+ if (d.gesture) ch.gesture(d.gesture, d.gms ? { ms: d.gms } : undefined);
788
+ if (d.effect) ch.effect?.(d.effect);
789
+ if (d.act && ACTS[d.act]) {
790
+ actRoom = d.room || 150;
791
+ actUntil = Date.now() + (d.ms || 3600);
792
+ tellNeed();
793
+ requestAnimationFrame(() => ACTS[d.act](d.dir || 1, d));
794
+ }
795
+ if (d.face != null) {
796
+ if (is3d) ch.character.yaw = -d.face * 0.9;
797
+ else $('#stage').classList.toggle('flip', d.face < 0);
798
+ lookFree = Date.now() + 1500;
799
+ ch.lookAt?.(d.face * 0.8, 0);
800
+ }
454
801
  });
802
+
803
+ // ── personality: small life between events (glances, fidgets, effects, shape changes) ──
804
+ const pick = (a) => a[Math.floor(Math.random() * a.length)];
805
+ const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
806
+ const weighted = (list) => {
807
+ let r = Math.random() * list.reduce((a, x) => a + x[0], 0);
808
+ for (const x of list) if ((r -= x[0]) <= 0) return x[1];
809
+ };
810
+ function glance() {
811
+ lookFree = Date.now() + 1400 + Math.random() * 1200;
812
+ ch.lookAt((Math.random() * 2 - 1) * 0.8, (Math.random() * 2 - 1) * 0.5);
813
+ if (Math.random() < 0.4) ch.blink?.();
814
+ }
815
+ function fidget() {
816
+ const s = last || {};
817
+ const tired = s.energy < 35;
818
+ const bored = s.fun < 35;
819
+ const hungry = s.food < 35;
820
+ const happy = s.mood === 'happy';
821
+ weighted([
822
+ [5, glance],
823
+ [2, () => ch.gesture('wiggle')],
824
+ [1.5, () => (ch.gesture('think'), ch.effect?.('question'))],
825
+ [1, () => ch.gesture('shrug')],
826
+ [0.6, () => ch.gesture('hiccup')],
827
+ [0.5, () => (ch.gesture('sneeze'), ch.effect?.('sneeze'))],
828
+ [happy ? 2.5 : 0.6, () => (ch.gesture('dance'), ch.effect?.('notes'))],
829
+ [happy ? 0.8 : 0.2, () => (ch.gesture('celebrate'), ch.effect?.('sparkles'))],
830
+ [0.6, () => ch.gesture('spin')],
831
+ [0.5, () => ch.gesture('clap')],
832
+ [tired ? 3 : 0.4, () => ch.gesture('yawn')],
833
+ [bored ? 1.5 : 0.2, () => (ch.gesture('stomp'), ch.effect?.('steam'))],
834
+ [hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
835
+ [0.6, () => ch.effect?.('blush')],
836
+ [0.3, () => ch.gesture('shiver')],
837
+ [1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch', 'jelly']), { ms: 1600 + Math.random() * 1600 })],
838
+ [0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
839
+ [0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
840
+ ])?.();
841
+ }
842
+ (function life() {
843
+ setTimeout(() => {
844
+ if (!asleep && !thinking && !rec && !bubble.classList.contains('on') && !document.hidden) fidget();
845
+ life();
846
+ }, 4000 + Math.random() * 8000);
847
+ })();
455
848
  </script>
456
849
  </body>
457
850
  </html>