bloby-bot 0.50.3 → 0.51.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.50.3",
3
+ "version": "0.51.0",
4
4
  "releaseNotes": [
5
5
  "1. Something great..",
6
6
  "2. ",
@@ -46,6 +46,11 @@ const PLATFORM_ASSETS = new Set([
46
46
  '/manifest.json',
47
47
  ]);
48
48
 
49
+ // Directory-prefix platform assets — anything under these is served from supervisor/public/.
50
+ // Used for the Morphy animation set: drop new {clip}.png + {clip}.json into public/morphy/
51
+ // and they're automatically served without touching the allowlist.
52
+ const PLATFORM_ASSET_DIRS = ['/morphy/'];
53
+
49
54
  // Ensure dist-bloby exists (postinstall may have failed silently)
50
55
  if (!fs.existsSync(DIST_BLOBY)) {
51
56
  log.info('Building bloby chat UI (first run)...');
@@ -85,7 +90,7 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
85
90
  // JS/CSS modules → stale-while-revalidate
86
91
  // API, WebSocket, Vite internals → network-only (no cache)
87
92
 
88
- var CACHE = 'bloby-v14';
93
+ var CACHE = 'bloby-v15';
89
94
  var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
90
95
 
91
96
  // Precache the HTML shell on install so the cache is never empty.
@@ -1631,7 +1636,8 @@ mint();
1631
1636
 
1632
1637
  // Platform assets — served from supervisor/public/ so they survive workspace swaps
1633
1638
  const cleanUrl = (req.url || '').split('?')[0];
1634
- if (PLATFORM_ASSETS.has(cleanUrl)) {
1639
+ const inAssetDir = PLATFORM_ASSET_DIRS.some((d) => cleanUrl.startsWith(d) && !cleanUrl.includes('..'));
1640
+ if (PLATFORM_ASSETS.has(cleanUrl) || inAssetDir) {
1635
1641
  const assetPath = path.join(SUPERVISOR_PUBLIC, cleanUrl);
1636
1642
  try {
1637
1643
  const stat = fs.statSync(assetPath);
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "teleporting",
3
+ "spritesheet": "teleporting.png",
4
+ "frame": {
5
+ "w": 218,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 19,
17
+ "to": 19,
18
+ "mode": "hold"
19
+ },
20
+ "enter": {
21
+ "from": 20,
22
+ "to": 45,
23
+ "mode": "forward",
24
+ "next": "active"
25
+ },
26
+ "active": {
27
+ "from": 46,
28
+ "to": 68,
29
+ "mode": "pingpong"
30
+ },
31
+ "exit": {
32
+ "from": 69,
33
+ "to": 121,
34
+ "mode": "forward",
35
+ "next": "idle"
36
+ }
37
+ }
38
+ }
@@ -33,24 +33,34 @@
33
33
 
34
34
  // ══════════════════════════════════════════════════════════════════
35
35
  // ── Blob Sprite Sheet Config (splash only) ───────────────────────
36
+ // Loaded from /morphy/teleporting.json. Clips map to animStates:
37
+ // idle (hold) → animState 'idle'
38
+ // enter (forward) → animState 'melting'
39
+ // active (pingpong)→ animState 'traveling'
40
+ // exit (forward) → animState 'reforming'
36
41
  // ══════════════════════════════════════════════════════════════════
37
42
 
43
+ var BLOB_CONFIG_URL = '/morphy/teleporting.json';
44
+ var BLOB_ASSETS_DIR = '/morphy/';
45
+
46
+ // Defaults mirror the teleporting.json shipped at the URL above so
47
+ // animation works even if the fetch is cached at the wrong revision.
38
48
  var COLS = 16;
39
- var FRAME_W = 125;
40
- var FRAME_H = 120;
49
+ var FRAME_W = 218;
50
+ var FRAME_H = 180;
41
51
  var DISPLAY_H = 58;
42
52
  var DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
43
53
 
44
- var IDLE_START = 0, IDLE_END = 29;
45
- var MELT_START = 30, MELT_END = 52;
46
- var TRAVEL_START = 52, TRAVEL_END = 84;
47
- var REFORM_START = 84, REFORM_END = 191;
54
+ var IDLE_START = 19, IDLE_END = 19;
55
+ var MELT_START = 20, MELT_END = 45;
56
+ var TRAVEL_START = 46, TRAVEL_END = 68;
57
+ var REFORM_START = 69, REFORM_END = 121;
48
58
 
49
- var FPS = 29;
59
+ var FPS = 24;
50
60
  var FRAME_MS = 1000 / FPS;
51
- var IDLE_FPS = 22;
61
+ var IDLE_FPS = 24;
52
62
  var IDLE_FRAME_MS = 1000 / IDLE_FPS;
53
- var REFORM_FPS = 70;
63
+ var REFORM_FPS = 24;
54
64
  var REFORM_FRAME_MS = 1000 / REFORM_FPS;
55
65
 
56
66
  var TRAVEL_PX_PER_MS = 0.65;
@@ -147,6 +157,7 @@
147
157
  var animState = 'loading';
148
158
  var currentFrame = 0;
149
159
  var idleDirection = 1;
160
+ var travelFrameDir = 1;
150
161
  var lastFrameTime = 0;
151
162
  var travelDuration = 0;
152
163
  var travelStartTime = 0;
@@ -181,21 +192,41 @@
181
192
  var hpSpeechInstance = null;
182
193
  var hpSpeechTranscript = '';
183
194
 
184
- // ── Load blob sprite sheet ──
195
+ // ── Load blob sprite sheet (config + image) ──
196
+ function applyBlobConfig(cfg) {
197
+ COLS = cfg.grid.cols;
198
+ FRAME_W = cfg.frame.w;
199
+ FRAME_H = cfg.frame.h;
200
+ DISPLAY_W = DISPLAY_H * (FRAME_W / FRAME_H);
201
+ IDLE_START = cfg.clips.idle.from; IDLE_END = cfg.clips.idle.to;
202
+ MELT_START = cfg.clips.enter.from; MELT_END = cfg.clips.enter.to;
203
+ TRAVEL_START = cfg.clips.active.from; TRAVEL_END = cfg.clips.active.to;
204
+ REFORM_START = cfg.clips.exit.from; REFORM_END = cfg.clips.exit.to;
205
+ FPS = cfg.fps; FRAME_MS = 1000 / FPS;
206
+ IDLE_FPS = cfg.fps; IDLE_FRAME_MS = 1000 / IDLE_FPS;
207
+ REFORM_FPS = cfg.fps; REFORM_FRAME_MS = 1000 / REFORM_FPS;
208
+ }
209
+
185
210
  function loadSprite(onDone, onFail) {
186
- var img = new Image();
187
- img.onload = function () {
188
- spriteSheet = img;
189
- center.x = Math.round(W / 2);
190
- center.y = Math.round(H / 2);
191
- currentFrame = IDLE_START;
192
- animState = 'idle';
193
- var splash = document.getElementById('splash');
194
- if (splash) splash.style.display = 'none';
195
- onDone();
196
- };
197
- img.onerror = function () { if (onFail) onFail(); };
198
- img.src = '/spritesheet.webp';
211
+ fetch(BLOB_CONFIG_URL)
212
+ .then(function (r) { if (!r.ok) throw new Error('blob config ' + r.status); return r.json(); })
213
+ .then(function (cfg) {
214
+ applyBlobConfig(cfg);
215
+ var img = new Image();
216
+ img.onload = function () {
217
+ spriteSheet = img;
218
+ center.x = Math.round(W / 2);
219
+ center.y = Math.round(H / 2);
220
+ currentFrame = IDLE_START;
221
+ animState = 'idle';
222
+ var splash = document.getElementById('splash');
223
+ if (splash) splash.style.display = 'none';
224
+ onDone();
225
+ };
226
+ img.onerror = function () { if (onFail) onFail(); };
227
+ img.src = BLOB_ASSETS_DIR + cfg.spritesheet;
228
+ })
229
+ .catch(function () { if (onFail) onFail(); });
199
230
  }
200
231
 
201
232
  // ── Load headphones sprite sheet ──
@@ -300,10 +331,11 @@
300
331
  else if (currentFrame <= IDLE_START) { currentFrame = IDLE_START; idleDirection = 1; }
301
332
  } else if (animState === 'melting') {
302
333
  currentFrame++;
303
- if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; }
334
+ if (currentFrame > MELT_END) { animState = 'traveling'; currentFrame = TRAVEL_START; travelStartTime = now; travelFrameDir = 1; }
304
335
  } else if (animState === 'traveling') {
305
- currentFrame++;
306
- if (currentFrame > TRAVEL_END) currentFrame = TRAVEL_START;
336
+ currentFrame += travelFrameDir;
337
+ if (currentFrame >= TRAVEL_END) { currentFrame = TRAVEL_END; travelFrameDir = -1; }
338
+ else if (currentFrame <= TRAVEL_START) { currentFrame = TRAVEL_START; travelFrameDir = 1; }
307
339
  } else if (animState === 'reforming') {
308
340
  currentFrame++;
309
341
  if (currentFrame > REFORM_END) {
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "teleporting",
3
+ "spritesheet": "teleporting.png",
4
+ "frame": {
5
+ "w": 218,
6
+ "h": 180
7
+ },
8
+ "grid": {
9
+ "cols": 16,
10
+ "rows": 8
11
+ },
12
+ "totalFrames": 121,
13
+ "fps": 24,
14
+ "clips": {
15
+ "idle": {
16
+ "from": 19,
17
+ "to": 19,
18
+ "mode": "hold"
19
+ },
20
+ "enter": {
21
+ "from": 20,
22
+ "to": 45,
23
+ "mode": "forward",
24
+ "next": "active"
25
+ },
26
+ "active": {
27
+ "from": 46,
28
+ "to": 68,
29
+ "mode": "pingpong"
30
+ },
31
+ "exit": {
32
+ "from": 69,
33
+ "to": 121,
34
+ "mode": "forward",
35
+ "next": "idle"
36
+ }
37
+ }
38
+ }
@@ -6,7 +6,7 @@
6
6
  // JS/CSS modules → stale-while-revalidate
7
7
  // API, WebSocket, Vite internals → network-only (no cache)
8
8
 
9
- const CACHE = 'bloby-v5';
9
+ const CACHE = 'bloby-v6';
10
10
 
11
11
  // Precache the HTML shell on install so the cache is never empty.
12
12
  // Without this, the first navigation isn't intercepted (SW wasn't
@@ -249,13 +249,27 @@ curl -s -o "workspace/files/audio/plaud/<FILE_ID>.mp3" '<TEMP URL>'
249
249
 
250
250
  ---
251
251
 
252
- ## Transcription — pick a path
252
+ ## Transcription — pick a path (with pricing)
253
253
 
254
- Once the audio is on disk, you need text. **Ask the human once** which path they want, then save it as `transcriptionMode` in `.plaud.json` so you don't re-ask every sync.
254
+ Once the audio is on disk, you need text. **Talk to the human about the trade-offs once**, pick a path, save it as `transcriptionMode` in `.plaud.json` so you don't re-ask every sync.
255
255
 
256
- ### Path A Bloby Marketplace `audio-to-text` (easiest, pay-per-minute)
256
+ ### The trade-off table (lead with this)
257
257
 
258
- If the bloby is registered with the relay (Quick Tunnel mode → there's a token at `~/.bloby/config.json → relay.token`), just POST the file. No API key to manage, no provider account.
258
+ | Path | Cost | Setup | Notes |
259
+ |---|---|---|---|
260
+ | **Bloby Marketplace** | **$0.22/hr** ($0.0037/min) | None — uses your relay token | **Cheapest hosted option.** Pay-as-you-go from owner's credit balance. No API key to manage. |
261
+ | Groq Whisper (BYO key) | ~$0.04/hr | Human creates a Groq account, gives you the key | Often has a generous free tier. Same model the marketplace uses under the hood. |
262
+ | Mistral Voxtral (BYO key) | varies | Human creates a Mistral account, gives you the key | Newer option. Check [docs.mistral.ai](https://docs.mistral.ai) for current pricing. |
263
+ | OpenAI Whisper (wizard key) | **$1.02/hr** | Already configured if human set whisper_key in the Bloby wizard | **~5× more expensive than the marketplace.** Only worth it if the human really wants to use their existing OpenAI key. |
264
+ | Local (whisper.cpp / faster-whisper) | Free (compute) | Human installs the binary themselves | Fully offline, full privacy. Slower than cloud. |
265
+
266
+ **Important:** The Bloby wizard today only collects an OpenAI Whisper key. **Don't assume that's the only option.** When you talk to your human about transcription for the first time, **proactively mention** that for $0.22/hr they can use the marketplace, that Groq is even cheaper if they BYO key, and that OpenAI (the wizard default) is actually the most expensive of the cloud options.
267
+
268
+ ### Path A — Bloby Marketplace `audio-to-text` (default recommendation)
269
+
270
+ If the bloby is registered with the relay (Quick Tunnel mode → there's a token at `~/.bloby/config.json → relay.token`), POST the audio file. Returns the transcript directly. No API key to manage.
271
+
272
+ The marketplace tries **owner credit balance first** on either route — if your human has any credits at `https://bloby.bot`, neither route needs an on-chain dance and a plain `curl` works:
259
273
 
260
274
  ```bash
261
275
  TOKEN=$(jq -r '.relay.token' ~/.bloby/config.json)
@@ -266,32 +280,119 @@ curl -s -X POST 'https://api.bloby.bot/api/services/audio-to-text/use' \
266
280
  -F "language=en" # optional
267
281
  ```
268
282
 
269
- Returns JSON:
283
+ If you get **HTTP 200** — done. Parse the JSON, write the `transcript` to `<FILE_ID>.mp3.txt`.
270
284
 
271
- ```json
272
- {
273
- "transcript": "...",
274
- "language": "en",
275
- "estimatedMinutes": 5,
276
- "priceUsd": 0.0185,
277
- "paidVia": "balance",
278
- "groqDurationSec": 275.4,
279
- "model": "whisper-large-v3-turbo"
280
- }
285
+ If you get **HTTP 402** — balance is empty, you need to settle on-chain. Pick the route matching your wallet's funded network (see "Picking the network" below).
286
+
287
+ #### Picking the network: Tempo `/use` vs Base `/use-base`
288
+
289
+ The bloby's wallet (`~/.bloby/config.json → wallet`) can be funded on either Tempo USDC or Base USDC. The human picked when they funded it. **Ask once at first sync** and save it in `.plaud.json`:
290
+
291
+ ```
292
+ Bloby: Your account has no marketplace credits, so I'll need to settle on-chain.
293
+ Is your wallet funded on Tempo USDC or Base USDC?
294
+ (If you don't know — open https://bloby.bot, sign in, check your wallet balance.)
281
295
  ```
282
296
 
283
- - **Pricing:** $0.0037 per estimated minute, rounded up (~$0.22/hr).
284
- - **How duration is estimated:** file size ÷ assumed 32kbps bitrate. Plaud-sourced mp3 matches this assumption well. High-bitrate files from other sources would be over-charged proportionally — for those, switch to Path B.
285
- - **Paid from:** account balance first; falls back to MPP (Tempo USDC) or Base (use `/use-base` instead). Make sure the bloby's account has funds OR its wallet is funded on the matching network.
286
- - **Limits:** 25MB per file. Mp3 from Plaud comfortably fits — observed 1MB ≈ 4½min.
297
+ Save as `marketplaceNetwork: "tempo" | "base"`. Re-ask only if both routes start failing.
287
298
 
288
- Write the response's `transcript` to `workspace/files/audio/plaud/<FILE_ID>.mp3.txt`.
299
+ #### Tempo path (`/use`) needs `mppx/client`, NOT curl
289
300
 
290
- ### Path B Bring your own API key (DIY)
301
+ The `mppx` CLI does not support multipart uploads (`-F`). For file-upload services, use the `mppx/client` Node library instead. Write a small helper:
291
302
 
292
- Pick a provider, ask the human for their key, store it as a workspace secret (`workspace/.env` is fine — the backend reloads on .env change). Then call directly from Bash.
303
+ ```bash
304
+ # One-time install (in workspace root or skill dir):
305
+ npm install --prefix workspace mppx viem
306
+ ```
293
307
 
294
- **Groq Whisper** — cheapest, fastest. Same model the marketplace uses under the hood. Free tier exists.
308
+ ```js
309
+ // workspace/skills/plaud/marketplace-tempo.mjs
310
+ import { Mppx, tempo } from 'mppx/client';
311
+ import { privateKeyToAccount } from 'viem/accounts';
312
+ import { readFileSync, writeFileSync } from 'node:fs';
313
+
314
+ const [, , filePath, language] = process.argv;
315
+ const cfg = JSON.parse(readFileSync(`${process.env.HOME}/.bloby/config.json`, 'utf8'));
316
+ const account = privateKeyToAccount(cfg.wallet.privateKey);
317
+ const mppx = Mppx.create({ methods: [tempo({ account })] });
318
+
319
+ const form = new FormData();
320
+ form.append('file', new Blob([readFileSync(filePath)]), filePath.split('/').pop());
321
+ if (language) form.append('language', language);
322
+
323
+ const res = await mppx.fetch('https://api.bloby.bot/api/services/audio-to-text/use', {
324
+ method: 'POST',
325
+ headers: { 'X-Bloby-Token': cfg.relay.token },
326
+ body: form,
327
+ });
328
+ if (!res.ok) { console.error(await res.text()); process.exit(1); }
329
+ const data = await res.json();
330
+ writeFileSync(`${filePath}.txt`, data.transcript);
331
+ console.log(JSON.stringify({ priceUsd: data.priceUsd, paidVia: data.paidVia, transcriptPath: `${filePath}.txt` }, null, 2));
332
+ ```
333
+
334
+ Invoke from Bash:
335
+ ```bash
336
+ node workspace/skills/plaud/marketplace-tempo.mjs workspace/files/audio/plaud/<FILE_ID>.mp3 en
337
+ ```
338
+
339
+ #### Base path (`/use-base`) — `x402-fetch` works
340
+
341
+ Base is easier because `x402-fetch` is a plain `fetch` wrapper that handles FormData natively:
342
+
343
+ ```bash
344
+ npm install --prefix workspace x402-fetch viem
345
+ ```
346
+
347
+ ```js
348
+ // workspace/skills/plaud/marketplace-base.mjs
349
+ import { wrapFetchWithPayment } from 'x402-fetch';
350
+ import { privateKeyToAccount } from 'viem/accounts';
351
+ import { readFileSync, writeFileSync } from 'node:fs';
352
+
353
+ const [, , filePath, language] = process.argv;
354
+ const cfg = JSON.parse(readFileSync(`${process.env.HOME}/.bloby/config.json`, 'utf8'));
355
+ const account = privateKeyToAccount(cfg.wallet.privateKey);
356
+ const fetchWithPay = wrapFetchWithPayment(fetch, account);
357
+
358
+ const form = new FormData();
359
+ form.append('file', new Blob([readFileSync(filePath)]), filePath.split('/').pop());
360
+ if (language) form.append('language', language);
361
+
362
+ const res = await fetchWithPay('https://api.bloby.bot/api/services/audio-to-text/use-base', {
363
+ method: 'POST',
364
+ headers: { 'X-Bloby-Token': cfg.relay.token },
365
+ body: form,
366
+ });
367
+ if (!res.ok) { console.error(await res.text()); process.exit(1); }
368
+ const data = await res.json();
369
+ writeFileSync(`${filePath}.txt`, data.transcript);
370
+ console.log(JSON.stringify({ priceUsd: data.priceUsd, paidVia: data.paidVia, transcriptPath: `${filePath}.txt` }, null, 2));
371
+ ```
372
+
373
+ Invoke:
374
+ ```bash
375
+ node workspace/skills/plaud/marketplace-base.mjs workspace/files/audio/plaud/<FILE_ID>.mp3 en
376
+ ```
377
+
378
+ #### Suggested flow
379
+
380
+ 1. Try the plain `curl` first — covers the case where the human has credit balance.
381
+ 2. If `curl` returns 402, fall through to the helper for `marketplaceNetwork` from `.plaud.json`.
382
+ 3. If you don't have `marketplaceNetwork` set yet, ask the human (script above).
383
+
384
+ Both routes return the same JSON. Pricing:
385
+ - **$0.0037 per estimated minute, rounded up ($0.22/hr).**
386
+ - Duration is estimated from file size ÷ 32 kbps. Plaud mp3 matches; high-bitrate non-Plaud files get over-charged proportionally — use Path B for those.
387
+ - 25MB cap per file (Plaud comfortably fits — observed 1MB ≈ 4½ min).
388
+
389
+ Set `transcriptionMode: "marketplace"` in `.plaud.json` once it works.
390
+
391
+ ### Path B — Bring your own API key (BYO)
392
+
393
+ Pick a provider, ask the human for their key, store it (workspace `.env` works — backend auto-reloads on .env change). Then call from Bash.
394
+
395
+ **Groq Whisper** — currently the cheapest cloud option (~$0.04/hr at our list rate, often free under their free tier). Same model as the marketplace. Recommend this when the human wants to BYO.
295
396
  ```bash
296
397
  curl -s -X POST 'https://api.groq.com/openai/v1/audio/transcriptions' \
297
398
  -H "Authorization: Bearer $GROQ_API_KEY" \
@@ -299,8 +400,9 @@ curl -s -X POST 'https://api.groq.com/openai/v1/audio/transcriptions' \
299
400
  -F "model=whisper-large-v3-turbo" \
300
401
  -F "response_format=json"
301
402
  ```
403
+ Set `transcriptionMode: "groq"`.
302
404
 
303
- **OpenAI Whisper** — the human may already have an OpenAI key from the Bloby wizard. Read it from the settings table directly:
405
+ **OpenAI Whisper** — only do this if the human explicitly prefers it. **$1.02/hr ~5× more expensive than the marketplace.** The key is the one collected by the Bloby wizard, readable directly from the settings DB:
304
406
  ```bash
305
407
  WHISPER_KEY=$(sqlite3 ~/.bloby/memory.db "SELECT value FROM settings WHERE key='whisper_key';")
306
408
  curl -s -X POST 'https://api.openai.com/v1/audio/transcriptions' \
@@ -308,6 +410,8 @@ curl -s -X POST 'https://api.openai.com/v1/audio/transcriptions' \
308
410
  -F "file=@workspace/files/audio/plaud/<FILE_ID>.mp3" \
309
411
  -F "model=whisper-1"
310
412
  ```
413
+ Before using this path, **say something like**: *"I see you set an OpenAI Whisper key in the wizard. I can use it, but it's about 5× more expensive than the marketplace ($1.02/hr vs $0.22/hr). Want me to use the marketplace instead, or stick with OpenAI?"*
414
+ Set `transcriptionMode: "openai"` if they confirm.
311
415
 
312
416
  **Mistral Voxtral**:
313
417
  ```bash
@@ -316,26 +420,24 @@ curl -s -X POST 'https://api.mistral.ai/v1/audio/transcriptions' \
316
420
  -F "file=@workspace/files/audio/plaud/<FILE_ID>.mp3" \
317
421
  -F "model=voxtral-mini-latest"
318
422
  ```
423
+ Set `transcriptionMode: "mistral"`.
319
424
 
320
- **Local — no API, no cost, fully private:**
321
- - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — C++ binary, CPU or Metal/CUDA. Install once, transcribe forever.
425
+ **Local — no API, no cost, fully offline:**
426
+ - [whisper.cpp](https://github.com/ggerganov/whisper.cpp) — C++ binary, CPU or Metal/CUDA.
322
427
  - [faster-whisper](https://github.com/SYSTRAN/faster-whisper) — Python, ~4× faster than reference whisper.
323
428
  - The human installs one of these themselves. The bloby invokes the CLI from Bash.
324
429
 
325
- After whichever path, extract the `text` field and write it to `workspace/files/audio/plaud/<FILE_ID>.mp3.txt`.
430
+ Set `transcriptionMode: "local"` and add a `localCommand` field to `.plaud.json` with the exact invocation pattern.
431
+
432
+ After whichever path, extract the `text` field from the response (or stdout for local) and write it to `workspace/files/audio/plaud/<FILE_ID>.mp3.txt`.
433
+
434
+ ### How to talk to the human about this
326
435
 
327
- ### Choosing for the human
436
+ First-time setup, before transcribing anything:
328
437
 
329
- If they don't have a preference, recommend **Path A (Marketplace)**:
330
- - No key setup.
331
- - Already integrated with the bloby's payment.
332
- - Pay-as-you-go — no monthly minimum.
333
- - If their account has any balance from other marketplace use, it just works.
438
+ > *"For transcription I have a few options. Cheapest is the Bloby marketplace at $0.22/hour — no setup, paid from your account credits. If you have a Groq API key, BYO is even cheaper. I see you set an OpenAI Whisper key in the wizard — I can use that too, but at $1.02/hour it's about 5× more expensive than the marketplace, so I'd recommend not using it unless you specifically want to. There's also local transcription if you'd rather install whisper.cpp. What's your preference?"*
334
439
 
335
- Recommend **Path B** if:
336
- - They're transcribing a lot and want to use a free tier or flat-rate plan.
337
- - They want 100% local for privacy reasons.
338
- - They already have a preferred provider.
440
+ After they pick, save it as `transcriptionMode` and don't re-ask.
339
441
 
340
442
  ---
341
443