@7ots/cli 0.1.6 → 0.1.8

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 (61) hide show
  1. package/README.md +3 -3
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +107 -38
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +5 -1
  7. package/cli/lib/inferences.mjs +30 -6
  8. package/cli/lib/integrations.mjs +69 -5
  9. package/cli/lib/orquesta.mjs +44 -1
  10. package/cli/lib/pet-server.mjs +352 -11
  11. package/cli/lib/prompts.mjs +31 -17
  12. package/cli/lib/prowl.mjs +110 -0
  13. package/cli/pet/electron/director.cjs +37 -5
  14. package/cli/pet/electron/main.cjs +92 -10
  15. package/cli/pet/pet.html +149 -10
  16. package/cli/pet/settings.html +549 -43
  17. package/dist/7ots.esm.js +216 -137
  18. package/dist/7ots.esm.js.map +4 -4
  19. package/dist/7ots.iife.js +216 -137
  20. package/dist/7ots.iife.js.map +4 -4
  21. package/llms.txt +1 -1
  22. package/package.json +17 -14
  23. package/server/channels/agent.mjs +7 -1
  24. package/server/channels/index.mjs +3 -2
  25. package/server/platform/connect.mjs +4 -1
  26. package/server/platform/db.mjs +14 -1
  27. package/server/platform/device.mjs +101 -4
  28. package/server/platform/mods.mjs +222 -0
  29. package/server/platform/orquesta.mjs +78 -14
  30. package/server/platform/profile.mjs +161 -0
  31. package/server/platform/routes.mjs +39 -33
  32. package/server/platform/runtime.mjs +5 -3
  33. package/server/platform/store.mjs +1 -1
  34. package/server/platform/vm.mjs +688 -17
  35. package/server/server.mjs +12 -2
  36. package/server/tts.mjs +17 -4
  37. package/src/avatar/three.js +3 -2
  38. package/src/character/AvatarEditor.js +95 -7
  39. package/src/character/Character.js +116 -17
  40. package/src/character/TradingCard.js +226 -0
  41. package/src/character/mods/index.js +4 -0
  42. package/src/character/mods/orc.js +64 -0
  43. package/src/character/mods.js +251 -0
  44. package/src/character/more/acc-head.js +213 -0
  45. package/src/character/more/acc-rest.js +342 -0
  46. package/src/character/more/brows.js +39 -0
  47. package/src/character/more/cheeks.js +78 -0
  48. package/src/character/more/eyes.js +288 -0
  49. package/src/character/more/mouths.js +259 -0
  50. package/src/character/more/patterns.js +120 -0
  51. package/src/character/parts.js +100 -0
  52. package/src/character/skin.js +166 -1
  53. package/src/character/styles.js +1285 -9
  54. package/src/character3d/Character3D.js +253 -37
  55. package/src/character3d/index.js +7 -1
  56. package/src/character3d/model.js +367 -0
  57. package/src/i18n/messages/character.js +37 -22
  58. package/src/i18n/messages/platform.js +33 -0
  59. package/src/identity/random.js +17 -3
  60. package/src/identity/schema.js +10 -0
  61. package/src/index.js +1 -0
package/cli/pet/pet.html CHANGED
@@ -45,6 +45,12 @@
45
45
  opacity: 0; transform: translate(-50%, 6px) scale(.97); transform-origin: 50% 100%; transition: opacity .22s, transform .22s; pointer-events: none; }
46
46
  .bubble.on { opacity: 1; transform: translate(-50%, 0) scale(1); }
47
47
  .bubble::after { content: ''; position: absolute; left: 50%; top: 100%; width: 12px; height: 12px; margin: -6px 0 0 -6px; background: inherit; border: inherit; border-width: 0 1px 1px 0; transform: rotate(45deg); }
