@7ots/cli 0.1.5 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +14 -20
  2. package/cli/7ots.mjs +55 -5
  3. package/cli/lib/account.mjs +89 -0
  4. package/cli/lib/assistant.mjs +122 -16
  5. package/cli/lib/brain.mjs +41 -11
  6. package/cli/lib/card-shot.mjs +84 -0
  7. package/cli/lib/config.mjs +28 -5
  8. package/cli/lib/i18n.mjs +57 -0
  9. package/cli/lib/inferences.mjs +76 -0
  10. package/cli/lib/integrations.mjs +414 -0
  11. package/cli/lib/orquesta.mjs +53 -0
  12. package/cli/lib/pet-core.mjs +7 -10
  13. package/cli/lib/pet-server.mjs +365 -22
  14. package/cli/lib/screen.mjs +125 -0
  15. package/cli/lib/stt-local.py +14 -0
  16. package/cli/lib/stt.mjs +68 -0
  17. package/cli/lib/tts-local.mjs +110 -0
  18. package/cli/lib/tts-local.py +18 -0
  19. package/cli/lib/vm.mjs +309 -0
  20. package/cli/lib/watchers.mjs +151 -0
  21. package/cli/pet/card.html +294 -0
  22. package/cli/pet/electron/director.cjs +175 -0
  23. package/cli/pet/electron/main.cjs +354 -49
  24. package/cli/pet/pet.html +328 -15
  25. package/cli/pet/settings.html +991 -57
  26. package/dist/7ots.esm.js +3 -3
  27. package/dist/7ots.esm.js.map +3 -3
  28. package/dist/7ots.iife.js +3 -3
  29. package/dist/7ots.iife.js.map +3 -3
  30. package/llms.txt +31 -17
  31. package/package.json +1 -1
  32. package/server/llm.mjs +2 -0
  33. package/server/platform/connect.mjs +3 -1
  34. package/server/platform/db.mjs +77 -0
  35. package/server/platform/device.mjs +125 -0
  36. package/server/platform/orquesta.mjs +5 -0
  37. package/server/platform/routes.mjs +100 -1
  38. package/server/platform/sites.mjs +146 -0
  39. package/server/platform/store.mjs +3 -1
  40. package/server/platform/vm.mjs +386 -0
  41. package/src/character3d/index.js +2 -1
  42. package/src/character3d/stage.js +15 -4
  43. package/src/i18n/messages/character.js +3 -3
  44. package/src/i18n/messages/platform.js +42 -3
@@ -3,12 +3,16 @@
3
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;
@@ -18,14 +22,18 @@ const LANG = String(process.env.SEVENOTS_PET_LANG || 'en').slice(0, 2);
18
22
  const ICON = path.join(__dirname, '..', '..', '..', 'brand', 'icon-512.png');
19
23
  const BIG = { w: 240, h: 270 }; // with room for the bubble, the buttons and the talk box
20
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 right = area.x + area.width;
45
- const floor = area.y + area.height;
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,10 +62,70 @@ 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;
56
109
 
57
110
  // The ot lives at an anchor (bottom-center of the window). The window is sized to what is in use:
58
111
  // 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 });
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)
60
129
  const pos = homeAnchor();
61
130
  let size = BIG; // it says hi on start
62
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 });
@@ -76,15 +145,22 @@ function start() {
76
145
  });
77
146
  win.setAlwaysOnTop(true, 'floating');
78
147
  win.setVisibleOnAllWorkspaces?.(true);
79
- win.loadURL(`${URL_}/pet?desktop=1${OPAQUE ? '&opaque=1' : ''}${VIEW3D ? '&3d=1' : ''}&token=${encodeURIComponent(TOKEN)}`);
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.
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.
83
152
  win.webContents.setWindowOpenHandler(({ url }) => {
84
- if (url.startsWith(`${URL_}/settings`)) openSettings();
153
+ if (url.startsWith(`${base}/settings`)) openSettings(base, tok);
85
154
  return { action: 'deny' };
86
155
  });
87
- 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));
88
164
  const alive = () => !win.isDestroyed();
89
165
  const page = (js) => alive() && win.webContents.executeJavaScript(js).catch(() => {});
90
166
 
@@ -102,22 +178,40 @@ function start() {
102
178
  win.showInactive();
103
179
  win.moveTop();
104
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
+ ]);
105
189
  const menu = () =>
