@7ots/cli 0.1.3 → 0.1.5

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.
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * Desktop mode: the pet page in a transparent, frameless, always-on-top window that strolls
3
- * along the bottom of the screen. Launched by `7ots pet --mode desktop` with
3
+ * along the bottom of the screen. It resizes to what is in use and lets clicks through its empty parts. Launched by `7ots pet --mode desktop` with
4
4
  * ~/.7ots/electron/…/electron cli/pet/electron/main.cjs (installed once by the CLI)
5
5
  * and SEVENOTS_PET_URL / SEVENOTS_PET_TOKEN in the environment.
6
- * Menu: right click on the pet, or the tray icon (Show / Hide / care / Quit).
6
+ * Menu: right click on the pet, or the tray icon (Show / Hide / Talk / care / Settings / Quit).
7
7
  * SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
8
8
  */
9
9
 
10
10
  const path = require('node:path');
11
- const { app, BrowserWindow, Menu, Tray, nativeImage, screen } = require('electron');
11
+ const { app, BrowserWindow, Menu, Tray, nativeImage, screen, shell } = require('electron');
12
12
 
13
13
  const URL_ = process.env.SEVENOTS_PET_URL;
14
14
  const TOKEN = process.env.SEVENOTS_PET_TOKEN;
@@ -16,16 +16,16 @@ const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
16
16
  const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
17
17
  const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
18
18
  const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
19
- const W = 240;
20
- const H = 270;
19
+ const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
20
+ const SMALL = { w: 160, h: 164 }; // just the ot
21
21
  const linux = process.platform === 'linux';
22
22
 