48
+ .bubble.asking { pointer-events: auto; -webkit-app-region: no-drag; border-color: var(--accent); }
49
+ .bubble .q { display: block; margin-bottom: 8px; }
50
+ .bubble .yn { display: flex; gap: 6px; justify-content: flex-end; }
51
+ .bubble .yn button { font: inherit; font-size: 12.5px; font-weight: 650; padding: 5px 12px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--ink); cursor: pointer; }
52
+ .bubble .yn button.ok { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
53
+ .bubble .yn button:hover { filter: brightness(1.08); }
48
54
  .dots { display: inline-flex; gap: 4px; padding: 2px 0; }
49
55
  .dots b { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: dot 1s infinite ease-in-out; }
50
56
  .dots b:nth-child(2) { animation-delay: .15s; } .dots b:nth-child(3) { animation-delay: .3s; }
@@ -119,8 +125,11 @@
119
125
  body.desktop .app { width: 100%; background: transparent; border: 0; box-shadow: none; border-radius: 0; overflow: visible; }
120
126
  body.desktop .top, body.desktop .who, body.desktop .level, body.desktop .stats, body.desktop .feed, body.desktop .foot { display: none; }
121
127
  body.desktop .hero { background: none; padding: 0 0 6px; }
122
- body.desktop .stage { width: 140px; height: 148px; }
128
+ body.desktop .stage { width: calc(140px * var(--pet-scale, 1)); height: calc(148px * var(--pet-scale, 1)); } /* stretched from a corner (see 'resize' below) */
123
129
  body.desktop .stage.flip .face { transform: scaleX(-1); }
130
+ /* the 3D ot is framed with room under its feet (~34px at 1x) that grows with the stretch: the figure is drawn
131
+ lower by what that room grew, so a bigger ot still stands where it stood; the stage (hit area) stays put */
132
+ body.desktop.ot3d .face { top: calc(34px * (var(--pet-scale, 1) - 1)); }
124
133
  /* scenes with other ots (electron/director.cjs): the acts move .act; props (fireballs, beams, sabers, bullets) */
125
134
  .actor { position: absolute; inset: 0; z-index: 1; transform-origin: 50% 92%; }
126
135
  .props { position: absolute; inset: 0; z-index: 3; pointer-events: none; overflow: visible; }
@@ -137,7 +146,7 @@
137
146
  .bye { display: none; }
138
147
  body.desktop.guest .bye { display: flex; }
139
148
  body.desktop .bubble { max-width: 220px; font-size: 12.5px; padding: 8px 12px; }
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;
149
+ body.desktop .actions { position: absolute; right: auto; left: calc(50% + 70px * var(--pet-scale, 1) - 4px); /* beside the ot at any size: over it, it hid the corners that stretch it */ bottom: 14px; transform: translateX(6px); display: flex; flex-direction: column; gap: 4px; padding: 4px; border-radius: 99px;
141
150
  background: color-mix(in srgb, var(--panel) 86%, transparent); border: 1px solid var(--line); box-shadow: var(--shadow); backdrop-filter: blur(10px);
142
151
  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
152
  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 */
@@ -213,17 +222,17 @@
213
222
  <script type="module">
214
223
  const BOOT = __BOOT__;
215
224
  const T = {
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…',
225
+ 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…', approve: 'Approve', reject: 'Reject',
217
226
  activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
218
227
  brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
219
228
  moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
220
229
  kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
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…',
230
+ 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…', approve: 'Aprobar', reject: 'Rechazar',
222
231
  activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
223
232
  brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
224
233
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
225
234
  kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
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…',
235
+ 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…', approve: 'Aprovar', reject: 'Recusar',
227
236
  activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
228
237
  brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
229
238
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
@@ -272,6 +281,7 @@
272
281
  }
273
282
  ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
274
283
  const is3d = !!ch.character;
284
+ document.body.classList.toggle('ot3d', is3d);
275
285
  // While a gesture/effect/morph plays the ot needs room around it (wings, hearts, confetti): the desktop
276
286
  // shell reads window.__need() and sizes the window to what is really on screen.
277
287
  let fxUntil = 0;
@@ -319,6 +329,7 @@
319
329
  }
320
330
  if (document.body.classList.contains('talking')) (w = Math.max(w, 240)), (h = Math.max(h, st.height + 60));
321
331
  if (Date.now() < actUntil) (w = Math.max(w, actRoom * 2)), (h = Math.max(h, st.height + 70));
332
+ w = Math.max(w, st.width + 2 * 58); // the menu beside it (shown on hover)
322
333
  return { w: Math.ceil(w), h: Math.ceil(h) };
323
334
  };
