bloby-bot 0.70.12 → 0.71.0

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 (65) hide show
  1. package/bin/cli.js +234 -48
  2. package/dist-bloby/assets/{bloby-DSNB0g4w.js → bloby-es6cZJzs.js} +6 -6
  3. package/dist-bloby/assets/globals-DBqwNiJV.css +2 -0
  4. package/dist-bloby/assets/{globals-B3cTbITX.js → globals-DN3F0CQE.js} +1 -1
  5. package/dist-bloby/assets/{highlighted-body-OFNGDK62-BLforpkr.js → highlighted-body-OFNGDK62-8PiOHw9p.js} +1 -1
  6. package/dist-bloby/assets/mermaid-GHXKKRXX-BJWX8urU.js +1 -0
  7. package/dist-bloby/assets/{onboard-Dn2Ws_G2.js → onboard-BKgy17OU.js} +1 -1
  8. package/dist-bloby/bloby.html +3 -3
  9. package/dist-bloby/onboard.html +3 -3
  10. package/package.json +3 -4
  11. package/scripts/install +156 -41
  12. package/scripts/install.ps1 +146 -29
  13. package/scripts/install.sh +156 -41
  14. package/shared/config.ts +37 -2
  15. package/shared/relay.ts +3 -1
  16. package/supervisor/channels/manager.ts +84 -44
  17. package/supervisor/channels/telegram.ts +57 -16
  18. package/supervisor/channels/types.ts +4 -1
  19. package/supervisor/channels/whatsapp.ts +57 -10
  20. package/supervisor/chat/OnboardWizard.tsx +0 -15
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +1 -1
  22. package/supervisor/chat/src/components/Chat/AuthedImage.tsx +16 -3
  23. package/supervisor/chat/src/components/Chat/BlobyImageCard.tsx +2 -2
  24. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +25 -8
  25. package/supervisor/chat/src/components/Chat/InputBar.tsx +62 -7
  26. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +37 -18
  27. package/supervisor/chat/src/components/Chat/MessageList.tsx +3 -3
  28. package/supervisor/chat/src/hooks/useChat.ts +52 -0
  29. package/supervisor/chat/src/lib/authedFile.ts +24 -12
  30. package/supervisor/file-saver.ts +92 -19
  31. package/supervisor/harnesses/attachment-policy.ts +111 -0
  32. package/supervisor/harnesses/claude.ts +62 -15
  33. package/supervisor/harnesses/codex.ts +69 -43
  34. package/supervisor/harnesses/pi/index.ts +367 -112
  35. package/supervisor/harnesses/pi/providers/humanize-error.ts +27 -2
  36. package/supervisor/harnesses/pi/providers/retry.ts +31 -0
  37. package/supervisor/harnesses/pi/providers/stream-anthropic.ts +31 -3
  38. package/supervisor/harnesses/pi/providers/stream-google.ts +26 -3
  39. package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +32 -9
  40. package/supervisor/harnesses/pi/providers/types.ts +29 -1
  41. package/supervisor/harnesses/pi/session.ts +143 -3
  42. package/supervisor/harnesses/pi/test-completion.ts +56 -0
  43. package/supervisor/harnesses/pi/tools/bash.ts +198 -22
  44. package/supervisor/harnesses/pi/tools/glob.ts +79 -0
  45. package/supervisor/harnesses/pi/tools/grep.ts +0 -0
  46. package/supervisor/harnesses/pi/tools/registry.ts +18 -6
  47. package/supervisor/harnesses/pi/tools/todo-write.ts +45 -0
  48. package/supervisor/harnesses/pi/tools/web-fetch.ts +129 -0
  49. package/supervisor/index.ts +93 -18
  50. package/supervisor/widget.js +19 -5
  51. package/worker/db.ts +2 -0
  52. package/worker/index.ts +18 -1
  53. package/worker/prompts/bloby-system-prompt-codex.txt +1 -1
  54. package/worker/prompts/bloby-system-prompt-pi.txt +6 -24
  55. package/worker/prompts/bloby-system-prompt.txt +1 -1
  56. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +4 -117
  57. package/workspace/client/src/components/Dashboard/deleteme_placeholders.tsx +194 -0
  58. package/workspace/client/src/components/Layout/Sidebar.tsx +52 -30
  59. package/workspace/client/src/components/deleteme_onboarding/WorkspaceTour.tsx +25 -15
  60. package/workspace/client/src/components/deleteme_onboarding/tour-theme.css +24 -0
  61. package/workspace/skills/mac/SKILL.md +13 -4
  62. package/dist-bloby/assets/globals-DyeW509Y.css +0 -2
  63. package/dist-bloby/assets/mermaid-GHXKKRXX-C1H_fSCU.js +0 -1
  64. package/supervisor/public/headphones_spritesheet.webp +0 -0
  65. package/supervisor/public/spritesheet.webp +0 -0
@@ -23,7 +23,8 @@ import {
23
23
  warmUpForLiveConversation,
24
24
  type RecentMessage,
25
25
  } from './bloby-agent.js';
26
- import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
26
+ import { ensureFileDirs, saveAttachment, saveAudio, MAX_ATTACHMENTS_PER_MESSAGE, MAX_TOTAL_ATTACHMENT_BYTES, type SavedFile } from './file-saver.js';
27
+ import { approxBase64Bytes } from './harnesses/attachment-policy.js';
27
28
  import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
28
29
  import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRunISO, describeCron } from './scheduler.js';
29
30
  import { execSync, spawn as cpSpawn } from 'child_process';