106
190
  Menu.buildFromTemplate([
107
191
  win.isVisible() ? { label: tr('hide'), click: () => win.hide() } : { label: tr('show'), click: show },
108
192
  { type: 'separator' },
109
193
  { label: tr('talk'), click: () => { show(); win.focus(); page('postMessage({ talk: true })'); } },
110
- { label: tr('feed'), click: () => post('/event', { type: 'feed' }) },
111
- { label: tr('play'), click: () => post('/event', { type: 'play' }) },
112
- { label: tr('sleep'), click: () => post('/event', { type: 'sleep' }) },
113
- { label: tr('wake'), click: () => post('/event', { type: 'wake' }) },
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' }) },
114
198
  { type: 'separator' },
115
- { label: tr('settings'), click: openSettings },
199
+ { label: tr('settings'), click: () => openSettings(base, tok) },
116
200
  { label: tr('still'), type: 'checkbox', checked: still, click: (i) => (still = i.checked) },
117
- { 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 },
118
202
  ]);
119
- win.webContents.on('context-menu', () => menu().popup({ window: win }));
120
- 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) {
121
215
  const refresh = () => tray.setContextMenu(menu()); // Linux trays only show the context menu
122
216
  refresh();
123
217
  win.on('show', refresh);
@@ -125,9 +219,11 @@ function start() {
125
219
  tray.on('click', () => (win.isVisible() ? win.hide() : show()));
126
220
  }
127
221
 
128
- // The user dragged it: adopt the new spot as its anchor and let it rest there a while.
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.
129
225
  let dragged = 0;
130
- win.on('moved', () => {
226
+ const onMove = () => {
131
227
  if (!alive() || !lastBounds) return;
132
228
  const [wx, wy] = win.getPosition();
133
229
  if (Math.abs(wx - lastBounds.x) < 3 && Math.abs(wy - lastBounds.y) < 3) return;
@@ -137,46 +233,89 @@ function start() {
137
233
  pos.y = wy + size.h;
138
234
  lastBounds = { ...lastBounds, x: wx, y: wy };
139
235
  plan = { mode: 'idle', until: Date.now() + 6000 };
140
- });
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);
141
264
 
142
265
  // ── What the page is showing (polled): hit areas for click-through, and whether it needs the big window ──
143
266
  let rects = [];
267
+ let need = null; // what the page really shows: { w, h } (bubble, effects around the body, talk box)
144
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)
145
270
  const HIT = `(() => {
146
271
  const out = [];
147
272
  for (const e of document.querySelectorAll('#stage, .actions, .ask')) {
148
273
  const cs = getComputedStyle(e);
149
- if (cs.display === 'none' || cs.visibility === 'hidden' || +cs.opacity < 0.3) continue;
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;
150
276
  const r = e.getBoundingClientRect();
151
277
  if (r.width && r.height) out.push([r.left, r.top, r.right, r.bottom]);
152
278
  }
153
- return { rects: out, busy: document.body.classList.contains('talking') || !!document.querySelector('.bubble.on') };
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') };
154
282
  })()`;
155
283
  const hitTimer = setInterval(() => {
156
284
  if (!alive()) return;
157
- win.webContents.executeJavaScript(HIT).then((r) => ({ rects, busy } = r)).catch(() => {});
158
- }, 250);
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);
159
291
 
160
292
  // Click-through: only the ot, its buttons and the talk box take the mouse; the transparent rest lets
161
293
  // clicks reach the window behind. The cursor is polled because `forward: true` is macOS/Windows only.
162
294
  let passing = null;
163
295
  let hoverUntil = 0;
296
+ let stillUntil = 0;
164
297
  const pass = (p) => {
165
298
  if (p === passing || !alive()) return;
166
299
  passing = p;
167
300
  win.setIgnoreMouseEvents(p, { forward: true });
168
301
  };
169
302
  let cursorNear = false;
303
+ let hovered = false;
170
304
  let lastLook = null;
305
+ let shrinkAt = 0;
306
+ let shrinkTo = null;
171
307
  let droppedSaid = true;
308
+ let dbgT = 0;
172
309
  const mouseTimer = setInterval(() => {
173
310
  if (!alive() || !win.isVisible()) return;
174
311
  const c = screen.getCursorScreenPoint();
175
312
  const [wx, wy] = win.getPosition();
176
313
  const x = c.x - wx;
177
314
  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);
315
+ const over = rects.some(([l, t, r, b]) => x >= l - 14 && x <= r + 14 && y >= t - 14 && y <= b + 14);
179
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;
180
319
  // the eyes follow the cursor anywhere on screen (the page only sees it inside the window)
181
320
  if (!lastLook || Math.abs(x - lastLook[0]) + Math.abs(y - lastLook[1]) > 6) {
182
321
  lastLook = [x, y];
@@ -188,21 +327,73 @@ function start() {
188
327
  page('postMessage({ dropped: true })');
189
328
  }
190
329
  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;
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;
196
360
  place();
197
361
  }
198
- }, 60);
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 {}
388
+ });
199
389
 