324
335
  // The shell polls __need every 250 ms; when an effect starts it's told at once (a console line it listens
@@ -341,9 +352,53 @@
341
352
  const r = $('#stage').getBoundingClientRect();
342
353
  return e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
343
354
  };
355
+ // Size: grabbing the ot by a corner stretches it (bigger or smaller) instead of moving it; remembered per ot.
356
+ const SCALE_KEY = `7ots.petScale${guest ? `.${guest}` : ''}`;
357
+ let scale = 1;
358
+ try {
359
+ scale = Math.max(0.6, Math.min(2, Number(localStorage.getItem(SCALE_KEY)) || 1));
360
+ } catch {}
361
+ const setScale = (k) => ((scale = k), document.documentElement.style.setProperty('--pet-scale', k.toFixed(3)), tellNeed());
362
+ if (desktop && scale !== 1) setScale(scale);
363
+ const CORNER = 22;
364
+ // which corner of the ot the pointer is on ('nw' | 'ne' | 'sw' | 'se'), or ''
365
+ const cornerAt = (e) => {
366
+ if (!desktop || !onOt(e)) return '';
367
+ const r = $('#stage').getBoundingClientRect();
368
+ const v = e.clientY - r.top < CORNER ? 'n' : r.bottom - e.clientY < CORNER ? 's' : '';
369
+ const h = e.clientX - r.left < CORNER ? 'w' : r.right - e.clientX < CORNER ? 'e' : '';
370
+ return v && h ? v + h : '';
371
+ };
344
372
  if (desktop) {
345
373
  let down = false;
346
- const up = () => down && ((down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false })));
374
+ let sizing = null;
375
+ let pulled = null; // { m, on }: a drag that started off the corners
376
+ const up = () => {
377
+ if (pulled?.on) {
378
+ ch.unmorph?.(pulled.m); // let go: back into shape
379
+ fxUntil = Date.now() + 600; // room while it eases back
380
+ }
381
+ pulled = null;
382
+ if (sizing) {
383
+ sizing = null;
384
+ try {
385
+ localStorage.setItem(SCALE_KEY, String(scale));
386
+ } catch {}
387
+ }
388
+ if (down) (down = false), console.debug('\u00a77drag' + JSON.stringify({ on: false }));
389
+ };
390
+ addEventListener('pointermove', (e) => {
391
+ if (sizing) {
392
+ // the ot stays anchored at its feet, centred: sideways it grows on both sides, so a corner moves half as much
393
+ const dx = (e.screenX - sizing.x) * (sizing.c[1] === 'e' ? 1 : -1) * 2;
394
+ const dy = (e.screenY - sizing.y) * (sizing.c[0] === 's' ? 1 : -1);
395
+ const k = sizing.k * ((140 * sizing.k + dx) / (140 * sizing.k) + (148 * sizing.k + dy) / (148 * sizing.k)) / 2;
396
+ return setScale(Math.max(0.6, Math.min(2, k)));
397
+ }
398
+ if (down) return;
399
+ const c = cornerAt(e);
400
+ document.body.style.cursor = c ? (c === 'nw' || c === 'se' ? 'nwse-resize' : 'nesw-resize') : '';
401
+ });
347
402
  // on the document: in 3D the ot is drawn on a canvas above the page, so events don't target #stage