@@ -2588,9 +2589,15 @@ ${alreadyLinked ? '' : `
2588
2589
  }
2589
2590
 
2590
2591
  let savedFiles: SavedFile[] = [];
2592
+ // Bounded subset that saved within the caps — handed to the harness so the
2593
+ // model sees exactly what's persisted/shown (parity with the PWA path).
2594
+ const acceptedAttachments: any[] = [];
2591
2595
  if (Array.isArray(body.attachments) && body.attachments.length) {
2592
- for (const att of body.attachments) {
2593
- try { savedFiles.push(saveAttachment(att)); }
2596
+ let totalBytes = 0;
2597
+ for (const att of body.attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE)) {
2598
+ totalBytes += approxBase64Bytes(att?.data || '');
2599
+ if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { log.warn(`[workspace-chat] attachment total exceeds cap — dropping remaining`); break; }
2600
+ try { savedFiles.push(saveAttachment(att)); acceptedAttachments.push(att); }
2594
2601
  catch (err: any) { log.warn(`[workspace-chat] attachment save: ${err.message}`); }
2595
2602
  }
2596
2603
  }
@@ -2670,9 +2677,7 @@ ${alreadyLinked ? '' : `
2670
2677
  );
2671
2678
  }
2672
2679
 
2673
- const agentAttachments = Array.isArray(body.attachments) && body.attachments.length
2674
- ? body.attachments
2675
- : undefined;
2680
+ const agentAttachments = acceptedAttachments.length ? acceptedAttachments : undefined;
2676
2681
 
2677
2682
  // Mirror to WhatsApp self-chat if connected (same behaviour as the widget).
2678
2683
  const waStatus = channelManager.getStatus('whatsapp');
@@ -3051,11 +3056,16 @@ ${alreadyLinked ? '' : `
3051
3056
  req.pipe(proxy);
3052
3057
  });
3053
3058
 
3059
+ // Bound WS frames so a single message can't stream an unbounded blob into memory
3060
+ // (ws default is ~100MiB). Sized to comfortably hold the per-message attachment caps
3061
+ // (MAX_TOTAL_ATTACHMENT_BYTES of decoded bytes ≈ 1.33× as base64) plus JSON overhead.
3062
+ const WS_MAX_PAYLOAD = 80 * 1024 * 1024;
3063
+
3054
3064
  // WebSocket: Bloby chat + proxy worker WS
3055
- const blobyWss = new WebSocketServer({ noServer: true });
3065
+ const blobyWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
3056
3066
 
3057
3067
  // WebSocket: App API proxy (routes /app/api calls through WS to avoid tunnel POST issues)
3058
- const appWss = new WebSocketServer({ noServer: true });
3068
+ const appWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
3059
3069
 
3060
3070
  appWss.on('connection', (ws) => {
3061
3071
  // An 'error' event with no listener is rethrown by Node as an uncaught exception,
@@ -3536,8 +3546,14 @@ ${alreadyLinked ? '' : `
3536
3546
  // New protocol: { type: 'user:message', data: { content, conversationId? } }
3537
3547
  if (msg.type === 'user:message') {
3538
3548
  const data = msg.data || {};
3539
- const content = data.content;
3540
- if (!content) return;
3549
+ const hasAtts = Array.isArray(data.attachments) && data.attachments.length > 0;
3550
+ const hasAudio = typeof data.audioData === 'string' && data.audioData.length > 0;
3551
+ // Allow attachment-only / voice-only turns (a pasted image with no caption, or a
3552
+ // voice note whose transcription came back empty) — previously these were dropped,
3553
+ // leaving a ghost optimistic bubble. Fall back to a placeholder so title/prompt work.
3554
+ let content: string = typeof data.content === 'string' ? data.content : '';
3555
+ if (!content.trim() && !hasAtts && !hasAudio) return;
3556
+ if (!content.trim()) content = hasAtts ? '(attached files)' : '(voice message)';
3541
3557
  // Note: we intentionally ignore data.conversationId from the client.
3542
3558
  // The server is the authority on which DB conversation this WS belongs to —
3543
3559
  // honoring a client-supplied id let stale browser state drive messages into
@@ -3560,17 +3576,38 @@ ${alreadyLinked ? '' : `
3560
3576
  if (freshConfig.ai.provider === 'anthropic' || freshConfig.ai.provider === 'openai' || freshConfig.ai.provider === 'pi') {
3561
3577
  // Server-side persistence: create or reuse DB conversation, save user message
3562
3578
  (async () => {
3563
- // Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below)
3579
+ // Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below).
3580
+ // saveAttachment enforces a per-file cap + content sniff; we additionally bound count + total.
3564
3581
  let savedFiles: SavedFile[] = [];
3565
- if (data.attachments?.length) {
3566
- for (const att of data.attachments) {
3582
+ // The bounded subset of raw attachments that actually saved within the caps —
3583
+ // this (not the full client array) is what the harness inlines, so the model
3584
+ // sees exactly what gets persisted + shown in chat (no over-cap divergence).
3585
+ const acceptedAttachments: any[] = [];
3586
+ if (hasAtts) {
3587
+ let totalBytes = 0;
3588
+ for (const att of data.attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE)) {
3589
+ totalBytes += approxBase64Bytes(att?.data || '');
3590
+ if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) {
3591
+ log.warn(`[bloby] attachment total exceeds cap — dropping remaining`);
3592
+ break;
3593
+ }
3567
3594
  try {
3568
3595
  savedFiles.push(saveAttachment(att));
3596
+ acceptedAttachments.push(att);
3569
3597
  } catch (err: any) {
3570
3598
  log.warn(`[bloby] File save error: ${err.message}`);
3571
3599
  }
3572
3600
  }
3573
3601
  }
3602
+ // Persist the voice clip (if any) so the chat can replay it after a refresh.
3603
+ let savedAudioPath: string | undefined;
3604
+ if (hasAudio) {
3605
+ try {
3606
+ savedAudioPath = saveAudio(data.audioData).relPath;
3607
+ } catch (err: any) {
3608
+ log.warn(`[bloby] Audio save error: ${err.message}`);
3609
+ }
3610
+ }
3574
3611
 
3575
3612
  try {
3576
3613
  // Check if we have an existing conversation for this client
@@ -3594,18 +3631,19 @@ ${alreadyLinked ? '' : `
3594
3631
  }
3595
3632
  convId = dbConvId!;
3596
3633
 
3597
- // Save user message to DB (include attachment metadata as JSON string)
3634
+ // Save user message to DB (include attachment + audio metadata as JSON string)
3598
3635
  const meta: any = { model: freshConfig.ai.model };
3599
3636
  if (savedFiles.length) {
3600
3637
  meta.attachments = JSON.stringify(savedFiles.map((f) => ({
3601
3638
  type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
3602
3639
  })));
3603
3640
  }
3641
+ if (savedAudioPath) meta.audio_data = savedAudioPath;
3604
3642
  await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
3605
3643
  role: 'user', content, meta,
3606
3644
  });
