@7ots/cli 0.1.7 → 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 (57) hide show
  1. package/README.md +1 -1
  2. package/cli/7ots.mjs +13 -1
  3. package/cli/lib/account.mjs +152 -2
  4. package/cli/lib/assistant.mjs +106 -41
  5. package/cli/lib/brain.mjs +13 -2
  6. package/cli/lib/config.mjs +3 -0
  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 +344 -11
  11. package/cli/pet/electron/director.cjs +37 -5
  12. package/cli/pet/electron/main.cjs +92 -10
  13. package/cli/pet/pet.html +149 -10
  14. package/cli/pet/settings.html +545 -42
  15. package/dist/7ots.esm.js +216 -137
  16. package/dist/7ots.esm.js.map +4 -4
  17. package/dist/7ots.iife.js +216 -137
  18. package/dist/7ots.iife.js.map +4 -4
  19. package/package.json +1 -1
  20. package/server/channels/agent.mjs +7 -1
  21. package/server/channels/index.mjs +3 -2
  22. package/server/platform/connect.mjs +4 -1
  23. package/server/platform/db.mjs +9 -1
  24. package/server/platform/device.mjs +101 -4
  25. package/server/platform/mods.mjs +222 -0
  26. package/server/platform/orquesta.mjs +78 -14
  27. package/server/platform/profile.mjs +161 -0
  28. package/server/platform/routes.mjs +38 -33
  29. package/server/platform/runtime.mjs +3 -1
  30. package/server/platform/store.mjs +1 -1
  31. package/server/platform/vm.mjs +370 -91
  32. package/server/server.mjs +12 -2
  33. package/src/avatar/three.js +3 -2
  34. package/src/character/AvatarEditor.js +95 -7
  35. package/src/character/Character.js +116 -17
  36. package/src/character/TradingCard.js +226 -0
  37. package/src/character/mods/index.js +4 -0
  38. package/src/character/mods/orc.js +64 -0
  39. package/src/character/mods.js +251 -0
  40. package/src/character/more/acc-head.js +213 -0
  41. package/src/character/more/acc-rest.js +342 -0
  42. package/src/character/more/brows.js +39 -0
  43. package/src/character/more/cheeks.js +78 -0
  44. package/src/character/more/eyes.js +288 -0
  45. package/src/character/more/mouths.js +259 -0
  46. package/src/character/more/patterns.js +120 -0
  47. package/src/character/parts.js +90 -0
  48. package/src/character/skin.js +166 -1
  49. package/src/character/styles.js +1285 -9
  50. package/src/character3d/Character3D.js +148 -35
  51. package/src/character3d/index.js +7 -1
  52. package/src/character3d/model.js +367 -0
  53. package/src/i18n/messages/character.js +34 -22
  54. package/src/i18n/messages/platform.js +15 -0
  55. package/src/identity/random.js +17 -3
  56. package/src/identity/schema.js +10 -0
  57. package/src/index.js +1 -0