348
403
  addEventListener('pointerdown', (e) => {
349
404
  // on the ot itself, or on the move handle in the menu (a sure grip while the ot is mid-animation)
@@ -351,14 +406,50 @@
351
406
  if (e.button !== 0 || (!grip && (e.target.closest?.('.actions, .ask, a, button') || !onOt(e)))) return;
352
407
  down = true;
353
408
  document.documentElement.setPointerCapture?.(e.pointerId);
354
- console.debug('\u00a77drag' + JSON.stringify({ on: true }));
409
+ const c = !grip && cornerAt(e);
410
+ if (c) sizing = { c, x: e.screenX, y: e.screenY, k: scale };
411
+ else if (!grip) {
412
+ const r = $('#stage').getBoundingClientRect();
413
+ const side = Math.abs(e.clientX - (r.left + r.width / 2)) / r.width > Math.abs(e.clientY - (r.top + r.height / 2)) / r.height;
414
+ pulled = { m: side ? 'squish' : 'stretch', on: false };
415
+ }
416
+ // resizing: the shell keeps the mouse but leaves the window where it is
417
+ console.debug('\u00a77drag' + JSON.stringify({ on: true, resize: !!c }));
418
+ });
419
+ // pulled by a side or the top: once the shell says the window is really being carried (the page sees
420
+ // almost no pointer motion then: the window moves with the hand), it stretches toward the pull — wider
421
+ // from a side, taller from above or below — until it's let go
422
+ addEventListener('message', (e) => {
423
+ if (!e.data?.carried || !pulled || pulled.on) return;
424
+ pulled.on = true;
425
+ fxUntil = Infinity; // room around it for as long as it's held stretched
426
+ ch.morph?.(pulled.m, { hold: true });
427
+ tellNeed();
355
428
  });
356
429
  for (const ev of ['pointerup', 'pointercancel', 'lostpointercapture']) addEventListener(ev, up);
357
430
  addEventListener('blur', up);
358
431
  }
359
432
  let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
360
433
  addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
361
- $('#face').addEventListener('click', () => ch.gesture('bounce'));
434
+ // A click on the ot pokes it: it gets squished and, poked again and again, more and more annoyed.
435
+ // Waits a beat so a double click (the menu) doesn't count as two pokes; a drag or a stretch isn't a poke.
436
+ let pressAt = null, pokeT = 0, pokes = 0, pokedAt = 0;
437
+ addEventListener('pointerdown', (e) => (pressAt = { x: e.screenX, y: e.screenY }));
438
+ addEventListener('click', (e) => {
439
+ clearTimeout(pokeT);
440
+ if (e.detail > 1 || !onOt(e) || e.target.closest?.('.actions, .ask, .bubble.asking, a, button')) return;
441
+ if (pressAt && Math.hypot(e.screenX - pressAt.x, e.screenY - pressAt.y) > 4) return;
442
+ pokeT = setTimeout(poke, 240);
443
+ });
444
+ function poke() {
445
+ pokes = Date.now() - pokedAt < 2500 ? pokes + 1 : 1;
446
+ pokedAt = Date.now();
447
+ lookFree = 0;
448
+ ch.morph?.('squish', { ms: 420 });
449
+ if (pokes === 1) ch.gesture('bounce');
450
+ else if (pokes <= 3) (ch.gesture('surprise'), ch.effect?.('exclaim'));
451
+ else (ch.gesture(pokes % 2 ? 'stomp' : 'shake'), ch.effect?.('anger'), pokes >= 6 && ch.effect?.('steam'));
452
+ }
362
453
 
363
454
  const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
364
455
  let asleep = false;
@@ -501,6 +592,8 @@
501
592
  speechSynthesis.speak(u);
502
593
  }
503
594
  function showBubble(content, ms) {
595
+ if (asking && !content.classList?.contains('askq')) return; // a question waiting for you stays until answered
596
+ bubble.classList.remove('asking');
504
597
  bubble.replaceChildren(content);
505
598
  bubble.classList.add('on');
506
599
  clearTimeout(hideT);
@@ -537,6 +630,42 @@
537
630
  if (withFace) react(text);
538
631
  speak(text);
539
632
  }