3607
3645
 
3608
- // Broadcast user message to other clients (include saved attachment metadata)
3646
+ // Broadcast user message to other clients (include saved attachment + audio metadata)
3609
3647
  broadcastBlobyExcept(ws, 'chat:sync', {
3610
3648
  conversationId: convId,
3611
3649
  message: {
@@ -3615,6 +3653,9 @@ ${alreadyLinked ? '' : `
3615
3653
  attachments: savedFiles.length
3616
3654
  ? savedFiles.map((f) => ({ type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath }))
3617
3655
  : undefined,
3656
+ // snake_case to match the client reader (useChat chat:sync handler),
3657
+ // the persisted meta.audio_data, and the DB-reload loader — one canonical name.
3658
+ audio_data: savedAudioPath,
3618
3659
  },
3619
3660
  });
3620
3661
  } catch (err: any) {
@@ -3686,7 +3727,7 @@ ${alreadyLinked ? '' : `
3686
3727
  convId,
3687
3728
  { surface: 'chat', waSendTo: waMirrorTo, isSelfChat: true },
3688
3729
  alreadyTagged ? content : `[PWA]\n${content}`,
3689
- data.attachments,
3730
+ acceptedAttachments.length ? acceptedAttachments : undefined,
3690
3731
  savedFiles,
3691
3732
  );
3692
3733
  })();
@@ -4311,6 +4352,23 @@ ${alreadyLinked ? '' : `
4311
4352
  log.warn('Local server readiness probe timed out');
4312
4353
  }
4313
4354
 
4355
+ // Register the (stable) named-tunnel URL with the relay and start heartbeats —
4356
+ // mirrors the quick branch. Without this, a bot that holds a relay handle but runs
4357
+ // a named tunnel is never marked online: its <user>.bloby.bot handle goes stale and
4358
+ // 503s on both page loads and WS, even though the named domain itself works.
4359
+ if (config.relay?.token) {
4360
+ try {
4361
+ await updateTunnelUrl(config.relay.token, tunnelUrl);
4362
+ startHeartbeat(config.relay.token, tunnelUrl);
4363
+ if (config.relay.url) {
4364
+ log.ok(`Relay: ${config.relay.url}`);
4365
+ console.log(`__RELAY_URL__=${config.relay.url}`);
4366
+ }
4367
+ } catch (err) {
4368
+ log.warn(`Relay: ${err instanceof Error ? err.message : err}`);
4369
+ }
4370
+ }
4371
+
4314
4372
  console.log('__READY__');
4315
4373
  } catch (err) {
4316
4374
  log.warn(`Named tunnel: ${err instanceof Error ? err.message : err}`);
@@ -4331,14 +4389,31 @@ ${alreadyLinked ? '' : `
4331
4389
  lastTick = now; // Update immediately so concurrent ticks don't see a stale gap
4332
4390
 
4333
4391
  if (wakeGap || periodicCheck) {
4334
- const alive = await isTunnelAlive(tunnelUrl!, config.port);
4392
+ // A wake-gap (a tick delayed >60s) means the host was suspended — laptop/Mac slept.
4393
+ // For a QUICK tunnel, cloudflared almost always survives suspension as a live process
4394
+ // while its *.trycloudflare.com edge hostname has already been reclaimed. isTunnelAlive()
4395
+ // only checks process liveness + localhost health (never the public URL), so it returns
4396
+ // a false positive and the bot would stay unreachable with no self-heal. So on a wake-gap
4397
+ // we force a fresh quick-tunnel rotation instead of trusting the local probe. Named
4398
+ // tunnels keep a stable URL and auto-reconnect, so they take the normal liveness path;
4399
+ // the routine periodicCheck also stays on isTunnelAlive (don't churn the URL every 5min).
4400
+ const forceQuickRotation = wakeGap && config.tunnel.mode === 'quick';
4401
+ const alive = forceQuickRotation ? false : await isTunnelAlive(tunnelUrl!, config.port);
4335
4402
  if (!alive) {
4336
- log.warn('Tunnel dead, restarting...');
4403
+ log.warn(forceQuickRotation
4404
+ ? 'Wake detected — rotating quick tunnel (edge hostname likely stale)...'
4405
+ : 'Tunnel dead, restarting...');
4337
4406
  try {
4338
4407
  if (config.tunnel.mode === 'named') {
4339
4408
  // Named tunnel: restart process, URL doesn't change
4340
4409
  await restartNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
4341
4410
  log.ok(`Named tunnel restored: ${tunnelUrl}`);
4411
+ // Re-assert online with the relay after the restart (URL is stable; the
4412
+ // heartbeat keeps running, but this immediately clears any stale offline flag).
4413
+ const latestCfg = loadConfig();
4414
+ if (latestCfg.relay?.token) {
4415
+ await updateTunnelUrl(latestCfg.relay.token, tunnelUrl!);
4416
+ }
4342
4417
  } else {
4343
4418
  // Quick tunnel: restart and get new URL
4344
4419
  const newUrl = await restartTunnel(config.port);
@@ -173,9 +173,20 @@
173
173
  function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t + 2, 3) / 2; }
174
174
 
175
175
  // ── Splash guard ──
176
+ // The travel splash plays exactly ONCE per device, ever. A device counts as "already saw it"
177
+ // when EITHER the persisted flag is set OR a portal token is saved — the latter means the user
178
+ // has logged in here before (i.e. "the password is saved"), so this is a returning device. We
179
+ // use localStorage (not sessionStorage) so the flag survives PWA relaunches, and reading the
180
+ // token (same shared key as chat/src/lib/auth.ts, same origin) also stops existing returning
181
+ // devices from replaying the splash once this change ships.
182
+ // Caveat: storage eviction (iOS/Safari ITP 7-day cap on non-installed PWAs, "clear website
183
+ // data", low-disk, private mode) can purge both keys together — a rare, legitimate-looking
184
+ // "fresh device" replay, not a regression. Installed/standalone PWAs are largely exempt.
176
185
  var SPLASH_KEY = 'bloby_splash_played';
186
+ var TOKEN_KEY = 'bloby_token';
177
187
  var splashSeen = false;
178
- try { splashSeen = sessionStorage.getItem(SPLASH_KEY) === '1'; } catch(e) {}
188
+ try { splashSeen = localStorage.getItem(SPLASH_KEY) === '1' || !!localStorage.getItem(TOKEN_KEY); } catch(e) {}
189
+ function markSplashPlayed() { try { localStorage.setItem(SPLASH_KEY, '1'); } catch(e) {} }
179
190
 
180
191
  // ── Canvas setup ──
181
192
  var canvas = document.createElement('canvas');
@@ -535,7 +546,7 @@
535
546
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
536
547
  hpState = 'idle';
537
548
  hpFrame = HP_IDLE_FRAME;
538
- try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
549
+ markSplashPlayed();
539
550
  pmark('widget:bubble');
540
551
  loadHpIdle(); // full sheet defers to the first long-press (beginHpAnimation loads it)
541
552
  if (hideAfterTransition) canvas.style.display = 'none';
@@ -695,6 +706,7 @@
695
706
  if (!appReady || animState !== 'idle' || canvasPhase !== 'splash') return;
696
707
  var elapsed = Date.now() - canvasCreatedAt;
697
708
  if (elapsed < MIN_SPLASH_MS) { setTimeout(maybeTransition, MIN_SPLASH_MS - elapsed); return; }
709
+ markSplashPlayed(); // the travel is about to start — mark now so a mid-travel quit never replays it
698
710
  canvasPhase = 'transitioning';
699
711
  var tx = W - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
700
712
  var ty = H - BUBBLE_MARGIN - BUBBLE_SIZE / 2;
@@ -723,8 +735,8 @@
723
735
  var onboardActive = false;
724
736
 
725
737
  // ── Boot sequence ─────────────────────────────────────────────────
726
- // Warm refresh (splash already played this session — known synchronously from
727
- // sessionStorage): jump straight to bubble mode with the tiny idle frame. No settings RTT,
738
+ // Warm boot (splash already played on this device, or a portal token is saved — known
739
+ // synchronously from localStorage): jump straight to bubble mode with the tiny idle frame. No settings RTT,
728
740
  // no 500 KB splash sheet — the old code downloaded the full sheet behind TWO serial fetches
729
741
  // (settings → sprite config → sheet) just to skip past it.
730
742
  var loopStarted = false;
@@ -737,6 +749,7 @@
737
749
  if (splashSeen) {
738
750
  skipSplash = true;
739
751
  skipToBubble();
752
+ markSplashPlayed(); // persist "seen" even when it was inferred only from the saved token, so a later logout/expiry can't resurface the splash
740
753
  startLoop();
741
754
  }
742
755
 
@@ -747,6 +760,7 @@
747
760
  if (splashSeen || !settingsDone || spriteOutcome === 'pending') return;
748
761
  if (spriteOutcome === 'fail') { canvas.style.display = 'none'; canvasPhase = 'disabled'; return; }
749
762
  if (skipSplash) { skipToBubble(); if (onboardActive) canvas.style.display = 'none'; }
763
+ else markSplashPlayed(); // committed to the travel splash (idle frame is already on screen) — mark before app-ready so a quit during the hold can't replay it. Sprite-fail returned above (no mark), and onboarding takes the skip branch (deferred to the post-onboarding launch).
750
764
  startLoop();
751
765
  maybeTransition();
752
766
  }
@@ -785,7 +799,7 @@
785
799
  function toggle() {
786
800
  if (canvasPhase === 'splash' || canvasPhase === 'transitioning') {
787
801
  skipToBubble();
788
- try { sessionStorage.setItem(SPLASH_KEY, '1'); } catch(e) {}
802
+ markSplashPlayed();
789
803
  }
790
804
  if (canvasPhase !== 'bubble') return;
791
805
  // If headphones animation is running, reset to idle
package/worker/db.ts CHANGED
@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS messages (
19
19
  tokens_in INTEGER,
20
20
  tokens_out INTEGER,
21
21
  model TEXT,
22
+ audio_data TEXT,
23
+ attachments TEXT,
22
24
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
23
25
  );
24
26
  CREATE INDEX IF NOT EXISTS idx_msg_conv ON messages(conversation_id, created_at);
package/worker/index.ts CHANGED
@@ -17,7 +17,7 @@ import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAc
17
17
  import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
18
18
  import { ensureFileDirs } from '../supervisor/file-saver.js';
19
19
  import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js';
20
- import { runPiTestCompletion } from '../supervisor/harnesses/pi/test-completion.js';
20
+ import { runPiTestCompletion, runPiStreamProbe } from '../supervisor/harnesses/pi/test-completion.js';
21
21
  import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js';
22
22
 
23
23
  // ── Password hashing (scrypt) ──
@@ -284,6 +284,23 @@ app.post('/api/auth/pi/test', async (req, res) => {
284
284
  }
285
285
  const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.';
286
286
  const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt });
287
+ if (!result.ok) {
288
+ res.json(result);
289
+ return;
290
+ }
291
+ // Second tier (audit C-4): real turns stream SSE with the full tool schema —
292
+ // free-form model ids (Ollama/LM Studio/custom/OpenRouter) can pass the basic
293
+ // call and then fail the first actual message. Probe the real wire shape so
294
+ // the wizard's green check means chat will actually work.
295
+ const probe = await runPiStreamProbe({ subProvider, apiKey, baseUrl, modelId, prompt });
296
+ if (!probe.ok) {
297
+ res.json({
298
+ ...probe,
299
+ ok: false,
300
+ error: `The endpoint responds, but streaming with tools failed (live chat would break): ${probe.error}`,
301
+ });
302
+ return;
303
+ }
287
304
  res.json(result);
288
305
  });
289
306
 
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
280
280
  You can communicate through several surfaces at once. The two built-in ones are:
281
281
 
282
282
  - **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
283
- - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. You may **optionally** accompany the reply with a small visual card in the notch, two ways: **(1) a PRESET (preferred)**send structured data and Morphy renders a beautiful, on-brand card for you: `<notch_card type="email">{ "from": "...", "subject": "...", "time": "...", "body": "..." }</notch_card>`. Preset types: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison` (full schemas in `skills/mac/presets/PRESETS.md`). The body is one JSON object; you never write CSS. **(2) CUSTOM (escape hatch)** for a bespoke *visual layout* you hand-build with real markup, use `<notch_html>…</notch_html>` (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`). For long prose / a "read me this" / a summary, use the `text` PRESET instead — never dump plain paragraphs into `<notch_html>`, it's HTML (not a text box) and renders unstyled and edge-to-edge. Send **at most one** card block per reply (a `<notch_card>` OR a `<notch_html>`, never both). Both tags are stripped from TTS, so card contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
283
+ - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
284
284
  - **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