200
390
  // ── Movement: steering with ease-in/ease-out, a little gait bounce, hops, pauses, curiosity ──
201
391
  const rnd = (a, b) => a + Math.random() * (b - a);
202
392
  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));
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));
206
397
  const vel = { x: 0, y: 0 };
207
398
  let plan = { mode: 'idle', until: Date.now() + 2500 };
208
399
  let gait = 0;
@@ -218,7 +409,9 @@ function start() {
218
409
  // a stroll: sometimes short and lazy, sometimes across the screen
219
410
  const far = Math.random() < 0.3;
220
411
  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));
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));
222
415
  return { mode: 'go', tx, ty: clampY(pos.y + rnd(-60, 60)), speed: far ? rnd(110, 170) : rnd(45, 95) };
223
416
  }
224
417
 
@@ -228,11 +421,15 @@ function start() {
228
421
  const now = Date.now();
229
422
  const dt = Math.min(0.05, (now - lastT) / 1000);
230
423
  lastT = now;
231
- if (still || busy || now - dragged < 6000 || now < hoverUntil) {
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))))) {
232
429
  // stop gently
233
430
  vel.x *= 0.8;
234
431
  vel.y *= 0.8;
235
- if (Math.abs(vel.x) < 1 && facing !== 0) tellFacing(0);
432
+ if (Math.abs(vel.x) < 1 && facing !== 0 && plan.mode !== 'show') tellFacing(0); // keeps facing what it points at
236
433
  return;
237
434
  }
238
435
 
@@ -240,11 +437,24 @@ function start() {
240
437
  let tx = pos.x;
241
438
  let ty = pos.y;
242
439
  let speed = 0;
440
+ if (plan.mode === 'show' && now > plan.until) plan = { mode: 'idle', until: now + rnd(2000, 5000) };
243
441
  if (plan.mode === 'idle' && now > plan.until) {
244
442
  plan = next();
245
443
  if (plan.mode === 'follow') page('postMessage({ follow: true })');
246
444
  }
247
- if (plan.mode === 'go') ({ tx, ty, speed } = plan);
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
+ }
248
458
  else if (plan.mode === 'follow') {
249
459
  const c = screen.getCursorScreenPoint();
250
460
  // stays a polite distance from the cursor, on the floor band
@@ -270,8 +480,9 @@ function start() {
270
480
  vel.x += (vx - vel.x) * k;
271
481
  vel.y += (vy - vel.y) * k;
272
482
  pos.x = Math.max(minX, Math.min(maxX, pos.x + vel.x * dt));
273
- pos.y = clampY(pos.y + vel.y * dt);
483
+ pos.y = Math.max(minY, Math.min(floor, pos.y + vel.y * dt)); // roams near the floor, errands go anywhere
274
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);
275
486
 
276
487
  // gait: a small bounce per step, scaled by speed
277
488
  const v = Math.hypot(vel.x, vel.y);
@@ -279,22 +490,116 @@ function start() {
279
490
  if (v > 4) lift += Math.abs(Math.sin(gait * Math.PI)) * Math.min(5, v / 22);
280
491
 
281
492
  // 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;
493
+ const f = v > 8 ? Math.max(-1, Math.min(1, vel.x / 70)) : plan.mode === 'show' || scene ? plan.dir || 0 : 0;
283
494
  if (Math.abs(f - facing) > 0.15 || (f === 0 && facing !== 0)) tellFacing(Math.round(f * 10) / 10);
284
495
 
285
496
  place(Math.round(lift));
286
497
  }, 16);
287
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
+
288
562
  function tellFacing(f) {
289
563
  facing = f;
290
564
  page(`postMessage({ walk: ${f} })`);
291
565
  }
292
566
 
567
+ function close() {
568
+ if (alive()) win.destroy();
569
+ }
293
570
  win.on('closed', () => {
294
571
  clearInterval(stepTimer);
295
572
  clearInterval(hitTimer);
296
573
  clearInterval(mouseTimer);
297
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
+ };
298
603
  }
299
604
 
300
605
  // Hidden is not closed: the pet lives on in the tray until Quit.