633
+ // It asks before acting for you (Orquesta…): Approve / Reject in the bubble, answered on POST /approve.
634
+ let asking = null;
635
+ function ask4(a) {
636
+ if (a.done) {
637
+ if (asking !== a.id) return;
638
+ asking = null;
639
+ bubble.classList.remove('on', 'asking');
640
+ return;
641
+ }
642
+ asking = null;
643
+ const box = document.createElement('div');
644
+ box.className = 'askq';
645
+ const q = document.createElement('span');
646
+ q.className = 'q';
647
+ q.textContent = a.text;
648
+ const yn = document.createElement('div');
649
+ yn.className = 'yn';
650
+ const btn = (ok) => {
651
+ const b = document.createElement('button');
652
+ b.type = 'button';
653
+ b.textContent = t(ok ? 'approve' : 'reject');
654
+ if (ok) b.className = 'ok';
655
+ b.addEventListener('click', () => {
656
+ yn.querySelectorAll('button').forEach((x) => (x.disabled = true));
657
+ post('/approve', { id: a.id, ok }).catch(() => {});
658
+ });
659
+ return b;
660
+ };
661
+ yn.append(btn(false), btn(true));
662
+ box.append(q, yn);
663
+ showBubble(box, 0);
664
+ bubble.classList.add('asking');
665
+ asking = a.id;
666
+ ch.gesture('think');
667
+ speak(a.text);
668
+ }
540
669
  function setThinking(on) {
541
670
  if (on === thinking) return;
542
671
  thinking = on;
@@ -546,7 +675,7 @@
546
675
  d.className = 'dots';
547
676
  d.innerHTML = '<b></b><b></b><b></b>';
548
677
  showBubble(d, 0);
549
- } else if (bubble.querySelector('.dots')) bubble.classList.remove('on');
678
+ } else if (!asking && bubble.querySelector('.dots')) bubble.classList.remove('on');
550
679
  }
551
680
 
552
681
  // ── live updates ──
@@ -582,6 +711,7 @@
582
711
  if (guest) return; // the host's events are the host's
583
712
  if (m.stir || (m.say && asleep && Date.now() < stirUntil)) stir(); // awake until it has said it
584
713
  if (m.state) render(m.state);
714
+ if (m.ask) ask4(m.ask); // before thinking: an approved ask hands the bubble back to the dots
585
715
  if ('thinking' in m) setThinking(!!m.thinking);
586
716
  if (m.activity) addActivity(m.activity);
587
717
  if (m.gesture) ch.gesture(m.gesture);
@@ -600,6 +730,7 @@
600
730
  askIn.placeholder = t('askPh');
601
731
  $('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
602
732
  $('#setBtn').title = t('settings');
733
+ if (!desktop) $('#setBtn').removeAttribute('target'); // in a browser tab, Settings opens in place (its Back returns here); the desktop window opens a tab
603
734
  $('#byeBtn').title = t('dismiss');
604
735
  $('#byeBtn').addEventListener('click', () => console.debug('\u00a77bye')); // the shell sends the visitor home
605
736
  const talking = (on) => {
@@ -617,6 +748,14 @@
617
748
  clearTimeout(menuT);
618
749
  if (on) menuT = setTimeout(() => document.body.classList.remove('menu'), 8000);
619
750
  });
751
+ // right click: the shell's menu (settings, talk, sleep, hide…) at the cursor. Asked for by name: Electron's own
752
+ // 'context-menu' event didn't reach the shell from this transparent click-through window on Linux.
753
+ if (desktop)
754
+ addEventListener('contextmenu', (e) => {
755
+ if (e.target.closest?.('.ask input')) return; // the talk box keeps copy/paste
756
+ e.preventDefault();
757
+ console.debug('\u00a77ctx');
758
+ });
620
759
  askIn.addEventListener('keydown', (e) => e.key === 'Escape' && talking(false));
621
760
  ask.addEventListener('submit', async (e) => {
622
761
  e.preventDefault();
@@ -834,7 +973,7 @@
834
973
  [hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
835
974
  [0.6, () => ch.effect?.('blush')],
836
975
  [0.3, () => ch.gesture('shiver')],
837
- [1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch', 'jelly']), { ms: 1600 + Math.random() * 1600 })],
976
+ [1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch']), { ms: 1600 + Math.random() * 1600 })],
838
977
  [0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
839
978
  [0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
840
979
  ])?.();