@7ots/cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -20
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +122 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +28 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +365 -22
  14. package/cli/lib/screen.mjs +125 -0
  15. package/cli/lib/stt-local.py +14 -0
  16. package/cli/lib/stt.mjs +68 -0
  17. package/cli/lib/tts-local.mjs +110 -0
  18. package/cli/lib/tts-local.py +18 -0
  19. package/cli/lib/vm.mjs +309 -0
  20. package/cli/lib/watchers.mjs +151 -0
  21. package/cli/pet/card.html +294 -0
  22. package/cli/pet/electron/director.cjs +175 -0
  23. package/cli/pet/electron/main.cjs +515 -59
  24. package/cli/pet/pet.html +414 -21
  25. package/cli/pet/settings.html +991 -57
  26. package/dist/7ots.esm.js +3 -3
  27. package/dist/7ots.esm.js.map +3 -3
  28. package/dist/7ots.iife.js +3 -3
  29. package/dist/7ots.iife.js.map +3 -3
  30. package/llms.txt +31 -17
  31. package/package.json +1 -1
  32. package/server/llm.mjs +2 -0
  33. package/server/platform/connect.mjs +3 -1
  34. package/server/platform/db.mjs +77 -0
  35. package/server/platform/device.mjs +125 -0
  36. package/server/platform/orquesta.mjs +5 -0
  37. package/server/platform/routes.mjs +100 -1
  38. package/server/platform/sites.mjs +146 -0
  39. package/server/platform/store.mjs +3 -1
  40. package/server/platform/vm.mjs +386 -0
  41. package/src/character3d/index.js +2 -1
  42. package/src/character3d/stage.js +15 -4
  43. package/src/i18n/messages/character.js +3 -3
  44. package/src/i18n/messages/platform.js +42 -3
@@ -1,14 +1,18 @@
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 / Talk / care / Settings / Quit).
6
+ * Menu: right click on the pet, or the tray icon (Show / Hide / Talk / Listen / care / Settings / Quit).
7
+ * Voice: Ctrl+Alt+Space (anywhere) makes it listen; the page records and the server transcribes (/stt).
8
+ * With a purpose: when something needs you (the agent finished or waits, a reminder opened a site) it walks to
9
+ * that window (found with xdotool on X11; elsewhere it comes to the cursor) and points at it.
7
10
  * SEVENOTS_PET_OPAQUE=1 paints a solid card instead of transparency (Linux setups without a compositor).
8
11
  */
9
12
 
10
13
  const path = require('node:path');
11
- const { app, BrowserWindow, Menu, Tray, nativeImage, screen, shell } = require('electron');
14
+ const { execFile } = require('node:child_process');
15
+ const { app, BrowserWindow, Menu, Tray, globalShortcut, nativeImage, screen, session, shell } = require('electron');
12
16
 
13
17
  const URL_ = process.env.SEVENOTS_PET_URL;
14
18
  const TOKEN = process.env.SEVENOTS_PET_TOKEN;
@@ -16,16 +20,20 @@ const OPAQUE = process.env.SEVENOTS_PET_OPAQUE === '1';
16
20
  const VIEW3D = process.env.SEVENOTS_PET_3D === '1';
17
21
  const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
18
22
  const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
19
- const W = 240;
20
- const H = 270;
23
+ const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
24
+ const SMALL = { w: 160, h: 164 }; // just the ot
25
+ // The smallest the window gets: the ot with room for its hands, a hop and small effects. Every resize of a
26
+ // transparent window flickers (and in 3D rescales the canvas), so it stays at least this big; click-through
27
+ // keeps the empty parts out of the way.
28
+ const MIN = { w: 288, h: 272 };
21
29
  const linux = process.platform === 'linux';
22
30
 
