bloby-bot 0.69.5 → 0.69.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 (31) hide show
  1. package/package.json +1 -1
  2. package/supervisor/channels/whatsapp.ts +25 -22
  3. package/supervisor/index.ts +224 -20
  4. package/supervisor/public/morphy/headphones-idle.webp +0 -0
  5. package/supervisor/public/morphy/headphones.json +4 -4
  6. package/supervisor/public/morphy/headphones.webp +0 -0
  7. package/supervisor/public/morphy/teleporting.json +2 -2
  8. package/supervisor/public/morphy/teleporting.webp +0 -0
  9. package/supervisor/shell.ts +53 -0
  10. package/supervisor/vite-dev.ts +15 -1
  11. package/supervisor/widget.js +124 -24
  12. package/supervisor/workspace-guard.js +33 -0
  13. package/vite.config.ts +22 -0
  14. package/workspace/client/public/morphy/headphones-idle.webp +0 -0
  15. package/workspace/client/public/morphy/headphones.json +4 -4
  16. package/workspace/client/public/morphy/headphones.webp +0 -0
  17. package/workspace/client/public/morphy/teleporting.json +2 -2
  18. package/workspace/client/public/morphy/teleporting.webp +0 -0
  19. package/workspace/client/public/sw.js +25 -2
  20. package/workspace/skills/create-skill/SKILL.md +188 -0
  21. package/workspace/skills/create-skill/references/patterns.md +126 -0
  22. package/workspace/client/public/arrow.png +0 -0
  23. package/workspace/client/public/bloby_happy.mov +0 -0
  24. package/workspace/client/public/bloby_happy.webm +0 -0
  25. package/workspace/client/public/bloby_happy_reappearing.mov +0 -0
  26. package/workspace/client/public/bloby_happy_reappearing.webm +0 -0
  27. package/workspace/client/public/bloby_say_hi.mov +0 -0
  28. package/workspace/client/public/bloby_say_hi.webm +0 -0
  29. package/workspace/client/public/bloby_tilts.webm +0 -0
  30. package/workspace/client/public/headphones_spritesheet.webp +0 -0
  31. package/workspace/client/public/spritesheet.webp +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.69.5",