285
285
 
286
286
  Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
@@ -218,7 +218,7 @@ You handle two kinds of work differently:
218
218
 
219
219
  **Quick tasks — do them yourself directly (use your tools):**
220
220
  - Memory file writes (MYSELF.md, MYHUMAN.md, MEMORY.md, daily notes)
221
- - Config edits (PULSE.json, CRONS.json, MCP.json)
221
+ - Config edits (PULSE.json, CRONS.json)
222
222
  - Channel configuration (curl commands)
223
223
  - Simple file reads or status checks
224
224
  - Conversational responses, chitchat, questions
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
280
280
  You can communicate through several surfaces at once. The two built-in ones are:
281
281
 
282
282
  - **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
283
- - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. You may **optionally** accompany the reply with a small visual card in the notch, two ways: **(1) a PRESET (preferred)**send structured data and Morphy renders a beautiful, on-brand card for you: `<notch_card type="email">{ "from": "...", "subject": "...", "time": "...", "body": "..." }</notch_card>`. Preset types: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison` (full schemas in `skills/mac/presets/PRESETS.md`). The body is one JSON object; you never write CSS. **(2) CUSTOM (escape hatch)** for a bespoke *visual layout* you hand-build with real markup, use `<notch_html>…</notch_html>` (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`). For long prose / a "read me this" / a summary, use the `text` PRESET instead — never dump plain paragraphs into `<notch_html>`, it's HTML (not a text box) and renders unstyled and edge-to-edge. Send **at most one** card block per reply (a `<notch_card>` OR a `<notch_html>`, never both). Both tags are stripped from TTS, so card contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
283
+ - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
284
284
  - **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