23
23
  const L = {
24
- en: { talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
25
- es: { talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
26
- pt: { talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
24
+ en: { settings: '⚙️ Settings', talk: '💬 Talk', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Feed', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
25
+ es: { settings: '⚙️ Configuración', talk: '💬 Hablar', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Dar comida', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
26
+ pt: { settings: '⚙️ Configurações', talk: '💬 Falar', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Dar comida', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
27
27
  }[LANG] || {};
28
- const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
28
+ const tr = (k) => L[k] || { show: 'Show pet', hide: 'Hide', talk: 'Talk', settings: 'Settings', feed: 'Feed', play: 'Play', sleep: 'Sleep', wake: 'Wake up', still: 'Stay still', quit: 'Quit pet' }[k];
29
29
 
30
30
  app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
31
31
  if (linux && !OPAQUE) {
@@ -41,8 +41,8 @@ app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the co
41
41
 
42
42
  function start() {
43
43
  const area = screen.getPrimaryDisplay().workArea;
44
- const home = () => [area.x + area.width - W - 24, area.y + area.height - H];
45
- let [x, y] = home();
44
+ const right = area.x + area.width;
45
+ const floor = area.y + area.height;
46
46
 
47
47
  // Tray first: if there is one, the window can stay out of the taskbar; if not, it shows there so it can be found.
48
48
  let tray = null;
@@ -54,14 +54,18 @@ function start() {
54
54
  }
55
55
  } catch {}
56
56
 
57
+ // The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
58
+ // SMALL is just the body; BIG (room for the bubble, the buttons and the talk box) only while needed.
59
+ const homeAnchor = () => ({ x: right - BIG.w / 2 - 24, y: floor });
60
+ const pos = homeAnchor();
61
+ let size = BIG; // it says hi on start
62
+ const bounds = (s = size, a = pos, lift = 0) => ({ x: Math.round(a.x - s.w / 2), y: Math.round(a.y - s.h - lift), width: s.w, height: s.h });
63
+
57
64
  const win = new BrowserWindow({
58
- width: W,
59
- height: H,
60
- x,
61
- y,
65
+ ...bounds(),
62
66
  transparent: !OPAQUE,
63
67
  frame: false,
64
- resizable: false,
68
+ resizable: true, // resized by code only; frameless, so no edges to grab
65
69
  hasShadow: OPAQUE,
66
70
  alwaysOnTop: true,
67
71
  skipTaskbar: !!tray,
@@ -73,13 +77,28 @@ function start() {
73
77
  win.setAlwaysOnTop(true, 'floating');
74
78
  win.setVisibleOnAllWorkspaces?.(true);
75
79
  win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
76
- win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
80
+ const settingsUrl = `${URL_}/settings?token=${encodeURIComponent(TOKEN)}`;
81
+ const openSettings = () => shell.openExternal(settingsUrl).catch(() => {});
82
+ // The page's ⚙️ button opens the settings in the real browser; anything else is denied.
83
+ win.webContents.setWindowOpenHandler(({ url }) => {
84
+ if (url.startsWith(`${URL_}/settings`)) openSettings();
85
+ return { action: 'deny' };
86
+ });
77
87
  win.webContents.on('will-navigate', (e) => e.preventDefault());
88
+ const alive = () => !win.isDestroyed();
89
+ const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
78
90
 
79
91
  let still = false;
92
+ let lastBounds = null;
93
+ const place = (lift = 0) => {
94
+ const b = bounds(size, pos, lift);
95
+ if (lastBounds && b.x === lastBounds.x && b.y === lastBounds.y && b.width === lastBounds.width && b.height === lastBounds.height) return;
96
+ lastBounds = b;
97
+ win.setBounds(b);
98
+ };
80
99
  const show = () => {
81
- [x, y] = home();
82
- win.setPosition(x, y);
100
+ Object.assign(pos, homeAnchor());
101
+ place();
83
102
  win.showInactive();
84
103
  win.moveTop();
85
104
  };
@@ -87,12 +106,13 @@ function start() {
87
106
  Menu.buildFromTemplate([
88
107
  win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
89
108
  { type: 'separator' },
90
- { label: tr('talk'), click: () => { show(); win.focus(); win.webContents.executeJavaScript('postMessage({ talk: true })').catch(() => {}); } },
109
+ { label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
91
110
  { label: tr('feed'), click: () => post('/event', { type: 'feed' }) },
92
111
  { label: tr('play'), click: () => post('/event', { type: 'play' }) },
93
112
  { label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
94
113
  { label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
95
114
  { type: 'separator' },
115
+ { label: tr('settings'), click: openSettings },
96
116
  { label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
97
117
  { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
98
118
  ]);
@@ -105,32 +125,176 @@ function start() {
105
125
  tray.on('click', () => (win.isVisible() ? win.hide() : show()));
106
126
  }
107
127
 
108
- // Walk: a few seconds walking, a few standing, turn at the edges.
109
- let dir = -1;
110
- let walking = false;
128
+ // The user dragged it: adopt the new spot as its anchor and let it rest there a while.
111
129
  let dragged = 0;
112
- let lastSet = x;
113
130
  win.on('moved', () => {
114
- if (Math.abs(win.getPosition()[0] - lastSet) > 4) dragged = Date.now(); // the user dragged it
131
+ if (!alive() || !lastBounds) return;
132
+ const [wx, wy] = win.getPosition();
133
+ if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
134
+ dragged = Date.now();
135
+ droppedSaid = false;
136
+ pos.x = wx + size.w / 2;
137
+ pos.y = wy + size.h;
138
+ lastBounds = { ...lastBounds, x: wx, y: wy };
139
+ plan = { mode: 'idle', until: Date.now() + 6000 };
115
140
  });
116
- const tell = () => win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
117
- setInterval(() => {
118
- walking = !still && Math.random() < 0.45;
119
- if (Math.random() < 0.3) dir = -dir;
120
- tell();
121
- }, 4000);
122
- setInterval(() => {
123
- if (!walking || still || !win.isVisible() || Date.now() - dragged < 8000 || win.isDestroyed()) return;
124
- const [cx, cy] = win.getPosition();
125
- x = cx + dir * 2;
126
- if (x < area.x || x > area.x + area.width - W) {
127
- dir = -dir;
128
- tell();
141
+
142
+ // ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
143
+ let rects = [];
144
+ let busy = false; // bubble or talk box on screen
145
+ const HIT = `(() => {
146
+ const out = [];
147
+ for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
148
+ const cs = getComputedStyle(e);
149
+ if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
150
+ const r = e.getBoundingClientRect();
151
+ if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
152
+ }
153
+ return { rects: out, busy: document.body.classList.contains('talking') || !!document.querySelector('.bubble.on') };
154
+ })()`;
155
+ const hitTimer = setInterval(() => {
156
+ if (!alive()) return;
157
+ win.webContents.executeJavaScript(HIT).then((r) => ({ rects, busy } = r)).catch(() => {});
158
+ }, 250);
159
+
160
+ // Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
161
+ // clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
162
+ let passing = null;
163
+ let hoverUntil = 0;
164
+ const pass = (p) => {
165
+ if (p === passing || !alive()) return;
166
+ passing = p;
167
+ win.setIgnoreMouseEvents(p, { forward: true });
168
+ };
169
+ let cursorNear = false;
170
+ let lastLook = null;
171
+ let droppedSaid = true;
172
+ const mouseTimer = setInterval(() => {
173
+ if (!alive() || !win.isVisible()) return;
174
+ const c = screen.getCursorScreenPoint();
175
+ const [wx, wy] = win.getPosition();
176
+ const x = c.x - wx;
177
+ const y = c.y - wy;
178
+ const over = rects.some(([l, t, r, b]) => x >= l - 4 && x <= r + 4 && y >= t - 4 && y <= b + 4);
179
+ if (over) hoverUntil = Date.now() + 900;
180
+ // the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
181
+ if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
182
+ lastLook = [x, y];
183
+ page(`postMessage({ look: [${x}, ${y}] })`);
184
+ }
185
+ // dropped after a drag: a little dizzy
186
+ if (dragged && !droppedSaid && Date.now() - dragged > 350) {
187
+ droppedSaid = true;
188
+ page('postMessage({ dropped: true })');
189
+ }
190
+ cursorNear = Math.hypot(c.x - pos.x, c.y - (pos.y - SMALL.h / 2)) < 260;
191
+ pass(!(over || busy) && rects.length > 0);
192
+ // Size: big while it talks, you type, or the cursor is on it (its buttons show on hover).
193
+ const want = busy || Date.now() < hoverUntil ? BIG : SMALL;
194
+ if (want !== size && Date.now() - dragged > 400) {
195
+ size = want;
196
+ place();
197
+ }
198
+ }, 60);
199
+
200
+ // ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
201
+ const rnd = (a, b) => a + Math.random() * (b - a);
202
+ const BAND = 200; // how far above the taskbar it roams
203
+ const minX = area.x + BIG.w / 2;
204
+ const maxX = right - BIG.w / 2;
205
+ const clampY = (y) => Math.max(floor - BAND, Math.min(floor, y));
206
+ const vel = { x: 0, y: 0 };
207
+ let plan = { mode: 'idle', until: Date.now() + 2500 };
208
+ let gait = 0;
209
+ let facing = 0; // what the page last heard: -1..1 (3D yaw follows it smoothly)
210
+ let lastT = Date.now();
211
+
212
+ function next() {
213
+ const now = Date.now();
214
+ const r = Math.random();
215
+ if (r < 0.38) return { mode: 'idle', until: now + rnd(1800, 7000) };
216
+ if (r < 0.48) return { mode: 'hop', start: now, hops: 1 + Math.floor(Math.random() * 3), then: { mode: 'idle', until: now + 2500 } };
217
+ if (r < 0.6 && cursorNear) return { mode: 'follow', until: now + rnd(3000, 6000), speed: rnd(70, 110) };
218
+ // a stroll: sometimes short and lazy, sometimes across the screen
219
+ const far = Math.random() < 0.3;
220
+ const dx = (far ? rnd(300, 900) : rnd(60, 260)) * (Math.random() < 0.5 ? -1 : 1);
221
+ const tx = Math.max(minX, Math.min(maxX, pos.x + dx));
222
+ return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
223
+ }
224
+
225
+ const stepTimer = setInterval(() => {
226
+ // isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
227
+ if (!alive() || !win.isVisible()) return;
228
+ const now = Date.now();
229
+ const dt = Math.min(0.05, (now - lastT) / 1000);
230
+ lastT = now;
231
+ if (still || busy || now - dragged < 6000 || now < hoverUntil) {
232
+ // stop gently
233
+ vel.x *= 0.8;
234
+ vel.y *= 0.8;
235
+ if (Math.abs(vel.x) < 1 && facing !== 0) tellFacing(0);
129
236
  return;
130
237
  }
131
- lastSet = x;
132
- win.setPosition(x, cy);
133
- }, 40);
238
+
239
+ let lift = 0;
240
+ let tx = pos.x;
241
+ let ty = pos.y;
242
+ let speed = 0;
243
+ if (plan.mode === 'idle' && now > plan.until) {
244
+ plan = next();
245
+ if (plan.mode === 'follow') page('postMessage({ follow: true })');
246
+ }
247
+ if (plan.mode === 'go') ({ tx, ty, speed } = plan);
248
+ else if (plan.mode === 'follow') {
249
+ const c = screen.getCursorScreenPoint();
250
+ // stays a polite distance from the cursor, on the floor band
251
+ tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
252
+ ty = clampY(c.y + SMALL.h / 2);
253
+ speed = plan.speed;
254
+ if (now > plan.until) plan = { mode: 'idle', until: now + rnd(1500, 4000) };
255
+ } else if (plan.mode === 'hop') {
256
+ const t = (now - plan.start) / 480;
257
+ if (t >= plan.hops) plan = plan.then;
258
+ else if (Math.floor(t) !== plan.said) (plan.said = Math.floor(t), page('postMessage({ hop: true })'));
259
+ if (plan.mode === 'hop') lift = Math.sin(Math.PI * (t % 1)) * 34 * (1 - Math.floor(t) * 0.25);
260
+ }
261
+
262
+ // arrive: full speed far away, easing to a stop in the last 140 px; velocity eases too (no jerks)
263
+ const dx = tx - pos.x;
264
+ const dy = ty - pos.y;
265
+ const dist = Math.hypot(dx, dy);
266
+ const want = speed * Math.min(1, dist / 140);
267
+ const vx = dist > 0.5 ? (dx / dist) * want : 0;
268
+ const vy = dist > 0.5 ? (dy / dist) * want : 0;
269
+ const k = Math.min(1, dt * 2.6);
270
+ vel.x += (vx - vel.x) * k;
271
+ vel.y += (vy - vel.y) * k;
272
+ pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
273
+ pos.y = clampY(pos.y + vel.y * dt);
274
+ if (plan.mode === 'go' && dist < 3 && Math.hypot(vel.x, vel.y) < 6) plan = { mode: 'idle', until: now + rnd(1500, 6000) };
275
+
276
+ // gait: a small bounce per step, scaled by speed
277
+ const v = Math.hypot(vel.x, vel.y);
278
+ gait += dt * (4 + v / 18);
279
+ if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
280
+
281
+ // facing: turn toward where it walks, back to the front when it stops
282
+ const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : 0;
283
+ if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
284
+
285
+ place(Math.round(lift));
286
+ }, 16);
287
+
288
+ function tellFacing(f) {
289
+ facing = f;
290
+ page(`postMessage({ walk: ${f} })`);
291
+ }
292
+
293
+ win.on('closed', () => {
294
+ clearInterval(stepTimer);
295
+ clearInterval(hitTimer);
296
+ clearInterval(mouseTimer);
297
+ });
134
298
  }
135
299
 
136
300
  // Hidden is not closed: the pet lives on in the tray until Quit.
package/cli/pet/pet.html CHANGED
@@ -80,6 +80,7 @@
80
80
  /* actions */
81
81
  .actions { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 12px 18px 16px; }
82
82
  .act { display: flex; align-items: center; justify-content: center; gap: 7px; height: 38px; border-radius: 12px; border: 1px solid var(--line); background: var(--panel); font-weight: 650; font-size: 13px; cursor: pointer; transition: background .15s, border-color .15s, transform .1s; }
83
+ a.act { color: inherit; text-decoration: none; -webkit-app-region: no-drag; }
83
84
  .act svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
84
85
  .act:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); background: color-mix(in srgb, var(--accent) 7%, var(--panel)); }
85
86
  .act:active { transform: scale(.97); }
@@ -174,6 +175,7 @@
174
175
  <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>
175
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>
176
177
  <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
+ <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>
177
179
  </nav>
178
180
 
179
181
  <section class="feed" aria-labelledby="feed-h">
@@ -187,17 +189,17 @@
187
189
  <script type="module">
188
190
  const BOOT = __BOOT__;
189
191
  const T = {
190
- en: { 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…',
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…',
191
193
  activity: 'Activity', empty: 'Nothing yet. Run a command or ask your coding agent something.', tally: '{ok} ok · {fail} failed · turns {turns}',
192
194
  brain: 'Brain: {b}', age: 'Born {d}', lines: 'built-in phrases', now: 'now', m: '{n}m', h: '{n}h',
193
195
  moods: { happy: 'Happy', neutral: 'Calm', sad: 'Sad', disgust: 'Bored', sleep: 'Asleep', thinking: 'Thinking' },
194
196
  kind: { tool: 'Tool', shell: 'Shell', prompt: 'You asked', stop: 'Turn finished', notify: 'Needs you', start: 'Session started' } },
195
- es: { 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…',
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…',
196
198
  activity: 'Actividad', empty: 'Nada todavía. Ejecuta un comando o pregúntale algo a tu agente.', tally: '{ok} ok · {fail} fallos · turnos {turns}',
197
199
  brain: 'Cerebro: {b}', age: 'Nació {d}', lines: 'frases propias', now: 'ahora', m: '{n} min', h: '{n} h',
198
200
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Aburrido', sleep: 'Dormido', thinking: 'Pensando' },
199
201
  kind: { tool: 'Herramienta', shell: 'Terminal', prompt: 'Preguntaste', stop: 'Turno terminado', notify: 'Te necesita', start: 'Sesión iniciada' } },
200
- pt: { 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…',
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…',
201
203
  activity: 'Atividade', empty: 'Nada ainda. Rode um comando ou pergunte algo ao seu agente.', tally: '{ok} ok · {fail} falhas · turnos {turns}',
202
204
  brain: 'Cérebro: {b}', age: 'Nasceu {d}', lines: 'frases próprias', now: 'agora', m: '{n} min', h: '{n} h',
203
205
  moods: { happy: 'Feliz', neutral: 'Tranquilo', sad: 'Triste', disgust: 'Entediado', sleep: 'Dormindo', thinking: 'Pensando' },
@@ -244,7 +246,8 @@
244
246
  }
245
247
  ch ||= createCharacter($('#face'), spec, { locale: lang, title: id.name });
246
248
  const is3d = !!ch.character;
247
- addEventListener('pointermove', (e) => ch.lookAtPoint(e.clientX, e.clientY));
249
+ let lookFree = 0; // while > now, the eyes wander on their own (a glance) instead of following the cursor
250
+ addEventListener('pointermove', (e) => Date.now() > lookFree && ch.lookAtPoint(e.clientX, e.clientY));
248
251
  $('#face').addEventListener('click', () => ch.gesture('bounce'));
249
252
 
250
253
  const post = (path, body) => fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': BOOT.token }, body: JSON.stringify(body) });
@@ -384,8 +387,35 @@
384
387
  clearTimeout(hideT);
385
388
  if (ms) hideT = setTimeout(() => bubble.classList.remove('on'), ms);
386
389
  }
387
- function say(text) {
390
+ // What it says shows on its face too: a gesture/effect picked from the words (when the server sent none).
391
+ const REACT = [
392
+ [/(ja){2,}|(ha){2,}|jeje|lol|kkk|😂|🤣/i, 'laugh', null],
393
+ [/gracias|thank|obrigad|te quiero|love you|❤|💜|lindo|cute/i, 'bow', 'hearts'],
394
+ [/error|fail|fall[óo]|rompi|broke|oops|ups|ay no|uh.?oh/i, 'shake', 'sweat'],
395
+ [/listo|done|logrado|genial|great|bravo|yay|bien hecho|nice|perfect|✓|✅|🎉/i, 'celebrate', 'confetti'],
396
+ [/hambre|hungry|fome|comida|food|🍎/i, 'point', 'thought'],
397
+ [/sueño|tired|cansad|dormir|sleep|nap|zz/i, 'yawn', null],
398
+ [/miedo|scary|assust|fear|😱/i, 'shiver', 'sweat'],
399
+ [/enojad|angry|bravo con|grr|😠/i, 'stomp', 'anger'],
400
+ [/hola|hello|hi\b|hey|oi\b|buen(os|as) d[ií]as|good morning/i, 'wave', null],
401
+ [/[?¿]\s*$/, 'think', 'question'],
402
+ [/!\s*$/, 'bounce', 'exclaim'],
403
+ ];
404
+ function react(text) {
405
+ for (const [re, g, fx] of REACT) {
406
+ if (!re.test(text)) continue;
407
+ if (g) ch.gesture(g);
408
+ if (fx) ch.effect?.(fx);
409
+ return;
410
+ }
411
+ ch.gesture(Math.random() < 0.5 ? 'nod' : 'wiggle');
412
+ }
413
+ function say(text, withFace = false) {
388
414
  showBubble(document.createTextNode(text), Math.max(4500, text.length * 90));
415
+ talk(true, text); // the mouth moves with the words even without a voice; a real voice re-syncs it
416
+ clearTimeout(say.t);
417
+ say.t = setTimeout(() => talk(false), 500 + text.length * 60);
418
+ if (withFace) react(text);
389
419
  speak(text);
390
420
  }
391
421
  function setThinking(on) {
@@ -414,7 +444,7 @@
414
444
  if (m.gesture) ch.gesture(m.gesture);
415
445
  if (m.morph) ch.morph?.(m.morph);
416
446
  if (m.effect) ch.effect?.(m.effect);
417
- if (m.say) say(m.say);
447
+ if (m.say) say(m.say, !m.gesture);
418
448
  };
419
449
  }
420
450
  live.textContent = t('off');
@@ -424,6 +454,8 @@
424
454
  const ask = $('#ask');
425
455
  const askIn = $('#askIn');
426
456
  askIn.placeholder = t('askPh');
457
+ $('#setBtn').href = `/settings?token=${encodeURIComponent(BOOT.token)}`;
458
+ $('#setBtn').title = t('settings');
427
459
  const talking = (on) => {
428
460
  document.body.classList.toggle('talking', on);
429
461
  if (on) askIn.focus();
@@ -442,12 +474,64 @@
442
474
  });
443
475
  addEventListener('message', (e) => e.data?.talk && talking(true));
444
476
 
445
- // The desktop shell tells us which way we walk.
477
+ // The desktop shell tells us how we move: facing (walk -1..1), hops, being dropped, following, the cursor.
446
478
  addEventListener('message', (e) => {
447
- if (e.data?.walk == null) return;
448
- if (is3d) ch.character.yaw = e.data.walk ? -e.data.walk * 0.9 : 0; // turns toward where it walks
449
- else $('#stage').classList.toggle('flip', e.data.walk < 0);
479
+ const d = e.data || {};
480
+ if (d.walk != null) {
481
+ if (is3d) ch.character.yaw = -d.walk * 0.9; // turns toward where it walks, smoothly
482
+ else $('#stage').classList.toggle('flip', d.walk < 0);
483
+ }
484
+ if (d.hop) ch.gesture('bounce');
485
+ if (d.dropped) (ch.gesture('dizzy'), ch.effect?.('dizzy'));
486
+ if (d.follow) ch.effect?.(Math.random() < 0.6 ? 'hearts' : 'question');
487
+ if (d.look && Date.now() > lookFree) ch.lookAtPoint(d.look[0], d.look[1]); // the cursor anywhere on screen
450
488
  });
489
+
490
+ // ── personality: small life between events (glances, fidgets, effects, shape changes) ──
491
+ const pick = (a) => a[Math.floor(Math.random() * a.length)];
492
+ const SHAPES = ['round', 'egg', 'pear', 'bean', 'squircle', 'drop', 'ghost', 'cat', 'cloud', 'blob', 'heart', 'mushroom', 'tall', 'wide'].filter((x) => x !== spec.shape);
493
+ const weighted = (list) => {
494
+ let r = Math.random() * list.reduce((a, x) => a + x[0], 0);
495
+ for (const x of list) if ((r -= x[0]) <= 0) return x[1];
496
+ };
497
+ function glance() {
498
+ lookFree = Date.now() + 1400 + Math.random() * 1200;
499
+ ch.lookAt((Math.random() * 2 - 1) * 0.8, (Math.random() * 2 - 1) * 0.5);
500
+ if (Math.random() < 0.4) ch.blink?.();
501
+ }
502
+ function fidget() {
503
+ const s = last || {};
504
+ const tired = s.energy < 35;
505
+ const bored = s.fun < 35;
506
+ const hungry = s.food < 35;
507
+ const happy = s.mood === 'happy';
508
+ weighted([
509
+ [5, glance],
510
+ [2, () => ch.gesture('wiggle')],
511
+ [1.5, () => (ch.gesture('think'), ch.effect?.('question'))],
512
+ [1, () => ch.gesture('shrug')],
513
+ [0.6, () => ch.gesture('hiccup')],
514
+ [0.5, () => (ch.gesture('sneeze'), ch.effect?.('sneeze'))],
515
+ [happy ? 2.5 : 0.6, () => (ch.gesture('dance'), ch.effect?.('notes'))],
516
+ [happy ? 0.8 : 0.2, () => (ch.gesture('celebrate'), ch.effect?.('sparkles'))],
517
+ [0.6, () => ch.gesture('spin')],
518
+ [0.5, () => ch.gesture('clap')],
519
+ [tired ? 3 : 0.4, () => ch.gesture('yawn')],
520
+ [bored ? 1.5 : 0.2, () => (ch.gesture('stomp'), ch.effect?.('steam'))],
521
+ [hungry ? 1.2 : 0, () => (ch.gesture('point'), ch.effect?.('thought'))],
522
+ [0.6, () => ch.effect?.('blush')],
523
+ [0.3, () => ch.gesture('shiver')],
524
+ [1.2, () => ch.morph?.(pick(['puff', 'squish', 'stretch', 'jelly']), { ms: 1600 + Math.random() * 1600 })],
525
+ [0.7, () => ch.morph?.(pick(['wings', 'horns', 'spikes', 'hands']), { ms: 2500 + Math.random() * 2500 })],
526
+ [0.8, () => ch.morph?.(`shape:${pick(SHAPES)}`, { ms: 2500 + Math.random() * 3000 })],
527
+ ])?.();
528
+ }
529
+ (function life() {
530
+ setTimeout(() => {
531
+ if (!asleep && !thinking && !bubble.classList.contains('on') && !document.hidden) fidget();
532
+ life();
533
+ }, 4000 + Math.random() * 8000);
534
+ })();
451
535
  </script>
452
536
  </body>
453
537
  </html>