3
+ "version": "0.69.6",
4
4
  "releaseNotes": [
5
5
  "1. Fix: agent self-update ",
6
6
  "1",
@@ -481,6 +481,7 @@ export class WhatsAppChannel implements ChannelProvider {
481
481
  if (connection === 'open') {
482
482
  this.connected = true;
483
483
  this.reconnectAttempts = 0;
484
+ this.pairingRetries = 0;
484
485
  this.qrData = null;
485
486
  this.qrSvg = null;
486
487
  this.buildLidMap();
@@ -516,38 +517,40 @@ export class WhatsAppChannel implements ChannelProvider {
516
517
  return;
517
518
  }
518
519
 
519
- // Never paired (pair-success sets creds.registered before the post-pairing 515,
520
- // so a registered=false close means the pairing window expired unused).
521
- if (!state.creds.registered) {
522
- if (state.creds.me) {
523
- // Phantom identity from an uncompleted pairing-code attempt (Baileys sets
524
- // creds.me as a placeholder when the code is requested). Reconnecting would
525
- // try to LOG IN with an identity the server never registered → guaranteed
526
- // 401 wipe. Reset to a clean unpaired state instead.
527
- log.info('[whatsapp] Pairing code expired before confirmation — resetting credentials; connect again to retry');
528
- await this.deleteCredentials();
529
- } else if (this.pairingRetries < 2) {
530
- // Fresh QR windows for a while (the link page is likely still open),
531
- // then stop instead of cycling QR codes in the background forever.
520
+ // restartRequired (515) is the server's reconnect-now handoff. In the QR flow it
521
+ // arrives right after pair-success, while creds.registered is STILL false (that
522
+ // flag is only set in the pairing-code flow, messages-recv link_code_pairing_ref)
523
+ // so it MUST be handled before any unpaired/phantom logic, or a successful
524
+ // scan gets thrown away. Backoff guards a pathological repeated-515 loop.
525
+ if (statusCode === DisconnectReason.restartRequired) {
526
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 60_000);
527
+ log.info(`[whatsapp] Restart required (normal after pairing) reconnecting in ${delay}ms...`);
528
+ this.emitStatus();
529
+ this.scheduleReconnect(delay);
530
+ return;
531
+ }
532
+
533
+ // Pure QR wait expired without anyone claiming an identity — regenerate a fresh
534
+ // QR a couple of times (the link page is likely still open), then stop instead
535
+ // of cycling QR codes in the background forever.
536
+ if (!state.creds.registered && !state.creds.me) {
537
+ if (this.pairingRetries < 2) {
532
538
  this.pairingRetries++;
533
539
  log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
534
540
  this.emitStatus();
535
541
  this.scheduleReconnect(2000);
536
- return;
537
542
  } else {
538
543
  log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
544
+ this.emitStatus();
539
545
  }
540
- this.emitStatus();
541
546
  return;
542
547
  }
543
548
 
544
- // restartRequired (515) is the normal post-pairing handoff reconnect right
545
- // away, but only the first time (a repeated-515 loop is a known Baileys failure
546
- // mode; let it back off). Everything else backs off 5s 60s so an offline
547
- // network doesn't hot-loop.
548
- const fastRestart = statusCode === DisconnectReason.restartRequired && this.reconnectAttempts === 0;
549
- const delay = fastRestart ? 1000 : Math.min(5000 * 2 ** this.reconnectAttempts, 60_000);
550
- this.reconnectAttempts++;
549
+ // Anything else reconnects with backoff (5s 60s). This includes the
550
+ // me-set-but-unregistered states: a real post-pairing identity completes its
551
+ // login on reconnect, and a stale pairing-code placeholder gets a 401 from the
552
+ // server which lands in the loggedOut branch above and wipes it cleanly.
553
+ const delay = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
551
554
  log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
552
555
  this.emitStatus();
553
556
  this.scheduleReconnect(delay);
@@ -28,6 +28,7 @@ import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
28
28
  import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRunISO, describeCron } from './scheduler.js';
29
29
  import { execSync, spawn as cpSpawn } from 'child_process';
30
30
  import crypto from 'crypto';
31
+ import zlib from 'zlib';
31
32
  import { ChannelManager } from './channels/manager.js';
32
33
  import { SHELL_HTML } from './shell.js';
33
34
 
@@ -141,8 +142,10 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
141
142
  // cached shell that masks a broken (or just-fixed) frontend and produces the confusing
142
143
  // "normal refresh is broken but hard refresh works" split. Cache is a pure offline fallback.
143
144
 
144
- var CACHE = 'bloby-v24';
145
- var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
145
+ var CACHE = 'bloby-v25';
146
+ // Hash class includes -/_ — Vite's content hashes are base64url (e.g. bloby-Dfx1hOe-.js);
147
+ // without them ~3 MB of hashed chat assets silently fell out of cache-first into network-first.
148
+ var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9_-]{6,}[.](js|css)$');
146
149
 
147
150
  // Precache the HTML shell on install so the cache is never empty.
148
151
  // Without this, the first navigation isn't intercepted (SW wasn't
@@ -197,6 +200,31 @@ self.addEventListener('fetch', function(event) {
197
200
  return;
198
201
  }
199
202
 
203
+ // Vite's optimized deps (/node_modules/.vite/deps/*?v=<hash>) are content-addressed and
204
+ // served max-age=31536000,immutable — but fell into network-first below, re-crossing the
205
+ // tunnel whenever the HTTP cache evicted them (common on mobile). Cache-first, pruning
206
+ // older ?v= versions of the same module. Source modules (?t=, /@*) stay network-only.
207
+ if (url.pathname.indexOf('/node_modules/.vite/deps/') === 0 && url.searchParams.has('v')) {
208
+ event.respondWith(caches.open(CACHE).then(function(c) {
209
+ return c.match(request).then(function(hit) {
210
+ if (hit) return hit;
211
+ return fetch(request).then(function(r) {
212
+ if (r.ok) {
213
+ c.put(request, r.clone());
214
+ c.keys().then(function(keys) {
215
+ for (var i = 0; i < keys.length; i++) {
216
+ var u = new URL(keys[i].url);
217
+ if (u.pathname === url.pathname && u.search !== url.search) c.delete(keys[i]);
218
+ }
219
+ });
220
+ }
221
+ return r;
222
+ });
223
+ });
224
+ }));
225
+ return;
226
+ }
227
+
200
228
  // Navigation: only stale-while-revalidate the dashboard root (/).
201
229
  // /bloby/* is a separate app — let it go to network to avoid
202
230
  // caching the wrong HTML under the / key.
@@ -431,6 +459,12 @@ export async function startSupervisor() {
431
459
  // Create HTTP server first (Vite needs it for HMR WebSocket)
432
460
  // The request handler is set up later via server.on('request')
433
461
  const server = http.createServer();
462
+ // cloudflared keeps a pool of ~100 origin connections with a 90s idle timeout; Node's
463
+ // default keepAliveTimeout is 5s, so after any pause every request burst re-paid TCP setup —
464
+ // and the 5s-vs-90s mismatch is the classic reused-socket race behind sporadic tunnel 502s
465
+ // ("error opening a stream to the origin"). Outlive cloudflared's pool instead.
466
+ server.keepAliveTimeout = 120_000;
467
+ server.headersTimeout = 125_000; // must exceed keepAliveTimeout (Node guards header floods with it)
434
468
 
435
469
  // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly.
436
470
  // A Vite boot failure must NOT take down the supervisor (G1): chat is served independently and
@@ -605,19 +639,150 @@ export async function startSupervisor() {
605
639
  });
606
640
  }
607
641
 
642
+ // ── Cached static serving: in-memory buffers + content ETags + 304s + gzip ──────────────
643
+ // Everything the supervisor serves on the refresh path used to be fs.readFileSync per request
644
+ // with no validator — nothing could ever 304, so ~90 KB of identical bytes crossed the tunnel
645
+ // on every refresh (plus sync SD-card reads on Pi-class hardware). Entries are stat-validated
646
+ // by mtime, so editing a file (dev) or `bloby update` + restart (prod) is always picked up;
647
+ // `no-cache` semantics (revalidate every load) are preserved — clients just get 304s now.
648
+ type CachedAsset = { buf: Buffer; gz: Buffer | null; etag: string; mtimeMs: number };
649
+ const staticCache = new Map<string, CachedAsset>();
650
+
651
+ function buildCachedAsset(buf: Buffer, mtimeMs: number): CachedAsset {
652
+ const etag = '"' + crypto.createHash('sha1').update(buf).digest('base64url').slice(0, 16) + '"';
653
+ const gz = buf.length > 1024 ? zlib.gzipSync(buf, { level: 6 }) : null;
654
+ // Keep gzip only when it actually pays for the Content-Encoding overhead.
655
+ return { buf, gz: gz && gz.length < buf.length * 0.92 ? gz : null, etag, mtimeMs };
656
+ }
657
+
658
+ function serveCachedText(
659
+ req: http.IncomingMessage,
660
+ res: http.ServerResponse,
661
+ key: string,
662
+ src: { file?: string; content?: string },
663
+ contentType: string,
664
+ cacheControl: string,
665
+ extra: Record<string, string> = {},
666
+ ): void {
667
+ let entry = staticCache.get(key);
668
+ if (src.file !== undefined) {
669
+ let mtimeMs = 0;
670
+ try { mtimeMs = fs.statSync(src.file).mtimeMs; } catch { res.writeHead(404); res.end('Not found'); return; }
671
+ if (!entry || entry.mtimeMs !== mtimeMs) {
672
+ if (staticCache.size > 200) staticCache.clear(); // runaway guard; rebuilt on demand
673
+ try { entry = buildCachedAsset(fs.readFileSync(src.file), mtimeMs); staticCache.set(key, entry); }
674
+ catch { res.writeHead(404); res.end('Not found'); return; }
675
+ }
676
+ } else if (!entry) {
677
+ entry = buildCachedAsset(Buffer.from(src.content || '', 'utf-8'), -1);
678
+ staticCache.set(key, entry);
679
+ }
680
+ if (req.headers['if-none-match'] === entry.etag) {
681
+ res.writeHead(304, { ETag: entry.etag, 'Cache-Control': cacheControl, ...extra });
682
+ res.end();
683
+ return;
684
+ }
685
+ const gzOk = entry.gz !== null && String(req.headers['accept-encoding'] || '').includes('gzip');
686
+ res.writeHead(200, {
687
+ 'Content-Type': contentType,
688
+ 'Cache-Control': cacheControl,
689
+ ETag: entry.etag,
690
+ Vary: 'Accept-Encoding',
691
+ ...(gzOk ? { 'Content-Encoding': 'gzip' } : {}),
692
+ ...extra,
693
+ });
694
+ res.end(gzOk ? entry.gz : entry.buf);
695
+ }
696
+
697
+ // Read once per process — the version can only change via an update, which restarts us.
698
+ let pkgVersion = 'unknown';
699
+ try { pkgVersion = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
700
+
701
+ // Keep-alive agent for the loopback proxies (Vite dev server + workspace backend). Without it
702
+ // (on Node 18, the install floor) every proxied module request opens a fresh TCP connection —
703
+ // dozens of handshakes per refresh that a Pi actually feels.
704
+ const loopbackAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
705
+ const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'];
706
+ function stripHopByHop(h: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
707
+ const out: http.IncomingHttpHeaders = {};
708
+ for (const k in h) if (!HOP_BY_HOP.includes(k)) out[k] = h[k];
709
+ return out; // forwarding 'connection: close' would defeat the keep-alive pool
710
+ }
711
+
712
+ // ── Refresh-performance instrumentation ─────────────────────────────────────────────────
713
+ // Server side: a ring of recent request timings (duration, bytes out, encoding, whether the
714
+ // socket was reused). Client side: the shell assembles marks from itself + widget + the
715
+ // workspace iframe into one beacon per page load and POSTs it here. Read both via
716
+ // GET /__bloby/perf — LOCALHOST ONLY (request paths/timings stay off the tunnel); remote
717
+ // debugging uses the console print behind localStorage.bloby_perf='1' in the shell.
718
+ const perfRequests: object[] = [];
719
+ const perfBeacons: object[] = [];
720
+ function isLoopback(req: http.IncomingMessage): boolean {
721
+ const a = req.socket.remoteAddress || '';
722
+ return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
723
+ }
724
+
608
725
  // HTTP request handler — proxies to Vite dev servers + worker API
609
726
  server.on('request', async (req, res) => {
727
+ // Request timing sample — attached before any routing so every branch is covered.
728
+ {
729
+ const t0 = Date.now();
730
+ const sock = req.socket as any;
731
+ const reused = !!sock.__blobySeen;
732
+ sock.__blobySeen = true;
733
+ const w0 = sock.bytesWritten || 0;
734
+ let sampled = false;
735
+ res.on('close', () => {
736
+ if (sampled) return;
737
+ sampled = true;
738
+ perfRequests.push({
739
+ ts: t0,
740
+ m: req.method,
741
+ p: (req.url || '').split('?')[0].slice(0, 120),
742
+ s: res.statusCode,
743
+ ms: Date.now() - t0,
744
+ out: Math.max(0, (req.socket.bytesWritten || 0) - w0),
745
+ enc: String(res.getHeader('content-encoding') || ''),
746
+ reused,
747
+ });
748
+ if (perfRequests.length > 400) perfRequests.splice(0, perfRequests.length - 400);
749
+ });
750
+ }
751
+
752
+ // Client perf beacon — one compact JSON per page load, posted by the shell.
753
+ if (req.url === '/__bloby/perf' && req.method === 'POST') {
754
+ const chunks: Buffer[] = [];
755
+ let size = 0;
756
+ req.on('data', (c: Buffer) => { size += c.length; if (size <= 32_768) chunks.push(c); });
757
+ req.on('end', () => {
758
+ try {
759
+ if (size <= 32_768) {
760
+ const beacon = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
761
+ perfBeacons.push({ ts: Date.now(), ...beacon });
762
+ if (perfBeacons.length > 20) perfBeacons.splice(0, perfBeacons.length - 20);
763
+ }
764
+ } catch {}
765
+ res.writeHead(204);
766
+ res.end();
767
+ });
768
+ return;
769
+ }
770
+ if (req.url === '/__bloby/perf' && req.method === 'GET') {
771
+ if (!isLoopback(req)) { res.writeHead(403); res.end('localhost only'); return; }
772
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
773
+ res.end(JSON.stringify({ beacons: perfBeacons, requests: perfRequests }));
774
+ return;
775
+ }
776
+
610
777
  // Bloby widget — served directly (not part of Vite build)
611
778
  if (req.url === '/bloby/widget.js') {
612
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
613
- res.end(fs.readFileSync(paths.widgetJs));
779
+ serveCachedText(req, res, 'widget', { file: paths.widgetJs }, 'application/javascript', 'no-cache');
614
780
  return;
615
781
  }
616
782
 
617
783
  // App WS client — served directly (proxies /app/api calls through WebSocket)
618
784
  if (req.url === '/bloby/app-ws.js') {
619
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
620
- res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
785
+ serveCachedText(req, res, 'app-ws', { file: path.join(PKG_DIR, 'supervisor', 'app-ws.js') }, 'application/javascript', 'no-cache');
621
786
  return;
622
787
  }
623
788
 
@@ -625,15 +790,13 @@ export async function startSupervisor() {
625
790
  // below). Auto-reloads into the "backend down" interstitial when the backend gives up, and
626
791
  // replaces Vite's raw error overlay with a friendly one. Served here so it's always current.
627
792
  if (req.url === '/bloby/workspace-guard.js') {
628
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
629
- res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'workspace-guard.js')));
793
+ serveCachedText(req, res, 'guard', { file: path.join(PKG_DIR, 'supervisor', 'workspace-guard.js') }, 'application/javascript', 'no-cache');
630
794
  return;
631
795
  }
632
796
 
633
797
  // Service worker — served from embedded constant (supervisor/ is always updated)
634
798
  if (req.url === '/sw.js' || req.url === '/bloby/sw.js') {
635
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
636
- res.end(SW_JS);
799
+ serveCachedText(req, res, 'sw', { content: SW_JS }, 'application/javascript', 'no-cache');
637
800
  return;
638
801
  }
639
802
 
@@ -650,10 +813,8 @@ export async function startSupervisor() {
650
813
  // every reconnect. A mismatch means the immortal shell survived a self-update running
651
814
  // pre-update code; the chat then triggers one full shell reload to pick up the new bundle.
652
815
  if (req.url === '/__bloby/version') {
653
- let v = 'unknown';
654
- try { v = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
655
816
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
656
- res.end(JSON.stringify({ version: v }));
817
+ res.end(JSON.stringify({ version: pkgVersion }));
657
818
  return;
658
819
  }
659
820
 
@@ -822,7 +983,7 @@ export async function startSupervisor() {
822
983
  }
823
984
 
824
985
  const proxy = http.request(
825
- { host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: req.headers },
986
+ { host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
826
987
  (proxyRes) => {
827
988
  const ct = String(proxyRes.headers['content-type'] || '');
828
989
  const isSse = ct.includes('text/event-stream');
@@ -2698,6 +2859,13 @@ ${alreadyLinked ? '' : `
2698
2859
  // HTML files: no-cache so rebuilds are picked up immediately
2699
2860
  // Hashed assets (.js, .css): immutable caching
2700
2861
  const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
2862
+ // Text assets go through the in-memory cache: gzip (the 750 KB chat entry → ~230 KB
2863
+ // over the tunnel) + ETag/304. The mtime check inside handles rebuilds. Binary types
2864
+ // (images, fonts, webm) stream as before — gzip wouldn't help them.
2865
+ if (['.js', '.css', '.html', '.svg', '.json'].includes(ext) && stat.size < 5_000_000) {
2866
+ serveCachedText(req, res, 'bloby:' + filePath, { file: fullPath }, mime, cacheControl);
2867
+ return;
2868
+ }
2701
2869
  res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cacheControl });
2702
2870
  // A file removed/replaced mid-stream (common during a workspace rebuild) emits an
2703
2871
  // async 'error' on the stream — without this listener it crashes the supervisor (G1).
@@ -2725,6 +2893,14 @@ ${alreadyLinked ? '' : `
2725
2893
  if (stat.isFile()) {
2726
2894
  const ext = path.extname(assetPath);
2727
2895
  const mime = MIME_TYPES[ext] || 'application/octet-stream';
2896
+ // ETag + 304 via the memory cache (these had no validator, so every >24h-stale
2897
+ // client re-downloaded the sprite sheets in full). Binary types won't gzip below
2898
+ // the keep threshold — the helper drops unhelpful gzip automatically. Very large
2899
+ // files (videos) keep streaming.
2900
+ if (stat.size < 5_000_000) {
2901
+ serveCachedText(req, res, 'pub:' + cleanUrl, { file: assetPath }, mime, 'public, max-age=86400');
2902
+ return;
2903
+ }
2728
2904
  res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=86400' });
2729
2905
  const rs = fs.createReadStream(assetPath);
2730
2906
  rs.on('error', () => { if (!res.headersSent) { res.writeHead(404); res.end('Not found'); } else res.destroy(); });
@@ -2770,8 +2946,7 @@ ${alreadyLinked ? '' : `
2770
2946
  (cleanUrl === '/' || fetchDest === 'document' ||
2771
2947
  (!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
2772
2948
  ) {
2773
- res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', 'X-Bloby-Origin': 'supervisor' });
2774
- res.end(SHELL_HTML);
2949
+ serveCachedText(req, res, 'shell', { content: SHELL_HTML }, 'text/html', 'no-cache', { 'X-Bloby-Origin': 'supervisor' });
2775
2950
  return;
2776
2951
  }
2777
2952
 
@@ -2800,8 +2975,14 @@ ${alreadyLinked ? '' : `
2800
2975
 
2801
2976
  // Everything else → proxy to dashboard Vite dev server
2802
2977
  const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
2978
+ // Vite dev serves everything identity-encoded and nothing else on the origin side
2979
+ // compresses, so the residential UPLINK — the slowest hop — carried every module raw.
2980
+ // Gzip text responses on the fly (level 4: ~4x smaller for ~nothing on a Pi, per response).
2981
+ // Streams (SSE), pre-encoded bodies, non-text types, and 304s pass through untouched.
2982
+ const clientAcceptsGzip = String(req.headers['accept-encoding'] || '').includes('gzip');
2983
+ const GZIPPABLE_RE = /^(text\/(?!event-stream)|application\/(javascript|json|manifest\+json)|image\/svg)/;
2803
2984
  const proxy = http.request(
2804
- { host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
2985
+ { host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
2805
2986
  (proxyRes) => {
2806
2987
  const ct = String(proxyRes.headers['content-type'] || '');
2807
2988
  const enc = String(proxyRes.headers['content-encoding'] || '');
@@ -2810,8 +2991,21 @@ ${alreadyLinked ? '' : `
2810
2991
  // untouched. The guard auto-reloads into the "backend down" interstitial and replaces
2811
2992
  // Vite's raw error overlay — supervisor-side so it reaches every workspace, no edits needed.
2812
2993
  if (!ct.includes('text/html') || enc) {
2813
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
2814
- proxyRes.pipe(res);
2994
+ const len = Number(proxyRes.headers['content-length'] || NaN);
2995
+ const gzip =
2996
+ clientAcceptsGzip && !enc && req.method === 'GET' && proxyRes.statusCode === 200 &&
2997
+ GZIPPABLE_RE.test(ct) && !proxyRes.headers['content-range'] && !(len < 1024);
2998
+ if (!gzip) {
2999
+ res.writeHead(proxyRes.statusCode!, proxyRes.headers);
3000
+ proxyRes.pipe(res);
3001
+ return;
3002
+ }
3003
+ const headers = { ...proxyRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' };
3004
+ delete headers['content-length']; // length changes; let Node chunk it
3005
+ res.writeHead(proxyRes.statusCode!, headers);
3006
+ const gz = zlib.createGzip({ level: 4 });
3007
+ gz.on('error', () => { try { res.destroy(); } catch {} });
3008
+ proxyRes.pipe(gz).pipe(res);
2815
3009
  return;
2816
3010
  }
2817
3011
  const chunks: Buffer[] = [];
@@ -2823,9 +3017,19 @@ ${alreadyLinked ? '' : `
2823
3017
  }
2824
3018
  const headers = { ...proxyRes.headers };
2825
3019
  // Body length changed — drop both framing headers and let Node set content-length
2826
- // from the single res.end() write.
3020
+ // from the single res.end() write. Vite's validators describe the UNMODIFIED body:
3021
+ // forwarding them would let a future conditional request 304 against bytes we changed.
2827
3022
  delete headers['content-length'];
2828
3023
  delete headers['transfer-encoding'];
3024
+ delete headers['etag'];
3025
+ delete headers['last-modified'];
3026
+ if (clientAcceptsGzip && proxyRes.statusCode === 200) {
3027
+ headers['content-encoding'] = 'gzip';
3028
+ headers['vary'] = 'Accept-Encoding';
3029
+ res.writeHead(proxyRes.statusCode!, headers);
3030
+ res.end(zlib.gzipSync(Buffer.from(html, 'utf-8'), { level: 4 }));
3031
+ return;
3032
+ }
2829
3033
  res.writeHead(proxyRes.statusCode!, headers);
2830
3034
  res.end(html);
2831
3035
  });
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "headphones",
3
- "spritesheet": "headphones.png",
3
+ "spritesheet": "headphones.webp",
4
4
  "frame": {
5
- "w": 174,
6
- "h": 180
5
+ "w": 145,
6
+ "h": 150
7
7
  },
8
8
  "grid": {
9
9
  "cols": 16,
@@ -37,4 +37,4 @@
37
37
  "next": "idle"
38
38
  }
39
39
  }
40
- }
40
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleporting",
3
- "spritesheet": "teleporting.png",
3
+ "spritesheet": "teleporting.webp",
4
4
  "frame": {
5
5
  "w": 218,
6
6
  "h": 180
@@ -35,4 +35,4 @@
35
35
  "next": "idle"
36
36
  }
37
37
  }
38
- }
38
+ }
@@ -21,6 +21,8 @@ export const SHELL_HTML = `<!DOCTYPE html>
21
21
  <link rel="icon" type="image/png" href="/morphy-favicon.png" />
22
22
  <link rel="apple-touch-icon" href="/morphy-icon-192.png" />
23
23
  <link rel="manifest" href="/manifest.json" />
24
+ <!-- widget.js sits at the end of body; fetch it while the iframe + inline scripts parse -->
25
+ <link rel="preload" href="/bloby/widget.js" as="script" />
24
26
  <title>Bloby</title>
25
27
  </head>
26
28
  <body style="background-color:#0A0A0A;margin:0;overflow:hidden">
@@ -34,6 +36,53 @@ export const SHELL_HTML = `<!DOCTYPE html>
34
36
  allow="camera; microphone; geolocation; clipboard-read; clipboard-write; fullscreen; autoplay; display-capture"
35
37
  ></iframe>
36
38
 
39
+ <script>
40
+ // ── Refresh-performance marks ──────────────────────────────────────────
41
+ // Collected from this document + widget.js (same window, via window.__blobyPerf.mark) +
42
+ // the workspace iframe (workspace-guard posts {type:'bloby:perf'}), assembled into ONE
43
+ // beacon per page load and POSTed to /__bloby/perf (kept server-side, localhost-readable).
44
+ // Live console print: localStorage.bloby_perf = '1'.
45
+ (function () {
46
+ var marks = {};
47
+ var workspace = null;
48
+ var sent = false;
49
+ window.__blobyPerf = {
50
+ mark: function (name) { if (marks[name] === undefined) marks[name] = Math.round(performance.now()); },
51
+ workspace: function (data) { workspace = data; },
52
+ };
53
+ function assemble() {
54
+ var nav = null;
55
+ try { nav = performance.getEntriesByType('navigation')[0] || null; } catch (e) {}
56
+ var b = {
57
+ host: location.host,
58
+ shell: nav ? {
59
+ ttfb: Math.round(nav.responseStart),
60
+ htmlDone: Math.round(nav.responseEnd),
61
+ domLoaded: Math.round(nav.domContentLoadedEventEnd),
62
+ } : null,
63
+ marks: marks,
64
+ workspace: workspace, // its marks arrive on the iframe's clock; offsetMs aligns them
65
+ };
66
+ if (workspace && typeof workspace.timeOrigin === 'number') {
67
+ b.workspace.offsetMs = Math.round(workspace.timeOrigin - performance.timeOrigin);
68
+ }
69
+ return b;
70
+ }
71
+ function send() {
72
+ if (sent) return;
73
+ sent = true;
74
+ var beacon = assemble();
75
+ try { if (localStorage.getItem('bloby_perf') === '1') console.log('[bloby-perf]', JSON.stringify(beacon, null, 2)); } catch (e) {}
76
+ try { fetch('/__bloby/perf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(beacon), keepalive: true }).catch(function () {}); } catch (e) {}
77
+ }
78
+ // Send a few seconds after the workspace reports ready, so stragglers (sprite sheet,
79
+ // the deferred chat iframe, late modules) land in the same beacon. Backstop for broken
80
+ // workspaces. 7s covers the chat's idle-deferred load (~app-ready + 4s timeout + load).
81
+ window.addEventListener('bloby:app-ready', function () { setTimeout(send, 7000); });
82
+ setTimeout(send, 20000);
83
+ })();
84
+ </script>
85
+
37
86
  <script>
38
87
  (function () {
39
88
  var frame = document.getElementById('bloby-workspace');
@@ -68,8 +117,10 @@ export const SHELL_HTML = `<!DOCTYPE html>
68
117
  if (appReadyFired) return;
69
118
  appReadyFired = true;
70
119
  window.__blobyAppReady = true;
120
+ if (window.__blobyPerf) window.__blobyPerf.mark('shell:app-ready');
71
121
  window.dispatchEvent(new Event('bloby:app-ready'));
72
122
  }
123
+ if (window.__blobyPerf) window.__blobyPerf.mark('shell:frame-src-set');
73
124
 
74
125
  window.addEventListener('message', function (e) {
75
126
  // Same-origin iframe only — drop anything else (extensions, third-party embeds).
@@ -80,6 +131,8 @@ export const SHELL_HTML = `<!DOCTYPE html>
80
131
  fireAppReady();
81
132
  } else if (d.type === 'bloby:title') {
82
133
  document.title = String(d.title || '').slice(0, 200) || 'Bloby';
134
+ } else if (d.type === 'bloby:perf' && d.data && window.__blobyPerf) {
135
+ window.__blobyPerf.workspace(d.data);
83
136
  }
84
137
  });
85
138
 
@@ -1,7 +1,8 @@
1
1
  import { createServer as createViteServer, createLogger, type ViteDevServer } from 'vite';
2
2
  import type http from 'http';
3
+ import fs from 'fs';
3
4
  import path from 'path';
4
- import { PKG_DIR } from '../shared/paths.js';
5
+ import { PKG_DIR, WORKSPACE_DIR } from '../shared/paths.js';
5
6
  import { log } from '../shared/logger.js';
6
7
  import { appendFrontendLog } from './frontend-log.js';
7
8
 
@@ -37,6 +38,19 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
37
38
  dashboard: supervisorPort + 2,
38
39
  };
39
40
 
41
+ // vite.config.ts keys the dep-prebundle cache dir by package version (.vite-v<version>) so
42
+ // each release re-optimizes exactly once. Sweep the previous releases' dirs (best-effort,
43
+ // async — recharts-sized prebundles are ~10 MB each and would otherwise accumulate forever).
44
+ try {
45
+ let current = 'dev';
46
+ try { current = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'dev'; } catch {}
47
+ const nmDir = path.join(WORKSPACE_DIR, 'client', 'node_modules');
48
+ for (const name of fs.readdirSync(nmDir)) {
49
+ if (name.startsWith('.vite-v') && name !== '.vite-v' + current) {
50
+ fs.rm(path.join(nmDir, name), { recursive: true, force: true }, () => {});
51
+ }
52
+ }
53
+ } catch { /* no node_modules yet / first boot — nothing to sweep */ }
40
54
 
41
55
  try {
42
56
  dashboardVite = await createViteServer({