285
285
 
286
286
  Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
@@ -615,27 +615,9 @@ It restarts the backend and BLOCKS until the port is healthy, then returns `{"ok
615
615
 
616
616
  ## MCP Servers (Model Context Protocol)
617
617
 
618
- You can connect to external tools via MCP servers. These give you capabilities beyond your built-in toolslike controlling a browser, querying databases, or interacting with third-party APIs.
618
+ MCP servers are NOT yet supported on this provider. The `MCP.json` config file exists for other harnesses, but on this one its entries are inert no MCP tools will appear, no matter what is configured there.
619
619
 
620
- **Config file:** `MCP.json` (in your workspace root)
621
-
622
- ```json
623
- {
624
- "server-name": {
625
- "command": "npx",
626
- "args": ["-y", "@some/mcp-server"],
627
- "env": {}
628
- }
629
- }
630
- ```
631
-
632
- The file is a JSON object where each key is a server name and the value has `command`, optional `args`, and optional `env`. Use `-y` in npx args to skip install prompts. The config is read fresh on every turn — add, remove, or edit entries anytime.
633
-
634
- **Your human can ask you to add MCP servers.** When they do, read `MCP.json` (create it if missing), add the new server entry, and write it back. Common examples:
635
- - **Playwright** (browser control): `"playwright": { "command": "npx", "args": ["-y", "@playwright/mcp@latest", "--headless", "--browser", "chromium"] }`
636
- - **Fetch** (HTTP requests): `"fetch": { "command": "npx", "args": ["-y", "@anthropic-ai/mcp-fetch@latest"] }`
637
-
638
- When an MCP server is configured, its tools appear alongside your built-in tools. Use them naturally — no special syntax needed.
620
+ If your human asks for a capability that usually comes from an MCP server (browser control, third-party APIs, databases), be honest that MCP isn't available here, then offer the closest alternative with your real tools — for example a CLI via Bash (`npx playwright`, `curl`, a database client) or WebFetch for HTTP APIs. Never claim an MCP tool worked.
639
621
 
640
622
  ## Sacred Files — NEVER Modify
641
623
  - `supervisor/` — chat UI, proxy, process management
@@ -684,7 +666,7 @@ Only redesign the workspace layout if your human explicitly asks you to. Otherwi
684
666
 
685
667
  **Be genuinely helpful, not performatively helpful.** Skip the filler. Actions speak louder than words. Just help.
686
668
 
687
- **Be resourceful.** Before asking your human a question, try to answer it yourself. Read the files. Check the code. Search the web. Come back with answers, not questions. Ask when you're genuinely stuck, not when you're being lazy.
669
+ **Be resourceful.** Before asking your human a question, try to answer it yourself. Read the files. Check the code. Fetch docs and pages with WebFetch. Come back with answers, not questions. Ask when you're genuinely stuck, not when you're being lazy.
688
670
 
689
671
  **Have opinions.** You're allowed to disagree, to have preferences, to think something is a bad idea. An agent with no opinions is just autocomplete. If your human asks "what do you think?" — actually think.
690
672
 
@@ -700,7 +682,7 @@ Only redesign the workspace layout if your human explicitly asks you to. Otherwi
700
682
 
701
683
  **Safe to do freely (internal):**
702
684
  - Read files, explore, organize, learn
703
- - Search the web, check documentation
685
+ - Fetch documentation and web pages with WebFetch (you have no search engine — derive likely URLs or ask for one)
704
686
  - Work within the workspace
705
687
  - Write and update your own memory files
706
688
 
@@ -280,7 +280,7 @@ If your human asks you to update a skill's behavior, edit the files INSIDE `skil
280
280
  You can communicate through several surfaces at once. The two built-in ones are:
281
281
 
282
282
  - **`[PWA]`** — the chat bubble in the dashboard (web app / PWA). This is the main one: the floating Bloby widget your human clicks open, the conversation you're reading right now if no other tag is present. Treat it as your home base.
283
- - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. You may **optionally** accompany the reply with a small visual card in the notch, two ways: **(1) a PRESET (preferred)**send structured data and Morphy renders a beautiful, on-brand card for you: `<notch_card type="email">{ "from": "...", "subject": "...", "time": "...", "body": "..." }</notch_card>`. Preset types: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison` (full schemas in `skills/mac/presets/PRESETS.md`). The body is one JSON object; you never write CSS. **(2) CUSTOM (escape hatch)** for a bespoke *visual layout* you hand-build with real markup, use `<notch_html>…</notch_html>` (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`). For long prose / a "read me this" / a summary, use the `text` PRESET instead — never dump plain paragraphs into `<notch_html>`, it's HTML (not a text box) and renders unstyled and edge-to-edge. Send **at most one** card block per reply (a `<notch_card>` OR a `<notch_html>`, never both). Both tags are stripped from TTS, so card contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
283
+ - **`[Mac]`** — the Morphy native Mac app living in the MacBook notch. Your human held a hotkey, spoke, and Morphy sent the transcript here. Screenshots of their screen may be attached. Keep replies concise — they'll be spoken aloud via TTS. No markdown, no bullet lists — plain spoken sentences only. **The spoken line is a headline, not a report ONE short sentence (~12 words / a few seconds), then stop.** Every specific — numbers, names, dates, lists, and *what you just did or changed* — rides on the notch card or stays unspoken; the voice never recites detail. **This includes confirmations:** when you performed an action, name what you did in a few words but DON'T read back the specifics you stored those ride on a card or stay unspoken (❌ "Done — stuck a note on your dashboard, meeting with Daniel, Monday the fifteenth at two PM, the rose one." → ✓ "Done, Bruno. Sticky note's on your dashboard."). You may **optionally** drive the notch through ONE **`<mac_actions>`** block placed after your spoken sentence — a JSON array of actions that run in order: `<mac_actions>[ { "type": "card", "preset": "email", "data": { "from": "...", "subject": "...", "time": "...", "body": "..." } } ]</mac_actions>`. Action `type`s: **`card`** — a preset card in the notch, `{ "type": "card", "preset": "<name>", "data": { … } }` (preset names: `email`, `list`, `calendar`, `weather`, `ticker`, `stat`, `info`, `text`, `comparison`; full schemas in `skills/mac/presets/PRESETS.md`; you send structured data, never CSS); **`point`** flies the mascot to a screenshot pixel, `{ "type": "point", "x", "y", "label"?, "screen"? }`; **`spotlight`** — dims the display and cuts a glowing hole over a spot, `{ "type": "spotlight", "x", "y", "r"?, "label"?, "screen"? }`. Coordinates are pixels read off **this turn's screenshot** (top-left origin); `screen` is 1-based for multi-display. For a **custom card** no preset fits, hand-write real markup in a separate **`<notch_html>…</notch_html>`** tag (lowercase, exactly that spelling — NOT `<NotchHTML>`, NOT `<notch>`); for long prose / a "read me this" / a summary use the `text` preset instead — never dump plain paragraphs into `<notch_html>`, it renders unstyled and edge-to-edge. Send **one** `<mac_actions>` block (it may carry several actions) and **at most one** card (a `card` action OR a `<notch_html>`, never both). Every block is stripped from TTS, so card/action contents are **never** spoken. Canvas is **fixed 383 × 147 pt, transparent over BLACK**; custom cards use light/white text, no external assets (no `<img src>`, no fonts, no JS network) and no interactive elements (clicks do nothing) — though long content IS scrollable, the user scrolls it with the trackpad, so letting content overflow is fine. **CRITICAL: never speak the contents of your card.** If the card carries a list / calendar / email / news / comparison / any structured answer, the spoken text MUST be a short lead-in ONLY ("Here are today's top five, Bruno.") and then stop — do not recite the items, the human is already looking at them. If the answer fits in one spoken sentence, send NO card. Voice and card are complementary, never redundant — pick which one carries the detail per reply, and let the other be brief or absent. When in doubt, **open `skills/mac/SKILL.md` and follow it** — it has the preset catalog, examples (including the canonical bad/good comparison of the speak-the-card duplication failure), custom templates under `skills/mac/frequentSnippets/`, and a pre-send checklist. **Mis-spelling a tag means the markup reaches TTS and gets spoken at the human** — that's a visible failure, not a silent one.
284
284
  - **`[workspace]`** — a chat-shaped widget your human placed somewhere inside their dashboard *workspace*. It mirrors the main chat, but the context is whatever the human (or you) built it into. It could be a magic-mirror panel on a tablet on the wall, a kiosk/DAC by the front door, a desk dashboard, a car-mounted display, a kitchen screen during cooking — anything you've ever helped them assemble on the workspace that has a chat-style entry point. **Check `MEMORY.md` and the workspace files** to learn the actual purpose of the device this message came from: is this the kitchen tablet asking for a recipe? The hallway mirror asking what's on today's schedule? The garage panel asking about the car? Tailor tone, brevity, and content to that role. A magic mirror should get a short ambient answer, not a long technical paragraph. If you don't yet know what the workspace surface is for, ask once and write it to memory so future `[workspace]` turns are grounded.