23
31
  const L = {
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' },
32
+ en: { settings: '⚙️ Settings', talk: '💬 Talk', listen: '🎙️ Listen (Ctrl+Alt+Space)', show: 'Show pet', hide: 'Hide (keeps living)', feed: '🍎 Treat', play: '🎾 Play', sleep: '💤 Sleep', wake: '☀️ Wake up', still: 'Stay still', quit: 'Quit pet' },
33
+ es: { settings: '⚙️ Configuración', talk: '💬 Hablar', listen: '🎙️ Escúchame (Ctrl+Alt+Espacio)', show: 'Mostrar mascota', hide: 'Ocultar (sigue viva)', feed: '🍎 Mimar', play: '🎾 Jugar', sleep: '💤 Dormir', wake: '☀️ Despertar', still: 'Quedarse quieta', quit: 'Cerrar mascota' },
34
+ pt: { settings: '⚙️ Configurações', talk: '💬 Falar', listen: '🎙️ Me escute (Ctrl+Alt+Espaço)', show: 'Mostrar bichinho', hide: 'Esconder (segue vivo)', feed: '🍎 Mimar', play: '🎾 Brincar', sleep: '💤 Dormir', wake: '☀️ Acordar', still: 'Ficar parado', quit: 'Fechar bichinho' },
27
35
  }[LANG] || {};
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];
36
+ const tr = (k) => L[k] || { listen: 'Listen', 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
37
 
30
38
  app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // it talks without a click
31
39
  if (linux && !OPAQUE) {
@@ -39,13 +47,14 @@ const post = (p, body) =>
39
47
 
40
48
  app.whenReady().then(() => setTimeout(start, linux ? 400 : 0)); // Linux: the compositor needs a moment
41
49
 
42
- function start() {
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();
50
+ // Every ot on the desktop: the one that lives here ('host') and visitors from the same account.
51
+ const DEBUG = !!process.env.SEVENOTS_PET_DEBUG;
52
+ const ots = new Map();
53
+ let tray = null;
54
+ let relaunching = false; // a 2D → 3D switch that needs the GPU (see setView)
46
55
 
56
+ function start() {
47
57
  // 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
- let tray = null;
49
58
  try {
50
59
  const img = nativeImage.createFromPath(ICON);
51
60
  if (!img.isEmpty()) {
@@ -53,15 +62,79 @@ function start() {
53
62
  tray.setToolTip('7ots pet');
54
63
  }
55
64
  } catch {}
65
+ ots.set('host', makeOt({ host: true }));
66
+ // Visitors come and go from the settings (Account → invite): polled, a window each.
67
+ const syncVisitors = async () => {
68
+ const list = await fetch(new URL('/visitors', URL_), { headers: { 'X-7ots-Token': TOKEN } }).then((r) => (r.ok ? r.json() : null), () => null);
69
+ if (!Array.isArray(list)) return;
70
+ const want = new Set(list.map((v) => v.id));
71
+ for (const [k, o] of ots) if (k !== 'host' && !want.has(k)) (o.close(), ots.delete(k));
72
+ list.forEach((v, i) => ots.has(v.id) || ((!v.url || v.token) && ots.set(v.id, makeOt({ id: v.id, name: v.name, index: i, url: v.url, token: v.token }))));
73
+ };
74
+ setTimeout(syncVisitors, 2500);
75
+ setInterval(syncVisitors, 4000);
76
+ require('./director.cjs').createDirector({ ots, lang: LANG });
77
+ }
78
+
79
+ // Settings in a window of our own (not a browser tab); links elsewhere open in the browser.
80
+ const settingsWins = new Map(); // one per ot that has its own daemon
81
+ function openSettings(base = URL_, tok = TOKEN) {
82
+ let settingsWin = settingsWins.get(base);
83
+ if (settingsWin && !settingsWin.isDestroyed()) return settingsWin.show(), settingsWin.focus();
84
+ settingsWin = new BrowserWindow({ width: 1120, height: 820, minWidth: 420, minHeight: 480, title: '7ots', icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14', show: false });
85
+ settingsWin.removeMenu?.();
86
+ settingsWins.set(base, settingsWin);
87
+ settingsWin.loadURL(`${base}/settings?token=${encodeURIComponent(tok)}`);
88
+ settingsWin.once('ready-to-show', () => settingsWin.show());
89
+ const ours = (u) => String(u || '').startsWith(base);
90
+ settingsWin.webContents.setWindowOpenHandler(({ url }) => {
91
+ if (!ours(url)) return shell.openExternal(url).catch(() => {}), { action: 'deny' };
92
+ return { action: 'allow', overrideBrowserWindow: { width: 760, height: 900, icon: ICON, autoHideMenuBar: true, backgroundColor: '#0f0d14' } }; // e.g. an ot's card
93
+ });
94
+ settingsWin.webContents.on('will-navigate', (e, url) => ours(url) || (e.preventDefault(), shell.openExternal(url).catch(() => {})));
95
+ settingsWin.on('closed', () => settingsWins.delete(base));
96
+ }
97
+
98
+ function makeOt(opt = {}) {
99
+ const HOST = !!opt.host;
100
+ // a second ot with everything (its own daemon: brain, voice, memory, settings) or a visitor drawn by the host's
101
+ const base = opt.url || URL_;
102
+ const tok = opt.token || TOKEN;
103
+ const FULL = HOST || !!opt.url;
104
+ const postOt = (p, body) =>
105
+ fetch(new URL(p, base), { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-7ots-Token': tok }, body: JSON.stringify(body || {}) }).catch(() => {});
106
+ const area = screen.getPrimaryDisplay().workArea;
107
+ const right = area.x + area.width;
108
+ const floor = area.y + area.height;
109
+
110
+ // The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
111
+ // SMALL is just the body; BIG (room for the bubble, the buttons and the talk box) only while needed.
112
+ // Home: where it lives on screen. By default the bottom-right corner; wherever you drop it, after a drag.
113
+ const HOME_FILE = path.join(require('node:os').homedir(), '.7ots', HOST ? 'pet-home.json' : `pet-home-${opt.id}.json`);
114
+ let home = null;
115
+ try {
116
+ const h = JSON.parse(require('node:fs').readFileSync(HOME_FILE, 'utf8'));
117
+ if (h.x >= area.x && h.x <= right && h.y >= area.y + 80 && h.y <= floor) home = { x: h.x, y: h.y };
118
+ } catch {}
119
+ let homeSave = null;
120
+ const setHome = (p) => {
121
+ home = { x: Math.round(p.x), y: Math.round(p.y) };
122
+ clearTimeout(homeSave);
123
+ homeSave = setTimeout(() => require('node:fs').promises.writeFile(HOME_FILE, JSON.stringify(home)).catch(() => {}), 800);
124
+ };
125
+ // a visitor turns up beside the host, a bit to the left
126
+ const hostHome = () => ots.get('host')?.home() || { x: right - BIG.w / 2 - 24, y: floor };
127
+ const homeAnchor = () => (home ? { ...home } : HOST ? { x: right - BIG.w / 2 - 24, y: floor } : { x: Math.max(area.x + 140, hostHome().x - 230 * ((opt.index || 0) + 1)), y: hostHome().y });
128
+ if (!HOST && !home) home = homeAnchor(); // a visitor roams beside the host, at its height (saved once dragged)
129
+ const pos = homeAnchor();
130
+ let size = BIG; // it says hi on start
131
+ 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 });
56
132
 
57
133
  const win = new BrowserWindow({
58
- width: W,
59
- height: H,
60
- x,
61
- y,
134
+ ...bounds(),
62
135
  transparent: !OPAQUE,
63
136
  frame: false,
64
- resizable: false,
137
+ resizable: true, // resized by code only; frameless, so no edges to grab
65
138
  hasShadow: OPAQUE,
66
139
  alwaysOnTop: true,
67
140
  skipTaskbar: !!tray,
@@ -72,39 +145,73 @@ function start() {
72
145
  });
73
146
  win.setAlwaysOnTop(true, 'floating');
74
147
  win.setVisibleOnAllWorkspaces?.(true);
75
- win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
76
- const settingsUrl = `${URL_}/settings?token=${encodeURIComponent(TOKEN)}`;
77
- const openSettings = () => shell.openExternal(settingsUrl).catch(() => {});
78
- // The page's ⚙️ button opens the settings in the real browser; anything else is denied.
148
+ let view3d = VIEW3D; // switched live from the settings (see setView)
149
+ const petURL = () => `${base}/pet?desktop=1${FULL ? '' : `&guest=${encodeURIComponent(opt.id)}`}${OPAQUE ? '&opaque=1' : ''}${view3d ? '&3d=1' : ''}&token=${encodeURIComponent(tok)}`;
150
+ win.loadURL(petURL());
151
+ // The page's ⚙️ button opens the settings window; anything else is denied.
79
152
  win.webContents.setWindowOpenHandler(({ url }) => {
80
- if (url.startsWith(`${URL_}/settings`)) openSettings();
153
+ if (url.startsWith(`${base}/settings`)) openSettings(base, tok);
81
154
  return { action: 'deny' };
82
155
  });
83
- win.webContents.on('will-navigate', (e) => e.preventDefault());
156
+ // Only our own pet page may load (its reload after a settings change or another ot: Electron routes
157
+ // location.reload() through will-navigate too, so blocking everything froze the old look until a restart).
158
+ win.webContents.on('will-navigate', (e, url) => String(url || e?.url || '').startsWith(`${base}/pet?`) || e.preventDefault());
159
+ win.webContents.on('console-message', (e, level, msg) => /\[7ots/.test(msg ?? e?.message) && console.log(msg ?? e?.message)); // to ~/.7ots/electron.log
160
+ // The microphone, only for our own page (voice control).
161
+ const ours = (u) => /^http:\/\/127\.0\.0\.1:\d+\//.test(String(u || '')); // our daemons
162
+ session.defaultSession.setPermissionRequestHandler((wc, perm, cb, d) => cb(perm === 'media' && ours(d?.requestingUrl || wc.getURL())));
163
+ session.defaultSession.setPermissionCheckHandler((wc, perm, origin) => perm === 'media' && ours(origin));
164
+ const alive = () => !win.isDestroyed();
165
+ const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
84
166
 
85
167
  let still = false;
168
+ let lastBounds = null;
169
+ const place = (lift = 0) => {
170
+ const b = bounds(size, pos, lift);
171
+ if (lastBounds && b.x === lastBounds.x && b.y === lastBounds.y && b.width === lastBounds.width && b.height === lastBounds.height) return;
172
+ lastBounds = b;
173
+ win.setBounds(b);
174
+ };
86
175
  const show = () => {
87
- [x, y] = home();
88
- win.setPosition(x, y);
176
+ Object.assign(pos, homeAnchor());
177
+ place();
89
178
  win.showInactive();
90
179
  win.moveTop();
91
180
  };
181
+ const dismiss = () => post('/account/visitor', { id: opt.id, on: false }).finally(() => close());
182
+ const guestMenu = () =>
183
+ Menu.buildFromTemplate([
184
+ { label: `${opt.name || opt.id}`, enabled: false },
185
+ { type: 'separator' },
186
+ { label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
187
+ { label: tr('settings'), click: () => openSettings(base, tok) },
188
+ ]);
92
189
  const menu = () =>
93
190
  Menu.buildFromTemplate([
94
191
  win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
95
192
  { type: 'separator' },
96
- { label: tr('talk'), click: () => { show(); win.focus(); win.webContents.executeJavaScript('postMessage({ talk: true })').catch(() => {}); } },
97
- { label: tr('feed'), click: () => post('/event', { type: 'feed' }) },
98
- { label: tr('play'), click: () => post('/event', { type: 'play' }) },
99
- { label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
100
- { label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
193
+ { label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
194
+ { label: tr('listen'), click: listen },
195
+ { label: tr('feed'), click: () => postOt('/event', { type: 'feed' }) },
196
+ { label: tr('sleep'), click: () => postOt('/event', { type: 'sleep' }) },
197
+ { label: tr('wake'), click: () => postOt('/event', { type: 'wake' }) },
101
198
  { type: 'separator' },
102
- { label: tr('settings'), click: openSettings },
199
+ { label: tr('settings'), click: () => openSettings(base, tok) },
103
200
  { label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
104
- { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) },
201
+ HOST ? { label: tr('quit'), click: () => post('/quit').finally(() => app.quit()) } : { label: { es: '👋 Despedir', pt: '👋 Mandar embora' }[LANG] || '👋 Send home', click: dismiss },
105
202
  ]);
106
- win.webContents.on('context-menu', () => menu().popup({ window: win }));
107
- if (tray) {
203
+ function listen() {
204
+ if (!win.isVisible()) win.showInactive();
205
+ page('postMessage({ listen: true })');
206
+ }
207
+ if (HOST) {
208
+ try {
209
+ globalShortcut.register('CommandOrControl+Alt+Space', listen);
210
+ } catch {}
211
+ app.on('will-quit', () => globalShortcut.unregisterAll());
212
+ }
213
+ win.webContents.on('context-menu', () => (FULL ? menu() : guestMenu()).popup({ window: win }));
214
+ if (HOST && tray) {
108
215
  const refresh = () => tray.setContextMenu(menu()); // Linux trays only show the context menu
109
216
  refresh();
110
217
  win.on('show', refresh);
@@ -112,38 +219,387 @@ function start() {
112
219
  tray.on('click', () => (win.isVisible() ? win.hide() : show()));
113
220
  }
114
221
 
115
- // Walk: a few seconds walking, a few standing, turn at the edges.
116
- let dir = -1;
117
- let walking = false;
222
+ // The user dragged it: that spot becomes its home (it roams around it, and comes back there after errands),
223
+ // remembered across restarts. Linux only emits 'move' (not 'moved'): our own setBounds also emits it, so
224
+ // only a position that differs from the last one we set counts as a drag.
118
225
  let dragged = 0;
119
- let lastSet = x;
120
- win.on('moved', () => {
121
- if (Math.abs(win.getPosition()[0] - lastSet) > 4) dragged = Date.now(); // the user dragged it
226
+ const onMove = () => {
227
+ if (!alive() || !lastBounds) return;
228
+ const [wx, wy] = win.getPosition();
229
+ if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
230
+ dragged = Date.now();
231
+ droppedSaid = false;
232
+ pos.x = wx + size.w / 2;
233
+ pos.y = wy + size.h;
234
+ lastBounds = { ...lastBounds, x: wx, y: wy };
235
+ plan = { mode: 'idle', until: Date.now() + 6000 };
236
+ setHome(pos);
237
+ };
238
+ win.on('move', onMove);
239
+ // Our own drag (the page has no drag region, so clicks reach it): the window follows the cursor while the
240
+ // button is down on the ot. Past 4 px it is a drag: the drop spot becomes home, as with a system drag.
241
+ let grabbing = null;
242
+ let grabTimer = null;
243
+ function grab(on) {
244
+ clearInterval(grabTimer);
245
+ if (!on) {
246
+ if (grabbing?.moved) (setHome(pos), (droppedSaid = false), (plan = { mode: 'idle', until: Date.now() + 6000 }));
247
+ grabbing = null;
248
+ return;
249
+ }
250
+ const c = screen.getCursorScreenPoint();
251
+ grabbing = { dx: pos.x - c.x, dy: pos.y - c.y, x: c.x, y: c.y, moved: false };
252
+ grabTimer = setInterval(() => {
253
+ if (!alive() || !grabbing) return clearInterval(grabTimer);
254
+ const p = screen.getCursorScreenPoint();
255
+ if (!grabbing.moved && Math.hypot(p.x - grabbing.x, p.y - grabbing.y) < 4) return;
256
+ grabbing.moved = true;
257
+ dragged = Date.now();
258
+ pos.x = Math.max(area.x + size.w / 2 - 40, Math.min(right - size.w / 2 + 40, p.x + grabbing.dx));
259
+ pos.y = Math.max(area.y + size.h - 40, Math.min(floor + 40, p.y + grabbing.dy));
260
+ place();
261
+ }, 16);
262
+ }
263
+ win.on('moved', onMove);
264
+
265
+ // ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
266
+ let rects = [];
267
+ let need = null; // what the page really shows: { w, h } (bubble, effects around the body, talk box)
268
+ let busy = false; // bubble or talk box on screen
269
+ let asleep = false; // asleep it stays put (an urgent stir wakes it for a moment, see pet.html)
270
+ const HIT = `(() => {
271
+ const out = [];
272
+ for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
273
+ const cs = getComputedStyle(e);
274
+ // the menu counts while faded out too: reaching for it must not let the mouse through (and hide it)
275
+ if (cs.display === 'none' || cs.visibility === 'hidden' || (+cs.opacity < 0.3 && !e.classList.contains('actions'))) continue;
276
+ const r = e.getBoundingClientRect();
277
+ if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
278
+ }
279
+ const goal = window.__goal || null;
280
+ window.__goal = null;
281
+ return { rects: out, goal, need: window.__need?.(), busy: document.body.classList.contains('talking') || document.body.classList.contains('listening') || !!document.querySelector('.bubble.on'), asleep: document.body.classList.contains('asleep') };
282
+ })()`;
283
+ const hitTimer = setInterval(() => {
284
+ if (!alive()) return;
285
+ win.webContents.executeJavaScript(HIT).then((r) => {
286
+ ({ rects, busy, asleep } = r);
287
+ need = r.need || null;
288
+ if (r.goal) goTo(r.goal);
289
+ }).catch(() => {});
290
+ }, 120);
291
+
292
+ // Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
293
+ // clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
294
+ let passing = null;
295
+ let hoverUntil = 0;
296
+ let stillUntil = 0;
297
+ const pass = (p) => {
298
+ if (p === passing || !alive()) return;
299
+ passing = p;
300
+ win.setIgnoreMouseEvents(p, { forward: true });
301
+ };
302
+ let cursorNear = false;
303
+ let hovered = false;
304
+ let lastLook = null;
305
+ let shrinkAt = 0;
306
+ let shrinkTo = null;
307
+ let droppedSaid = true;
308
+ let dbgT = 0;
309
+ const mouseTimer = setInterval(() => {
310
+ if (!alive() || !win.isVisible()) return;
311
+ const c = screen.getCursorScreenPoint();
312
+ const [wx, wy] = win.getPosition();
313
+ const x = c.x - wx;
314
+ const y = c.y - wy;
315
+ const over = rects.some(([l, t, r, b]) => x >= l - 14 && x <= r + 14 && y >= t - 14 && y <= b + 14);
316
+ if (over) hoverUntil = Date.now() + 900;
317
+ // anywhere over its window (not only the drawn parts): it stays put so it can be clicked or dragged
318
+ if (x >= 0 && y >= 0 && x <= size.w && y <= size.h) stillUntil = Date.now() + 1200;
319
+ // the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
320
+ if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
321
+ lastLook = [x, y];
322
+ page(`postMessage({ look: [${x}, ${y}] })`);
323
+ }
324
+ // dropped after a drag: a little dizzy
325
+ if (dragged && !droppedSaid && Date.now() - dragged > 350) {
326
+ droppedSaid = true;
327
+ page('postMessage({ dropped: true })');
328
+ }
329
+ cursorNear = Math.hypot(c.x - pos.x, c.y - (pos.y - SMALL.h / 2)) < 260;
330
+ if (DEBUG && Date.now() - (dbgT || 0) > 1000) (dbgT = Date.now()), console.log('[7ots dbg]', HOST ? 'host' : opt.id, 'cur', x, y, 'over', over, 'busy', busy, 'pass', passing, 'rects', JSON.stringify(rects.map((q) => q.map(Math.round))), 'size', size.w, size.h, 'win', wx, wy);
331
+ pass(!(over || busy || grabbing) && rects.length > 0); // while dragged it keeps the mouse (the button-up must reach the page)
332
+ // the menu shows on hover: CSS :hover alone misses it when the window starts taking the mouse (or resizes)
333
+ // under a cursor that has not moved since
334
+ const h = Date.now() < hoverUntil;
335
+ if (h !== hovered) (hovered = h), page(`document.body.classList.toggle('hover', ${h})`);
336
+ resize();
337
+ }, 60);
338
+ // testing: SEVENOTS_PET_FX=thought plays that effect every 5 s
339
+ if (process.env.SEVENOTS_PET_FX) setInterval(() => page(`postMessage(${JSON.stringify({ effect: process.env.SEVENOTS_PET_FX })})`), 5000);
340
+ let held = null; // a scene holds one size from start to end (see director.cjs)
341
+ function resize() {
342
+ // Size: what the page shows (the bubble's real height, room for effects), big enough for the buttons on hover.
343
+ // Grows at once, shrinks only after it has wanted less for 2.5 s: an animation that swings the arms in and
344
+ // out would otherwise resize the window on every swing, and each resize of a transparent window flickers.
345
+ // 16 px steps to avoid resizing for every pixel.
346
+ const hover = Date.now() < hoverUntil;
347
+ const step = (v) => Math.ceil(v / 16) * 16;
348
+ const want = {
349
+ w: step(Math.min(area.width, Math.max(MIN.w, need?.w || 0, hover ? BIG.w : 0, held?.w || 0))),
350
+ h: step(Math.min(area.height, Math.max(MIN.h, need?.h || 0, hover ? BIG.h : 0, held?.h || 0))),
351
+ };
352
+ const grow = want.w > size.w || want.h > size.h;
353
+ const differs = want.w !== size.w || want.h !== size.h;
354
+ if (!differs) (shrinkAt = 0), (shrinkTo = null);
355
+ 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()))) {
357
+ size = grow ? { w: Math.max(want.w, size.w), h: Math.max(want.h, size.h) } : shrinkTo || want;
358
+ shrinkAt = 0;
359
+ shrinkTo = null;
360
+ place();
361
+ }
362
+ }
363
+ // 2D ↔ 3D from the settings. A 2D start on Linux turned the GPU off (see the top), and WebGL cannot come
364
+ // back without it: then the whole shell relaunches as 3D (the daemons live on; visitors reopen from /visitors).
365
+ function setView(on) {
366
+ if (on === view3d) return;
367
+ if (on && linux && !OPAQUE && !VIEW3D) {
368
+ if (relaunching) return;
369
+ relaunching = true;
370
+ process.env.SEVENOTS_PET_3D = '1';
371
+ app.relaunch();
372
+ return app.exit(0);
373
+ }
374
+ view3d = on;
375
+ if (alive()) win.loadURL(petURL());
376
+ }
377
+ // An effect just started: the page says how much room it needs right away (see tellNeed in pet.html).
378
+ win.webContents.on('console-message', (e, level, msg) => {
379
+ const m = String(e?.message ?? msg ?? '');
380
+ if (m.startsWith('\u00a77drag')) return grab(JSON.parse(m.slice(6)).on);
381
+ if (m.startsWith('\u00a77bye')) return HOST || dismiss();
382
+ if (m.startsWith('\u00a77view')) return setView(m.slice(6) === '3d');
383
+ if (!m.startsWith('\u00a77need')) return;
384
+ try {
385
+ need = JSON.parse(m.slice(6));
386
+ if (alive() && win.isVisible()) resize();
387
+ } catch {}
122
388
  });
123
- const alive = () => !win.isDestroyed();
124
- const tell = () => alive() && win.webContents.executeJavaScript(`postMessage({ walk: ${walking ? dir : 0} })`).catch(() => {});
125
- const walkTimer = setInterval(() => {
126
- walking = !still && Math.random() < 0.45;
127
- if (Math.random() < 0.3) dir = -dir;
128
- tell();
129
- }, 4000);
389
+
390
+ // ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
391
+ const rnd = (a, b) => a + Math.random() * (b - a);
392
+ const BAND = 200; // how far above the taskbar it roams
393
+ const minX = area.x + 136; // half the widest window (bubble + effects) stays on screen
394
+ const maxX = right - 136;
395
+ const homeY = () => home?.y ?? floor; // it roams in a band above its home's floor
396
+ const clampY = (y) => Math.max(homeY() - BAND, Math.min(homeY(), y));
397
+ const vel = { x: 0, y: 0 };
398
+ let plan = { mode: 'idle', until: Date.now() + 2500 };
399
+ let gait = 0;
400
+ let facing = 0; // what the page last heard: -1..1 (3D yaw follows it smoothly)
401
+ let lastT = Date.now();
402
+
403
+ function next() {
404
+ const now = Date.now();
405
+ const r = Math.random();
406
+ if (r < 0.38) return { mode: 'idle', until: now + rnd(1800, 7000) };
407
+ if (r < 0.48) return { mode: 'hop', start: now, hops: 1 + Math.floor(Math.random() * 3), then: { mode: 'idle', until: now + 2500 } };
408
+ if (r < 0.6 && cursorNear) return { mode: 'follow', until: now + rnd(3000, 6000), speed: rnd(70, 110) };
409
+ // a stroll: sometimes short and lazy, sometimes across the screen
410
+ const far = Math.random() < 0.3;
411
+ const dx = (far ? rnd(300, 900) : rnd(60, 260)) * (Math.random() < 0.5 ? -1 : 1);
412
+ // ...but never wanders far from home: past 320 px it heads back toward it
413
+ const hx = home?.x ?? pos.x;
414
+ const tx = Math.max(minX, Math.min(maxX, Math.abs(pos.x + dx - hx) > 320 ? hx + rnd(-120, 120) : pos.x + dx));
415
+ return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
416
+ }
417
+
130
418
  const stepTimer = setInterval(() => {
131
419
  // isDestroyed first: once the window is gone, isVisible() throws ("Object has been destroyed").
132
- if (!alive() || !walking || still || !win.isVisible() || Date.now() - dragged < 8000) return;
133
- const [cx, cy] = win.getPosition();
134
- x = cx + dir * 2;
135
- if (x < area.x || x > area.x + area.width - W) {
136
- dir = -dir;
137
- tell();
420
+ if (!alive() || !win.isVisible()) return;
421
+ const now = Date.now();
422
+ const dt = Math.min(0.05, (now - lastT) / 1000);
423
+ lastT = now;
424
+ const errand = plan.mode === 'point';
425
+ const scene = plan.mode === 'scene' || plan.mode === 'fling';
426
+ // the cursor on it stops it, scenes too (the director sees interrupted() and ends the scene): something
427
+ // that walks away from the mouse can't be grabbed or clicked
428
+ if (now - dragged < 6000 || now < stillUntil || (!errand && now < hoverUntil) || (!scene && (still || (!errand && (busy || asleep))))) {
429
+ // stop gently
430
+ vel.x *= 0.8;
431
+ vel.y *= 0.8;
432
+ if (Math.abs(vel.x) < 1 && facing !== 0 && plan.mode !== 'show') tellFacing(0); // keeps facing what it points at
138
433
  return;
139
434
  }
140
- lastSet = x;
141
- win.setPosition(x, cy);
142
- }, 40);
435
+
436
+ let lift = 0;
437
+ let tx = pos.x;
438
+ let ty = pos.y;
439
+ let speed = 0;
440
+ if (plan.mode === 'show' && now > plan.until) plan = { mode: 'idle', until: now + rnd(2000, 5000) };
441
+ if (plan.mode === 'idle' && now > plan.until) {
442
+ plan = next();
443
+ if (plan.mode === 'follow') page('postMessage({ follow: true })');
444
+ }
445
+ if (plan.mode === 'go' || plan.mode === 'point' || plan.mode === 'scene') ({ tx, ty, speed } = plan);
446
+ else if (plan.mode === 'fling') {
447
+ // knocked back: an arc, sliding to a stop where it lands
448
+ const t = (now - plan.start) / plan.ms;
449
+ if (t >= 1) plan = { mode: 'scene', tx: pos.x, ty: pos.y, speed: 0, dir: plan.dir };
450
+ else {
451
+ pos.x = Math.max(minX, Math.min(maxX, pos.x + plan.vx * dt * (1 - t)));
452
+ lift = Math.sin(Math.PI * t) * plan.height;
453
+ vel.x = vel.y = 0;
454
+ tx = pos.x;
455
+ ty = pos.y;
456
+ }
457
+ }
458
+ else if (plan.mode === 'follow') {
459
+ const c = screen.getCursorScreenPoint();
460
+ // stays a polite distance from the cursor, on the floor band
461
+ tx = Math.max(minX, Math.min(maxX, c.x + (c.x > pos.x ? -90 : 90)));
462
+ ty = clampY(c.y + SMALL.h / 2);
463
+ speed = plan.speed;
464
+ if (now > plan.until) plan = { mode: 'idle', until: now + rnd(1500, 4000) };
465
+ } else if (plan.mode === 'hop') {
466
+ const t = (now - plan.start) / 480;
467
+ if (t >= plan.hops) plan = plan.then;
468
+ else if (Math.floor(t) !== plan.said) (plan.said = Math.floor(t), page('postMessage({ hop: true })'));
469
+ if (plan.mode === 'hop') lift = Math.sin(Math.PI * (t % 1)) * 34 * (1 - Math.floor(t) * 0.25);
470
+ }
471
+
472
+ // arrive: full speed far away, easing to a stop in the last 140 px; velocity eases too (no jerks)
473
+ const dx = tx - pos.x;
474
+ const dy = ty - pos.y;
475
+ const dist = Math.hypot(dx, dy);
476
+ const want = speed * Math.min(1, dist / 140);
477
+ const vx = dist > 0.5 ? (dx / dist) * want : 0;
478
+ const vy = dist > 0.5 ? (dy / dist) * want : 0;
479
+ const k = Math.min(1, dt * 2.6);
480
+ vel.x += (vx - vel.x) * k;
481
+ vel.y += (vy - vel.y) * k;
482
+ pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
483
+ pos.y = Math.max(minY, Math.min(floor, pos.y + vel.y * dt)); // roams near the floor, errands go anywhere
484
+ if (plan.mode === 'go' && dist < 3 && Math.hypot(vel.x, vel.y) < 6) plan = { mode: 'idle', until: now + rnd(1500, 6000) };
485
+ if (plan.mode === 'point' && ((dist < 4 && Math.hypot(vel.x, vel.y) < 8) || now > plan.until)) arrived(plan);
486
+
487
+ // gait: a small bounce per step, scaled by speed
488
+ const v = Math.hypot(vel.x, vel.y);
489
+ gait += dt * (4 + v / 18);
490
+ if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
491
+
492
+ // facing: turn toward where it walks, back to the front when it stops
493
+ const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : plan.mode === 'show' || scene ? plan.dir || 0 : 0;
494
+ if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
495
+
496
+ place(Math.round(lift));
497
+ }, 16);
498
+
499
+ // ── Errands: walk to what needs you and point at it ──
500
+ const minY = area.y + BIG.h;
501
+ const TERM = /terminal|kitty|alacritty|konsole|tilix|wezterm|xterm|ghostty|warp|terminator|code|cursor|windsurf|zed|jetbrains|idea|pycharm|webstorm/i;
502
+ const BROWSER = /chrom|firefox|brave|vivaldi|opera|edge|epiphany|librewolf|zen/i;
503
+ const run = (cmd, args) => new Promise((res) => execFile(cmd, args, { timeout: 1500 }, (e, out) => res(e ? '' : String(out))));
504
+ // The top-most visible window whose class matches, if it is not mostly covered by others (no use pointing
505
+ // at a terminal hidden behind the browser: then it comes to the cursor instead).
506
+ async function findWindow(re) {
507
+ if (!linux || !process.env.DISPLAY) return null;
508
+ const root = await run('xprop', ['-root', '_NET_CLIENT_LIST_STACKING', '_NET_CURRENT_DESKTOP']);
509
+ const desk = root.match(/_NET_CURRENT_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
510
+ const ids = (root.match(/0x[0-9a-f]+/gi) || []).reverse(); // bottom → top, reversed
511
+ const above = [];
512
+ for (const hex of ids) {
513
+ const id = String(parseInt(hex, 16));
514
+ const props = await run('xprop', ['-id', id, 'WM_CLASS', '_NET_WM_STATE', '_NET_WM_DESKTOP', '_NET_WM_WINDOW_TYPE']);
515
+ const cls = props.match(/WM_CLASS\(STRING\) = (.*)/)?.[1] || '';
516
+ const d = props.match(/_NET_WM_DESKTOP\(CARDINAL\) = (\d+)/)?.[1];
517
+ if (/HIDDEN/.test(props) || /7ots|electron/i.test(cls) || (d != null && desk != null && d !== desk && d !== '4294967295')) continue;
518
+ if (/_TYPE_(DOCK|DESKTOP)/.test(props)) continue;
519
+ const g = await run('xdotool', ['getwindowgeometry', '--shell', id]);
520
+ const n = (k) => Number(g.match(new RegExp(`^${k}=(-?\\d+)`, 'm'))?.[1]);
521
+ const r = { x: n('X'), y: n('Y'), w: n('WIDTH'), h: n('HEIGHT') };
522
+ if (!(r.w > 50)) continue;
523
+ if (re.test(cls)) {
524
+ // how much of it the windows above cover (approximate: sum of overlaps, capped)
525
+ let hid = 0;
526
+ for (const o of above) hid += Math.max(0, Math.min(r.x + r.w, o.x + o.w) - Math.max(r.x, o.x)) * Math.max(0, Math.min(r.y + r.h, o.y + o.h) - Math.max(r.y, o.y));
527
+ return hid / (r.w * r.h) > 0.6 ? null : r;
528
+ }
529
+ above.push(r);
530
+ }
531
+ return null;
532
+ }
533
+ async function goTo(goal) {
534
+ if (still) return;
535
+ if (goal === 'browser') await new Promise((r) => setTimeout(r, 900)); // let the site open first
536
+ const c = screen.getCursorScreenPoint();
537
+ 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
+ const cx = rect.x + rect.w / 2;
539
+ // stand beside the window if there is room, otherwise inside it, near its lower corner closest to us
540
+ const half = SMALL.w / 2 + 12;
541
+ const roomL = rect.x - half >= minX;
542
+ const roomR = rect.x + rect.w + half <= maxX;
543
+ let tx;
544
+ if (rect.cursor) tx = c.x + (c.x > pos.x ? -110 : 110);
545
+ else if (roomL && (!roomR || pos.x < cx)) tx = rect.x - half;
546
+ else if (roomR) tx = rect.x + rect.w + half;
547
+ else tx = pos.x < cx ? rect.x + half + 20 : rect.x + rect.w - half - 20;
548
+ tx = Math.max(minX, Math.min(maxX, tx));
549
+ const ty = Math.max(minY, Math.min(floor, rect.cursor ? c.y + SMALL.h / 2 : Math.min(rect.y + rect.h, rect.y + rect.h * 0.75 + SMALL.h / 2)));
550
+ const spot = rect.cursor ? c : { x: cx, y: rect.y + Math.min(rect.h / 2, 160) };
551
+ plan = { mode: 'point', tx, ty, speed: 260, until: Date.now() + 9000, spot };
552
+ page('postMessage({ errand: true })');
553
+ }
554
+ function arrived(p) {
555
+ const dir = p.spot.x >= pos.x ? 1 : -1;
556
+ plan = { mode: 'show', dir, until: Date.now() + 5000 };
557
+ tellFacing(dir);
558
+ const [wx, wy] = win.getPosition();
559
+ page(`postMessage({ point: ${dir}, at: [${Math.round(p.spot.x - wx)}, ${Math.round(p.spot.y - wy)}] })`);
560
+ }
561
+
562
+ function tellFacing(f) {
563
+ facing = f;
564
+ page(`postMessage({ walk: ${f} })`);
565
+ }
566
+
567
+ function close() {
568
+ if (alive()) win.destroy();
569
+ }
143
570
  win.on('closed', () => {
144
- clearInterval(walkTimer);
145
571
  clearInterval(stepTimer);
572
+ clearInterval(hitTimer);
573
+ clearInterval(mouseTimer);
146
574
  });
575
+
576
+ // ── What the director (scenes between ots, see director.cjs) can do with this one ──
577
+ return {
578
+ id: HOST ? 'host' : opt.id,
579
+ host: HOST,
580
+ home: () => homeAnchor(),
581
+ pos: () => ({ ...pos }),
582
+ bounds: () => ({ minX, maxX, floor }),
583
+ close,
584
+ // free to join a scene: shown, loaded, not held still, dragged, hovered, talking or on an errand
585
+ 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
+ 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
+ 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,
588
+ face: (dir) => {
589
+ if (plan.mode === 'scene' || plan.mode === 'fling') plan.dir = dir;
590
+ tellFacing(dir);
591
+ },
592
+ fling: (vx, height = 46, ms = 650) => (plan = { mode: 'fling', vx, height, ms, start: Date.now(), dir: plan.dir || 0 }),
593
+ say: (msg) => page(`postMessage(${JSON.stringify(msg)})`),
594
+ release: () => {
595
+ plan = { mode: 'idle', until: Date.now() + rnd(2500, 6000) };
596
+ tellFacing(0);
597
+ },
598
+ hold: (s) => ((held = s), resize()),
599
+ inScene: () => plan.mode === 'scene' || plan.mode === 'fling',
600
+ // the mouse on it (to click or drag it) ends the scene
601
+ interrupted: () => !alive() || !win.isVisible() || Date.now() - dragged < 1000 || Date.now() < hoverUntil || Date.now() < stillUntil,
602
+ };
147
603
  }
148
604
 
149
605
  // Hidden is not closed: the pet lives on in the tray until Quit.