@@ -98,12 +98,36 @@ const SCENES = {
98
98
 
99
99
  const GAP = 170; // how far apart they stand (their windows grow for what flies between them)
100
100
 
101
- exports.createDirector = ({ ots, lang = 'en' }) => {
101
+ // minutes between scenes, per the "how often" setting
102
+ const EVERY = { rare: [5, 10], normal: [1.5, 3.5], often: [0.5, 1] };
103
+
104
+ exports.createDirector = ({ ots, lang = 'en', url, token }) => {
102
105
  const L = LINES[lang] || LINES.en;
103
106
  let running = false;
104
107
  const SOON = process.env.SEVENOTS_PET_SCENES_SOON === '1'; // testing: one scene after another
105
108
  let nextAt = Date.now() + (SOON ? 4000 : 25000);
106
109
  let last = '';
110
+ // settings (daemon GET /scenes): on/off, how often, which scenes; plus a "play one now" from settings
111
+ let conf = { on: true, every: 'normal', list: Object.keys(SCENES) };
112
+ let want = null; // scene asked for ('' = any), played as soon as two ots are free
113
+ const gap = () => {
114
+ const [lo, hi] = EVERY[conf.every] || EVERY.normal;
115
+ return (lo + Math.random() * (hi - lo)) * 60000;
116
+ };
117
+ // the host's daemon holds the settings; a "play now" may come from any ot's settings
118
+ const get = (u, tk) => fetch(new URL('/scenes', u), { headers: { 'X-7ots-Token': tk } }).then((x) => (x.ok ? x.json() : null), () => null);
119
+ if (url && token)
120
+ setInterval(async () => {
121
+ const others = [...ots.values()].map((o) => o.daemon).filter((d) => d?.token && d.url !== url);
122
+ const [r, ...rest] = await Promise.all([get(url, token), ...others.map((d) => get(d.url, d.token))]);
123
+ if (r) {
124
+ const changed = r.every !== conf.every;
125
+ conf = { on: r.on !== false, every: r.every, list: (r.list || []).filter((n) => SCENES[n]) };
126
+ if (changed) nextAt = Math.min(nextAt, Date.now() + gap()); // more often: don't wait out the old, longer gap
127
+ }
128
+ const asked = [r, ...rest].find((x) => x?.play)?.play;
129
+ if (asked) want = asked.scene || '';
130
+ }, 2000);
107
131
  const wait = (ms) => new Promise((r) => setTimeout(r, ms));
108
132
  // waits, but gives up as soon as the mouse is on either of them (to click or drag it): true = interrupted
109
133
  const pause = async (ms, ...pair) => {
@@ -111,7 +135,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
111
135
  return pair.some((o) => o.interrupted());
112
136
  };
113
137
 
114
- async function play(x, y) {
138
+ async function play(x, y, asked = null) {
115
139
  running = true;
116
140
  // they take turns: a line from one ends the other's (one speech bubble at a time)
117
141
  const turns = (o, other) => ({ ...o, say: (m) => (m?.line && other.say({ hush: true }), o.say(m)) });
@@ -132,8 +156,10 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
132
156
  a.face(1);
133
157
  b.face(-1);
134
158
  if (await pause(500, a, b)) return;
135
- const names = Object.keys(SCENES).filter((n) => n !== last);
136
- const name = process.env.SEVENOTS_PET_SCENE || names[Math.floor(Math.random() * names.length)];
159
+ const pool = conf.list.length ? conf.list : Object.keys(SCENES);
160
+ const names = pool.length > 1 ? pool.filter((n) => n !== last) : pool;
161
+ if (asked !== null) want = null; // the request is served only once the scene really starts
162
+ const name = process.env.SEVENOTS_PET_SCENE || asked || names[Math.floor(Math.random() * names.length)];
137
163
  last = name;
138
164
  console.log(`[7ots director] ${name}`);
139
165
  // half the time the right one starts (scenes are written for the left one)
@@ -146,7 +172,7 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
146
172
  } finally {
147
173
  for (const o of [x, y]) (o.hold(null), o.inScene() && o.release());
148
174
  running = false;
149
- nextAt = Date.now() + (SOON ? 3000 : 90000 + Math.random() * 120000);
175
+ nextAt = Date.now() + (SOON ? 3000 : gap());
150
176
  }
151
177
  }
152
178
 
@@ -158,8 +184,14 @@ exports.createDirector = ({ ots, lang = 'en' }) => {
158
184
 
159
185
  setInterval(() => {
160
186
  if (running) return;
187
+ if (want !== null) {
188
+ const s = [...ots.values()].filter((o) => o.ready()).sort(() => Math.random() - 0.5);
189
+ if (s.length >= 2) play(s[0], s[1], want).catch((e) => console.log('[7ots director]', e.message));
190
+ return;
191
+ }
161
192
  const free = [...ots.values()].filter((o) => o.free());
162
193
  if (free.length < 2) return;
194
+ if (!conf.on || !conf.list.length) return;
163
195
  // two that wandered close start right away; otherwise now and then
164
196
  let pair = null;
165
197
  for (let i = 0; i < free.length && !pair; i++)
@@ -18,7 +18,9 @@ const URL_ = process.env.SEVENOTS_PET_URL;
18
18
  const TOKEN = process.env.SEVENOTS_PET_TOKEN;
19
19
  const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
20
20
  const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
21
- const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
21
+ // menus in the human's language: SEVENOTS_PET_LANG, else the system's (es_CL.UTF-8 → es)
22
+ const LANG = [process.env.SEVENOTS_PET_LANG, process.env.SEVENOTS_LANG, process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG]
23
+ .map((l) => String(l || '').slice(0, 2).toLowerCase()).find((l) => ['en', 'es', 'pt'].includes(l)) || 'en';
22
24
  const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
23
25
  const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
24
26
  const SMALL = { w: 160, h: 164 }; // just the ot
@@ -47,6 +49,69 @@ const post = (p, body) =>
47
49
 
48
50
  app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the compositor needs a moment
49
51
 
52
+ // The shell lives as long as the daemon that started it: when that one stops (7ots stop) or another takes
53
+ // its place (a relaunch), this shell leaves too, so there are never two shells (and two directors) at once.
54
+ {
55
+ let pid = 0;
56
+ let misses = 0;
57
+ setInterval(async () => {
58
+ const h = await fetch(new URL('/health', URL_), { signal: AbortSignal.timeout(3000) }).then((r) => r.json(), () => null);
59
+ if (!h?.pid) return ++misses > 4 && app.quit(); // gone for ~25 s
60
+ misses = 0;
61
+ pid ||= h.pid;
62
+ if (h.pid !== pid) app.quit();
63
+ }, 5000);
64
+ }
65
+
66
+ // The cursor. On X11 Chromium answers getCursorScreenPoint() from the last mouse event its own windows got,
67
+ // and a click-through window gets none: the point froze wherever the mouse last left the ot, so hovering it
68
+ // often did nothing (no menu, no grab). There a tiny python3 + libX11 helper asks the X server ~60 times a
69
+ // second instead; without python3/libX11 (or off X11) it stays Electron's answer.
70
+ let xCursor = null; // { x, y, at } in physical pixels
71
+ function watchX11Cursor() {
72
+ if (!linux || !process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return;
73
+ const py = [
74
+ 'import ctypes, ctypes.util, sys, time',
75
+ "x = ctypes.cdll.LoadLibrary(ctypes.util.find_library('X11') or 'libX11.so.6')",
76
+ 'x.XOpenDisplay.restype = ctypes.c_void_p',
77
+ 'x.XDefaultRootWindow.argtypes = [ctypes.c_void_p]; x.XDefaultRootWindow.restype = ctypes.c_ulong',
78
+ 'x.XQueryPointer.argtypes = [ctypes.c_void_p, ctypes.c_ulong] + [ctypes.c_void_p] * 7',
79
+ 'd = x.XOpenDisplay(None)',
80
+ 'if not d: sys.exit(1)',
81
+ 'r = x.XDefaultRootWindow(d)',
82
+ 'w = ctypes.c_ulong(); i = [ctypes.c_int() for _ in range(4)]; m = ctypes.c_uint(); last = None; n = 0',
83
+ 'while True:',
84
+ ' x.XQueryPointer(d, r, ctypes.byref(w), ctypes.byref(w), *[ctypes.byref(v) for v in i], ctypes.byref(m))',
85
+ ' p = (i[0].value, i[1].value); n += 1',
86
+ ' if p != last or n % 30 == 0:',
87
+ " last = p; sys.stdout.write('%d %d\\n' % p); sys.stdout.flush()",
88
+ ' time.sleep(0.016)',
89
+ ].join('\n');
90
+ let child;
91
+ try {
92
+ child = require('node:child_process').spawn('python3', ['-c', py], { stdio: ['ignore', 'pipe', 'ignore'] });
93
+ } catch {
94
+ return;
95
+ }
96
+ child.on('error', () => {});
97
+ let buf = '';
98
+ child.stdout.on('data', (d) => {
99
+ buf += d;
100
+ const lines = buf.split('\n');
101
+ buf = lines.pop();
102
+ const m = /^(-?\d+) (-?\d+)$/.exec(lines.pop() || '');
103
+ if (m) xCursor = { x: +m[1], y: +m[2], at: Date.now() };
104
+ });
105
+ app.on('will-quit', () => child.kill());
106
+ }
107
+ const cursorPoint = () => {
108
+ if (xCursor && Date.now() - xCursor.at < 2000) {
109
+ const k = screen.getPrimaryDisplay().scaleFactor || 1; // X11 pixels → Electron's DIP
110
+ return { x: Math.round(xCursor.x / k), y: Math.round(xCursor.y / k) };
111
+ }
112
+ return screen.getCursorScreenPoint();
113
+ };
114
+
50
115
  // Every ot on the desktop: the one that lives here ('host') and visitors from the same account.
51
116
  const DEBUG = !!process.env.SEVENOTS_PET_DEBUG;
52
117
  const ots = new Map();
@@ -54,6 +119,7 @@ let tray = null;
54
119
  let relaunching = false; // a 2D → 3D switch that needs the GPU (see setView)
55
120
 
56
121
  function start() {
122
+ watchX11Cursor();
57
123
  // Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
58
124
  try {
59
125
  const img = nativeImage.createFromPath(ICON);
@@ -73,7 +139,7 @@ function start() {
73
139
  };
74
140
  setTimeout(syncVisitors, 2500);
75
141
  setInterval(syncVisitors, 4000);
76
- require('./director.cjs').createDirector({ ots, lang: LANG });
142
+ require('./director.cjs').createDirector({ ots, lang: LANG, url: URL_, token: TOKEN });
77
143
  }
78
144
 
79
145
  // Settings in a window of our own (not a browser tab); links elsewhere open in the browser.
@@ -240,19 +306,27 @@ function makeOt(opt = {}) {
240
306
  // button is down on the ot. Past 4 px it is a drag: the drop spot becomes home, as with a system drag.
241
307
  let grabbing = null;
242
308
  let grabTimer = null;
243
- function grab(on) {
309
+ function grab(on, resizing = false) {
244
310
  clearInterval(grabTimer);
311
+ // stretching from a corner (pet.html): the window keeps the mouse and stays put; the page grows the ot
312
+ if (on && resizing) {
313
+ grabbing = { resize: true, moved: false };
314
+ grabTimer = setInterval(() => (grabbing ? (stillUntil = Date.now() + 1200) : clearInterval(grabTimer)), 200); // no wandering off mid-stretch
315
+ return;
316
+ }
245
317
  if (!on) {
246
318
  if (grabbing?.moved) (setHome(pos), (droppedSaid = false), (plan = { mode: 'idle', until: Date.now() + 6000 }));
247
319
  grabbing = null;
248
320
  return;
249
321
  }
322
+ // while grabbed the window has the mouse, so Electron's own cursor is live (and finer than the X11 poll)
250
323
  const c = screen.getCursorScreenPoint();
251
324
  grabbing = { dx: pos.x - c.x, dy: pos.y - c.y, x: c.x, y: c.y, moved: false };
252
325
  grabTimer = setInterval(() => {
253
326
  if (!alive() || !grabbing) return clearInterval(grabTimer);
254
327
  const p = screen.getCursorScreenPoint();
255
328
  if (!grabbing.moved && Math.hypot(p.x - grabbing.x, p.y - grabbing.y) < 4) return;
329
+ if (!grabbing.moved) page('postMessage({ carried: true })'); // the page stretches it toward the pull
256
330
  grabbing.moved = true;
257
331
  dragged = Date.now();
258
332
  pos.x = Math.max(area.x + size.w / 2 - 40, Math.min(right - size.w / 2 + 40, p.x + grabbing.dx));
@@ -269,7 +343,7 @@ function makeOt(opt = {}) {
269
343
  let asleep = false; // asleep it stays put (an urgent stir wakes it for a moment, see pet.html)
270
344
  const HIT = `(() => {
271
345
  const out = [];
272
- for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
346
+ for (const e of document.querySelectorAll('#stage, .actions, .ask, .bubble.asking')) {
273
347
  const cs = getComputedStyle(e);
274
348
  // the menu counts while faded out too: reaching for it must not let the mouse through (and hide it)
275
349
  if (cs.display === 'none' || cs.visibility === 'hidden' || (+cs.opacity < 0.3 && !e.classList.contains('actions'))) continue;
@@ -308,7 +382,7 @@ function makeOt(opt = {}) {
308
382
  let dbgT = 0;
309
383
  const mouseTimer = setInterval(() => {
310
384
  if (!alive() || !win.isVisible()) return;
311
- const c = screen.getCursorScreenPoint();
385
+ const c = cursorPoint();
312
386
  const [wx, wy] = win.getPosition();
313
387
  const x = c.x - wx;
314
388
  const y = c.y - wy;
@@ -353,7 +427,8 @@ function makeOt(opt = {}) {
353
427
  const differs = want.w !== size.w || want.h !== size.h;
354
428
  if (!differs) (shrinkAt = 0), (shrinkTo = null);
355
429
  else if (!grow) shrinkTo = shrinkTo ? { w: Math.max(shrinkTo.w, want.w), h: Math.max(shrinkTo.h, want.h) } : want; // the most it wanted while waiting
356
- if (differs && Date.now() - dragged > 400 && (grow || (!held && (shrinkAt ||= Date.now() + 6000) < Date.now()))) {
430
+ // growing never waits (a stretch while dragged would be cut off); shrinking waits for the drop
431
+ if (differs && (grow || (Date.now() - dragged > 400 && !held && (shrinkAt ||= Date.now() + 6000) < Date.now()))) {
357
432
  size = grow ? { w: Math.max(want.w, size.w), h: Math.max(want.h, size.h) } : shrinkTo || want;
358
433
  shrinkAt = 0;
359
434
  shrinkTo = null;
@@ -377,8 +452,12 @@ function makeOt(opt = {}) {
377
452
  // An effect just started: the page says how much room it needs right away (see tellNeed in pet.html).
378
453
  win.webContents.on('console-message', (e, level, msg) => {
379
454
  const m = String(e?.message ?? msg ?? '');
380
- if (m.startsWith('\u00a77drag')) return grab(JSON.parse(m.slice(6)).on);
455
+ if (m.startsWith('\u00a77drag')) {
456
+ const d = JSON.parse(m.slice(6));
457
+ return grab(d.on, d.resize);
458
+ }
381
459
  if (m.startsWith('\u00a77bye')) return HOST || dismiss();
460
+ if (m.startsWith('\u00a77ctx')) return (FULL ? menu() : guestMenu()).popup({ window: win });
382
461
  if (m.startsWith('\u00a77view')) return setView(m.slice(6) === '3d');
383
462
  if (!m.startsWith('\u00a77need')) return;
384
463
  try {
@@ -456,7 +535,7 @@ function makeOt(opt = {}) {
456
535
  }
457
536
  }
458
537
  else if (plan.mode === 'follow') {
459
- const c = screen.getCursorScreenPoint();
538
+ const c = cursorPoint();
460
539
  // stays a polite distance from the cursor, on the floor band
461
540
  tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
462
541
  ty = clampY(c.y + SMALL.h / 2);
@@ -531,9 +610,9 @@ function makeOt(opt = {}) {
531
610
  return null;
532
611
  }
533
612
  async function goTo(goal) {
534
- if (still) return;
613
+ if (still || plan.mode === 'scene' || plan.mode === 'fling') return; // a scene with another ot goes first
535
614
  if (goal === 'browser') await new Promise((r) => setTimeout(r, 900)); // let the site open first
536
- const c = screen.getCursorScreenPoint();
615
+ const c = cursorPoint();
537
616
  const rect = (goal === 'agent' ? await findWindow(TERM) : goal === 'browser' ? await findWindow(BROWSER) : null) || { x: c.x - 1, y: c.y - 1, w: 2, h: 2, cursor: true };
538
617
  const cx = rect.x + rect.w / 2;
539
618
  // stand beside the window if there is room, otherwise inside it, near its lower corner closest to us
@@ -582,6 +661,9 @@ function makeOt(opt = {}) {
582
661
  bounds: () => ({ minX, maxX, floor }),
583
662
  close,
584
663
  // free to join a scene: shown, loaded, not held still, dragged, hovered, talking or on an errand
664
+ daemon: { url: base, token: opt.token || TOKEN }, // its own daemon (settings there can ask for a scene)
665
+ // for a scene asked from settings: errands (pointing at the terminal while an agent works) may be cut short
666
+ ready: () => alive() && win.isVisible() && !win.webContents.isLoading() && !asleep && Date.now() - dragged > 3000 && Date.now() > hoverUntil + 1500 && plan.mode !== 'scene' && plan.mode !== 'fling',
585
667
  free: () => alive() && win.isVisible() && !win.webContents.isLoading() && !still && !asleep && Date.now() - dragged > 8000 && Date.now() > hoverUntil + 1500 && !busy && /idle|go|hop/.test(plan.mode),
586
668
  walkTo: (x, y = pos.y, speed = 120) => (plan = { mode: 'scene', tx: Math.max(minX, Math.min(maxX, x)), ty: Math.max(minY, Math.min(floor, y)), speed, dir: plan.dir || 0 }),
587
669
  arrivedAt: () => plan.mode === 'scene' && Math.abs(plan.tx - pos.x) < 6 && Math.abs(plan.ty - pos.y) < 6 && Math.hypot(vel.x, vel.y) < 10,
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
  ])?.();