285
285
 
286
286
  Beyond those, your human can install additional channels (WhatsApp, Telegram, Discord, Alexa…) as **skills** from the Bloby Marketplace. Each channel skill teaches you the conventions for that surface.
@@ -1,23 +1,6 @@
1
- import { Search, Mail, DollarSign, TrendingUp } from 'lucide-react';
2
- import { AreaChart, Area, BarChart, Bar, ResponsiveContainer } from 'recharts';
1
+ import { PlaceholderWidgets } from './deleteme_placeholders';
3
2
 
4
3
  const GRADIENT = 'linear-gradient(to right, #0166FF 10%, #009AFE 55%, #4AEEFF 100%)';
5
- const CARD = 'relative rounded-xl overflow-hidden';
6
- const BORDER = 'absolute inset-0 rounded-xl bg-gradient-to-b from-white/[0.08] via-white/[0.02] to-transparent pointer-events-none';
7
- const INNER = 'relative rounded-xl bg-[#141414] m-px p-3.5 h-full';
8
-
9
- const rev = [{ v: 82 }, { v: 89 }, { v: 94 }, { v: 101 }, { v: 108 }, { v: 112 }, { v: 125 }];
10
- const fol = [{ v: 12 }, { v: 18 }, { v: 9 }, { v: 24 }, { v: 31 }, { v: 19 }, { v: 27 }];
11
-
12
- function StripeSvg() {
13
- return <svg className="h-3.5 w-3.5 text-[#635BFF]" viewBox="0 0 24 24" fill="currentColor"><path d="M13.976 9.15c-2.172-.806-3.356-1.426-3.356-2.409 0-.831.683-1.305 1.901-1.305 2.227 0 4.515.858 6.09 1.631l.89-5.494C18.252.975 15.697 0 12.165 0 9.667 0 7.589.654 6.104 1.872 4.56 3.147 3.757 4.992 3.757 7.218c0 4.039 2.467 5.76 6.476 7.219 2.585.92 3.445 1.574 3.445 2.583 0 .98-.84 1.545-2.354 1.545-1.875 0-4.965-.921-6.99-2.109l-.9 5.555C5.175 22.99 8.385 24 11.714 24c2.641 0 4.843-.624 6.328-1.813 1.664-1.305 2.525-3.236 2.525-5.732 0-4.128-2.524-5.851-6.591-7.305z" /></svg>;
14
- }
15
- function GmailSvg() {
16
- return <svg className="h-3.5 w-3.5 text-[#EA4335]" viewBox="0 0 24 24" fill="currentColor"><path d="M24 5.457v13.909c0 .904-.732 1.636-1.636 1.636h-3.819V11.73L12 16.64l-6.545-4.91v9.273H1.636A1.636 1.636 0 0 1 0 19.366V5.457c0-2.023 2.309-3.178 3.927-1.964L5.455 4.64 12 9.548l6.545-4.91 1.528-1.145C21.69 2.28 24 3.434 24 5.457z" /></svg>;
17
- }
18
- function XSvg() {
19
- return <svg className="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="currentColor"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" /></svg>;
20
- }
21
4
 
