@7ots/cli 0.1.5 → 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 +354 -49
  24. package/cli/pet/pet.html +328 -15
  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,14 +272,106 @@
246
272
  }
247
273
  ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
248
274
  const is3d = !!ch.character;
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
+ }
249
359
  let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
250
360
  addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
251
361
  $('#face').addEventListener('click', () => ch.gesture('bounce'));
252
362
 
253
363
  const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
254
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
+ }
255
373
  document.querySelectorAll('[data-act]').forEach((b) =>
256
- 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 })),
257
375
  );
258
376
 
259
377
  // ── state ──
@@ -277,8 +395,9 @@
277
395
  function render(s) {
278
396
  last = s;
279
397
  asleep = s.asleep;
280
- document.body.classList.toggle('asleep', asleep);
281
- 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);
282
401
  paintMood();
283
402
  $('#sleepBtn [data-t]').textContent = t(asleep ? 'wake' : 'sleep');
284
403
  $('#sleepBtn').setAttribute('aria-label', t(asleep ? 'wake' : 'sleep'));
@@ -432,12 +551,36 @@
432
551
 
433
552
  // ── live updates ──
434
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
+ }
435
573
  function connect() {
436
574
  const es = new EventSource(`/events?token=${encodeURIComponent(BOOT.token)}`);
437
575
  es.onopen = () => { live.classList.add('on'); live.textContent = t('live'); };
438
576
  es.onerror = () => { live.classList.remove('on'); live.textContent = t('off'); };
439
577
  es.onmessage = (e) => {
440
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
441
584
  if (m.state) render(m.state);
442
585
  if ('thinking' in m) setThinking(!!m.thinking);
443
586
  if (m.activity) addActivity(m.activity);
@@ -445,6 +588,7 @@
445
588
  if (m.morph) ch.morph?.(m.morph);
446
589
  if (m.effect) ch.effect?.(m.effect);
447
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
448
592
  };
449
593
  }
450
594
  live.textContent = t('off');
@@ -456,12 +600,23 @@
456
600
  askIn.placeholder = t('askPh');
457
601
  $('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
458
602
  $('#setBtn').title = t('settings');
603
+ $('#byeBtn').title = t('dismiss');
604
+ $('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
459
605
  const talking = (on) => {
460
606
  document.body.classList.toggle('talking', on);
461
607
  if (on) askIn.focus();
462
608
  };
463
609
  $('#talkBtn').addEventListener('click', () => talking(!document.body.classList.contains('talking') || !desktop));
464
- $('#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
+ });
465
620
  askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
466
621
  ask.addEventListener('submit', async (e) => {
467
622
  e.preventDefault();
@@ -474,6 +629,75 @@
474
629
  });
475
630
  addEventListener('message', (e) => e.data?.talk && talking(true));
476
631
 
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
+
477
701
  // The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
478
702
  addEventListener('message', (e) => {
479
703
  const d = e.data || {};
@@ -482,11 +706,100 @@
482
706
  else $('#stage').classList.toggle('flip', d.walk < 0);
483
707
  }
484
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();
485
716
  if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
486
717
  if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
487
718
  if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
488
719
  });
489
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
+ };
778
+ addEventListener('message', (e) => {
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
+ }
801
+ });
802
+
490
803
  // ── personality: small life between events (glances, fidgets, effects, shape changes) ──
491
804
  const pick = (a) => a[Math.floor(Math.random() * a.length)];
492
805
  const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
@@ -528,7 +841,7 @@
528
841
  }
529
842
  (function life() {
530
843
  setTimeout(() => {
531
- if (!asleep && !thinking && !bubble.classList.contains('on') && !document.hidden) fidget();
844
+ if (!asleep && !thinking && !rec && !bubble.classList.contains('on') && !document.hidden) fidget();
532
845
  life();
533
846
  }, 4000 + Math.random() * 8000);
534
847
  })();