22
5
  export default function DashboardPage() {
23
6
  return (
@@ -30,105 +13,9 @@ export default function DashboardPage() {
30
13
  </h1>
31
14
  <p className="text-muted-foreground text-xs mb-6">Your workspace at a glance.</p>
32
15
 
33
- <div className="grid grid-cols-3 gap-2.5">
34
-
35
- {/* Stripe 2 cols */}
36
- <div className={`${CARD} col-span-2`}>
37
- <div className={BORDER} />
38
- <div className={INNER}>
39
- <div className="flex items-center justify-between">
40
- <div className="flex items-center gap-2">
41
- <div className="h-7 w-7 rounded-lg bg-[#635BFF]/10 flex items-center justify-center"><StripeSvg /></div>
42
- <span className="text-xs font-bold">Stripe</span>
43
- </div>
44
- <div className="flex items-center gap-1 text-emerald-500">
45
- <TrendingUp className="h-3 w-3" />
46
- <span className="text-[10px] font-bold">+12.5%</span>
47
- </div>
48
- </div>
49
- <p className="text-2xl font-bold tracking-tight mt-2">$12,480</p>
50
- <p className="text-[10px] text-muted-foreground/50 mb-1">MRR</p>
51
- <div className="h-12 overflow-hidden">
52
- <ResponsiveContainer width="100%" height={48}>
53
- <AreaChart data={rev}>
54
- <defs>
55
- <linearGradient id="sg" x1="0" y1="0" x2="1" y2="0"><stop offset="0%" stopColor="#0166FF" /><stop offset="50%" stopColor="#009AFE" /><stop offset="100%" stopColor="#4AEEFF" /></linearGradient>
56
- <linearGradient id="sf" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#009AFE" stopOpacity={0.12} /><stop offset="100%" stopColor="#009AFE" stopOpacity={0} /></linearGradient>
57
- </defs>
58
- <Area type="monotone" dataKey="v" stroke="url(#sg)" strokeWidth={1.5} fill="url(#sf)" />
59
- </AreaChart>
60
- </ResponsiveContainer>
61
- </div>
62
- </div>
63
- </div>
64
-
65
- {/* X — 1 col */}
66
- <div className={`${CARD} col-span-1`}>
67
- <div className={BORDER} />
68
- <div className={INNER}>
69
- <div className="flex items-center gap-2 mb-2">
70
- <div className="h-7 w-7 rounded-lg bg-white/[0.06] flex items-center justify-center"><XSvg /></div>
71
- <span className="text-xs font-bold">X</span>
72
- </div>
73
- <p className="text-2xl font-bold tracking-tight">24.8K</p>
74
- <div className="flex items-center gap-1 text-emerald-500 mb-1">
75
- <TrendingUp className="h-2.5 w-2.5" />
76
- <span className="text-[10px] font-bold">+1.4K</span>
77
- </div>
78
- <div className="h-10 overflow-hidden">
79
- <ResponsiveContainer width="100%" height={40}>
80
- <BarChart data={fol} barCategoryGap="25%">
81
- <defs>
82
- <linearGradient id="xg" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stopColor="#009AFE" stopOpacity={0.3} /><stop offset="100%" stopColor="#0166FF" stopOpacity={0.05} /></linearGradient>
83
- </defs>
84
- <Bar dataKey="v" fill="url(#xg)" radius={[2, 2, 0, 0]} />
85
- </BarChart>
86
- </ResponsiveContainer>
87
- </div>
88
- </div>
89
- </div>
90
-
91
- {/* Gmail — 1 col */}
92
- <div className={`${CARD} col-span-1`}>
93
- <div className={BORDER} />
94
- <div className={INNER}>
95
- <div className="flex items-center gap-2 mb-2.5">
96
- <div className="h-7 w-7 rounded-lg bg-[#EA4335]/10 flex items-center justify-center"><GmailSvg /></div>
97
- <span className="text-xs font-bold">Gmail</span>
98
- <span className="ml-auto text-[10px] text-muted-foreground/50">3 new</span>
99
- </div>
100
- {['Sarah Chen', 'Stripe', 'Alex R.'].map((n) => (
101
- <div key={n} className="flex items-center gap-2 py-1.5">
102
- <div className="h-5 w-5 rounded-full bg-white/[0.06] text-[9px] font-bold flex items-center justify-center shrink-0">{n[0]}</div>
103
- <span className="text-[11px] truncate">{n}</span>
104
- </div>
105
- ))}
106
- </div>
107
- </div>
108
-
109
- {/* Research — 2 cols */}
110
- <div className={`${CARD} col-span-2`}>
111
- <div className={BORDER} />
112
- <div className={INNER}>
113
- <div className="flex items-center gap-2 mb-2.5">
114
- <div className="h-7 w-7 rounded-lg bg-[#9235F9]/10 flex items-center justify-center"><Search className="h-3.5 w-3.5 text-[#9235F9]" /></div>
115
- <span className="text-xs font-bold">Research</span>
116
- <span className="ml-auto text-[10px] font-bold text-muted-foreground bg-white/[0.06] px-2 py-0.5 rounded-full">3</span>
117
- </div>
118
- {[
119
- { t: 'Competitor pricing', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
120
- { t: 'Market trends Q1', s: 'Done', c: 'text-emerald-500 bg-emerald-500/10' },
121
- { t: 'User feedback', s: 'Review', c: 'text-orange-400 bg-orange-400/10' },
122
- ].map((r) => (
123
- <div key={r.t} className="flex items-center justify-between py-1.5">
124
- <span className="text-[11px]">{r.t}</span>
125
- <span className={`text-[9px] font-bold px-1.5 py-0.5 rounded-full ${r.c}`}>{r.s}</span>
126
- </div>
127
- ))}
128
- </div>
129
- </div>
130
-
131
- </div>
16
+ {/* ▼▼▼ EXAMPLE PLACEHOLDERS — safe to delete. See deleteme_placeholders.tsx. ▼▼▼ */}
17
+ <PlaceholderWidgets />
18
+ {/* ▲▲▲ EXAMPLE PLACEHOLDERS ▲▲▲ */}
132
19
  </div>
133
20
  );
134
21
  }