bloby-bot 0.49.6 → 0.50.1

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.49.6",
3
+ "version": "0.50.1",
4
4
  "releaseNotes": [
5
5
  "1. Something great..",
6
6
  "2. ",
package/worker/index.ts CHANGED
@@ -1002,6 +1002,119 @@ app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req,
1002
1002
  }
1003
1003
  });
1004
1004
 
1005
+ // Transcribe an audio file already on disk under workspace/files/.
1006
+ // Body: { path, saveTranscriptNext?, language? }. `path` is interpreted
1007
+ // relative to workspace/files/ ("files/" prefix is tolerated).
1008
+ app.post('/api/whisper/transcribe-file', express.json({ limit: '1mb' }), async (req, res) => {
1009
+ const whisperEnabled = getSetting('whisper_enabled');
1010
+ const whisperKey = getSetting('whisper_key');
1011
+
1012
+ if (whisperEnabled !== 'true' || !whisperKey) {
1013
+ res.status(400).json({ error: 'Whisper not enabled or API key missing' });
1014
+ return;
1015
+ }
1016
+
1017
+ const { path: relPath, saveTranscriptNext, language } = req.body as {
1018
+ path?: string;
1019
+ saveTranscriptNext?: boolean;
1020
+ language?: string;
1021
+ };
1022
+
1023
+ if (!relPath || typeof relPath !== 'string') {
1024
+ res.status(400).json({ error: 'Missing path' });
1025
+ return;
1026
+ }
1027
+
1028
+ const normalized = relPath.replace(/^\/+/, '').replace(/^files\//, '');
1029
+ const absPath = path.resolve(paths.files, normalized);
1030
+ if (absPath !== paths.files && !absPath.startsWith(paths.files + path.sep)) {
1031
+ res.status(400).json({ error: 'Path escapes workspace/files/' });
1032
+ return;
1033
+ }
1034
+ if (!fs.existsSync(absPath) || !fs.statSync(absPath).isFile()) {
1035
+ res.status(404).json({ error: 'File not found' });
1036
+ return;
1037
+ }
1038
+
1039
+ try {
1040
+ const audioBuffer = fs.readFileSync(absPath);
1041
+ const filename = path.basename(absPath);
1042
+ const ext = path.extname(filename).toLowerCase().slice(1);
1043
+ const contentTypes: Record<string, string> = {
1044
+ mp3: 'audio/mpeg',
1045
+ m4a: 'audio/mp4',
1046
+ mp4: 'audio/mp4',
1047
+ wav: 'audio/wav',
1048
+ webm: 'audio/webm',
1049
+ ogg: 'audio/ogg',
1050
+ opus: 'audio/ogg',
1051
+ flac: 'audio/flac',
1052
+ };
1053
+ const contentType = contentTypes[ext] || 'application/octet-stream';
1054
+
1055
+ const boundary = '----WhisperBoundary' + Date.now();
1056
+ const CRLF = '\r\n';
1057
+ const parts: Buffer[] = [];
1058
+
1059
+ parts.push(Buffer.from(
1060
+ `--${boundary}${CRLF}` +
1061
+ `Content-Disposition: form-data; name="file"; filename="${filename}"${CRLF}` +
1062
+ `Content-Type: ${contentType}${CRLF}${CRLF}`
1063
+ ));
1064
+ parts.push(audioBuffer);
1065
+ parts.push(Buffer.from(CRLF));
1066
+
1067
+ parts.push(Buffer.from(
1068
+ `--${boundary}${CRLF}` +
1069
+ `Content-Disposition: form-data; name="model"${CRLF}${CRLF}` +
1070
+ `whisper-1${CRLF}`
1071
+ ));
1072
+
1073
+ if (language && typeof language === 'string') {
1074
+ parts.push(Buffer.from(
1075
+ `--${boundary}${CRLF}` +
1076
+ `Content-Disposition: form-data; name="language"${CRLF}${CRLF}` +
1077
+ `${language}${CRLF}`
1078
+ ));
1079
+ }
1080
+
1081
+ parts.push(Buffer.from(`--${boundary}--${CRLF}`));
1082
+
1083
+ const body = Buffer.concat(parts);
1084
+
1085
+ const response = await fetch('https://api.openai.com/v1/audio/transcriptions', {
1086
+ method: 'POST',
1087
+ headers: {
1088
+ 'Authorization': `Bearer ${whisperKey}`,
1089
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
1090
+ },
1091
+ body,
1092
+ });
1093
+
1094
+ if (!response.ok) {
1095
+ const errText = await response.text();
1096
+ log.warn(`Whisper API error: ${response.status} ${errText}`);
1097
+ res.status(502).json({ error: 'Whisper API error', detail: errText.slice(0, 500) });
1098
+ return;
1099
+ }
1100
+
1101
+ const result = await response.json() as { text: string };
1102
+ const transcript = result.text;
1103
+
1104
+ let transcriptPath: string | undefined;
1105
+ if (saveTranscriptNext) {
1106
+ const txtAbs = absPath + '.txt';
1107
+ fs.writeFileSync(txtAbs, transcript, 'utf8');
1108
+ transcriptPath = path.relative(paths.files, txtAbs).split(path.sep).join('/');
1109
+ }
1110
+
1111
+ res.json({ transcript, ...(transcriptPath ? { transcriptPath } : {}) });
1112
+ } catch (err: any) {
1113
+ log.warn(`Whisper transcribe-file failed: ${err.message}`);
1114
+ res.status(500).json({ error: 'Transcription failed' });
1115
+ }
1116
+ });
1117
+
1005
1118
  // Serve stored files (audio, images, documents)
1006
1119
  app.use('/api/files', express.static(paths.files));
1007
1120
 
@@ -0,0 +1,325 @@
1
+ # Plaud
2
+
3
+ ## What This Is
4
+
5
+ A channel for getting **recordings off the user's Plaud Note device** and into your workspace as `(audio file, transcript)` pairs you can read and act on.
6
+
7
+ Plaud is a tiny voice recorder (button on the case, magnet sticks to a phone). When the user records something — a meeting, a lecture, a thought on a walk — the device syncs to Plaud's cloud over Bluetooth/Wi-Fi. **You don't talk to the device.** You talk to Plaud's cloud, pull the audio, and transcribe it yourself.
8
+
9
+ There is **no Plaud CLI, no Plaud webhook, no official Plaud API.** Plaud's mobile/web app uses an undocumented HTTP API. This skill uses the same one — same shape OpenPlaud uses (`https://github.com/openplaud/openplaud`).
10
+
11
+ The user already pays Plaud $0 if they don't want Plaud's transcription subscription. We do transcription locally via Whisper using the OpenAI key the user added during the Bloby wizard. No new key, no new subscription.
12
+
13
+ ---
14
+
15
+ ## What Bloby Gives You (already-built plumbing)
16
+
17
+ | Thing | Where | How you use it |
18
+ |---|---|---|
19
+ | Whisper-on-disk endpoint | `POST http://localhost:7400/api/whisper/transcribe-file` | Send a path under `workspace/files/`, get a transcript back. Optional `saveTranscriptNext: true` writes `foo.mp3.txt` next to `foo.mp3`. |
20
+ | Settings k/v store | `GET/POST/PUT http://localhost:7400/api/settings` | Store/retrieve the Plaud JWT, region, workspace ID, last-sync cursor. |
21
+ | Workspace files dir | `workspace/files/audio/plaud/` | Drop downloaded audio here. The supervisor serves it at `/api/files/audio/plaud/<name>`. |
22
+ | Scheduling | `workspace/CRONS.json` or `workspace/PULSE.json` | Run sync periodically. See "Cadence" below. |
23
+
24
+ Use `http://localhost:7400` from Bash. Auth is the same Bearer token you already have in your session for the worker; for skill-internal calls running inside the supervisor's own bloby session, `/api/settings` and `/api/whisper/transcribe-file` work the same way `/api/whisper/transcribe` does.
25
+
26
+ ---
27
+
28
+ ## Plaud's API in 60 seconds
29
+
30
+ Three regions. Pick one when pairing. Token from one region won't work on another.
31
+
32
+ | Region | Base URL |
33
+ |---|---|
34
+ | Global | `https://api.plaud.ai` |
35
+ | EU | `https://api-euc1.plaud.ai` |
36
+ | Asia-Pacific | `https://api-apse1.plaud.ai` |
37
+
38
+ If the user doesn't know their region, start with Global. If `POST /auth/otp-send-code` returns `status: -302` with `data.domains.api`, redirect to that base instead — the user's account lives in a different region. Save whichever base actually succeeded.
39
+
40
+ **User-Agent matters.** Plaud blocks some defaults. Always send a normal browser UA. Example:
41
+
42
+ ```
43
+ User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
44
+ ```
45
+
46
+ ---
47
+
48
+ ## Pairing (first time)
49
+
50
+ Walk the human through it conversationally. They don't see any UI for this — just chat with you.
51
+
52
+ ### Step 1 — Ask for their Plaud email
53
+
54
+ ```
55
+ Bloby: Which email do you use on plaud.ai? I'll have them send you a 6-digit code.
56
+ Human: bruno@example.com
57
+ ```
58
+
59
+ If the human mentions they signed up with **Google or Apple**, jump to the "Paste-token fallback" section instead. OTP only works for accounts that were created with an email+password identity on Plaud's side.
60
+
61
+ ### Step 2 — Send the OTP
62
+
63
+ ```bash
64
+ curl -s -X POST 'https://api.plaud.ai/auth/otp-send-code' \
65
+ -H 'Content-Type: application/json' \
66
+ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
67
+ -d '{"username":"<EMAIL>"}'
68
+ ```
69
+
70
+ Expected `status: 0` and a `token` field. **Save the `token`** — you need it for verify. If you see `status: -302`, switch `apiBase` to `data.domains.api` and retry once.
71
+
72
+ ### Step 3 — Ask for the code
73
+
74
+ ```
75
+ Bloby: Check your inbox — Plaud sent you a 6-digit code. What is it?
76
+ ```
77
+
78
+ ### Step 4 — Verify
79
+
80
+ ```bash
81
+ curl -s -X POST '<apiBase>/auth/otp-login' \
82
+ -H 'Content-Type: application/json' \
83
+ -H 'User-Agent: Mozilla/5.0 ...' \
84
+ -d '{"code":"<6 DIGITS>","token":"<OTP TOKEN FROM STEP 2>"}'
85
+ ```
86
+
87
+ Expected `access_token` (a long `eyJ...` JWT). **This is the long-lived token. Store it.**
88
+
89
+ ### Step 5 — Store the connection
90
+
91
+ ```bash
92
+ # Each setting saved separately. Replace <TOKEN> and <BASE>.
93
+ curl -s -X POST 'http://localhost:7400/api/settings' \
94
+ -H 'Content-Type: application/json' \
95
+ -d '{"key":"plaud_token","value":"<JWT>"}'
96
+ curl -s -X POST 'http://localhost:7400/api/settings' \
97
+ -H 'Content-Type: application/json' \
98
+ -d '{"key":"plaud_api_base","value":"<BASE>"}'
99
+ curl -s -X POST 'http://localhost:7400/api/settings' \
100
+ -H 'Content-Type: application/json' \
101
+ -d '{"key":"plaud_email","value":"<EMAIL>"}'
102
+ ```
103
+
104
+ You can also save `plaud_workspace_id` once you discover it (see "Workspaces" below).
105
+
106
+ ### Step 6 — Smoke test
107
+
108
+ ```bash
109
+ curl -s '<BASE>/device/list' \
110
+ -H 'Authorization: Bearer <JWT>' \
111
+ -H 'User-Agent: Mozilla/5.0 ...'
112
+ ```
113
+
114
+ Should return a JSON object listing the user's Plaud devices (each has a `serial_number`). If you get `401`, the OTP didn't grant a usable token — start over. If `200`, tell the human: *"Paired. Your Plaud (serial ending ...XXXX) is connected. Want me to pull in everything you've recorded so far?"*
115
+
116
+ ---
117
+
118
+ ## Paste-token fallback (Google/Apple Plaud accounts)
119
+
120
+ If OTP just won't work and the human signed up with Google or Apple, get the bearer manually:
121
+
122
+ 1. Open [web.plaud.ai](https://web.plaud.ai) in a browser and sign in with Google/Apple normally.
123
+ 2. Open DevTools (F12 or Cmd+Option+I) → Network tab → refresh.
124
+ 3. Click any request to `api.plaud.ai`, `api-euc1.plaud.ai`, or `api-apse1.plaud.ai`.
125
+ 4. Under **Request Headers**, find `Authorization`. Copy everything after `Bearer ` (the long `eyJ...`).
126
+ 5. Tell the bloby in chat. The bloby saves it via the same `plaud_token` setting key, plus the matching `plaud_api_base`.
127
+
128
+ JWTs from this path expire too. The skill behaviour on 401 is the same (see "Re-auth" below).
129
+
130
+ ---
131
+
132
+ ## Syncing recordings
133
+
134
+ The shape of a sync run:
135
+
136
+ ```
137
+ GET /file/simple/web → list recent recordings (paginated)
138
+ for each new one:
139
+ GET /file/temp-url/<id>?is_opus=0 → get a short-lived S3 link (request the mp3, not opus)
140
+ curl -o workspace/files/audio/plaud/<id>.mp3 → download
141
+ POST /api/whisper/transcribe-file → produces <id>.mp3.txt alongside
142
+ ```
143
+
144
+ ### List recordings
145
+
146
+ ```bash
147
+ curl -s '<BASE>/file/simple/web?skip=0&limit=50&is_trash=0&sort_by=edit_time&is_desc=true' \
148
+ -H 'Authorization: Bearer <JWT>' \
149
+ -H 'User-Agent: Mozilla/5.0 ...'
150
+ ```
151
+
152
+ The response has `data_file_list` — an array of recording objects. Fields you'll care about:
153
+
154
+ | Field | Use |
155
+ |---|---|
156
+ | `id` | Plaud's file id. Use as the local filename. |
157
+ | `filename` | Human label the user gave it (or auto-generated). Sanitise before using as a filename. |
158
+ | `duration` | Seconds. |
159
+ | `start_time` / `end_time` | When the recording happened. |
160
+ | `version_ms` | Bumps if the user edits the recording. Track this to know when to re-download. |
161
+ | `serial_number` | Which Plaud device. |
162
+ | `is_trash` | Skip if 1. |
163
+
164
+ Page with `skip=` (the API also accepts a huge `limit`, but page through 50-at-a-time politely).
165
+
166
+ ### Dedup
167
+
168
+ You don't want to re-download what you already have. Two ways, pick one:
169
+
170
+ - **Filesystem**: if `workspace/files/audio/plaud/<id>.mp3` exists, skip it.
171
+ - **Cursor**: save the newest `version_ms` you've seen as `plaud_last_sync` setting. On next sync, skip anything `<=` that cursor. Faster — no `ls` needed.
172
+
173
+ If `version_ms` changed on a recording you already downloaded, the user edited the filename or trimmed it. Re-fetch and overwrite.
174
+
175
+ ### Get the download URL
176
+
177
+ ```bash
178
+ curl -s '<BASE>/file/temp-url/<FILE_ID>?is_opus=0' \
179
+ -H 'Authorization: Bearer <JWT>' \
180
+ -H 'User-Agent: Mozilla/5.0 ...'
181
+ ```
182
+
183
+ `is_opus=0` returns the mp3 variant (`temp_url`). `is_opus=1` returns opus in `temp_url_opus`. **Use mp3** — Whisper handles it cleanly, opus needs ffmpeg.
184
+
185
+ Response: `{ "temp_url": "https://<s3...>" }`. The URL expires in a few minutes. Download immediately.
186
+
187
+ ### Download
188
+
189
+ ```bash
190
+ mkdir -p workspace/files/audio/plaud
191
+ curl -s -o "workspace/files/audio/plaud/<FILE_ID>.mp3" '<TEMP URL>'
192
+ ```
193
+
194
+ ### Transcribe
195
+
196
+ ```bash
197
+ curl -s -X POST 'http://localhost:7400/api/whisper/transcribe-file' \
198
+ -H 'Content-Type: application/json' \
199
+ -d '{"path":"audio/plaud/<FILE_ID>.mp3","saveTranscriptNext":true}'
200
+ ```
201
+
202
+ Returns `{ "transcript": "...", "transcriptPath": "audio/plaud/<FILE_ID>.mp3.txt" }`. The `.txt` file is now sitting next to the audio. You can read it with `Read` like any other file.
203
+
204
+ The user's `whisper_key` from the wizard is what powers this — you don't need to know or handle the OpenAI key.
205
+
206
+ If transcription fails (e.g. file >25MB, Whisper API's own hard limit), leave the audio in place and skip the `.txt`. The human can ask you to split/compress later.
207
+
208
+ ### Pretty filenames (optional)
209
+
210
+ Tell the human you can keep the raw `<FILE_ID>.mp3` filenames, OR you can also rename to something human-readable. If they want pretty names:
211
+
212
+ ```bash
213
+ # After successful transcribe, also write a symlink or copy with a nicer name:
214
+ NICE="$(date -d "<start_time>" +%Y-%m-%d_%H%M)_<sanitised filename>"
215
+ ln -s "<FILE_ID>.mp3" "workspace/files/audio/plaud/${NICE}.mp3"
216
+ ln -s "<FILE_ID>.mp3.txt" "workspace/files/audio/plaud/${NICE}.txt"
217
+ ```
218
+
219
+ (Sanitise `filename` by stripping `/\\:*?"<>|`.)
220
+
221
+ Don't rename the originals — keep `<id>.mp3` as the canonical name so dedup keeps working.
222
+
223
+ ---
224
+
225
+ ## Cadence — CRON or PULSE?
226
+
227
+ **There is no automatic cron set up by this skill.** You and your human decide together. Two reasonable patterns:
228
+
229
+ ### Pattern A — CRON every N minutes
230
+
231
+ When the human wants near-real-time freshness ("any time I record something, you should know about it within 15 minutes"), add an entry to `workspace/CRONS.json`:
232
+
233
+ ```json
234
+ {
235
+ "id": "plaud-sync",
236
+ "schedule": "*/15 * * * *",
237
+ "task": "Run a Plaud sync: list new recordings, download any new ones into workspace/files/audio/plaud/, and transcribe them via /api/whisper/transcribe-file. After, summarise to the human in chat IF there were new recordings — otherwise stay silent.",
238
+ "enabled": true,
239
+ "oneShot": false
240
+ }
241
+ ```
242
+
243
+ Tune `*/15` to taste. `*/5` for aggressive, `0 * * * *` (top of every hour) for quiet.
244
+
245
+ ### Pattern B — PULSE memo
246
+
247
+ When the human prefers their bloby just *check* during normal pulse wake-ups, add one line to your `MYSELF.md` or `MEMORY.md`:
248
+
249
+ ```
250
+ - Each pulse, briefly check Plaud for new recordings via the plaud skill. If there's something new, transcribe and decide whether to surface it. If nothing new, move on silently.
251
+ ```
252
+
253
+ Pulse runs every 30 min by default. No CRON entry needed. Less aggressive than Pattern A, fits naturally with whatever else you're doing at pulse time.
254
+
255
+ ### Or: don't auto-sync at all
256
+
257
+ Some humans only want manual control: *"Bloby, pull anything new from Plaud."* That's also fine — just keep the skill installed, no CRON, no pulse memo, you sync when asked.
258
+
259
+ **Always check with the human first.** Default to Pattern B for new installs unless they tell you otherwise.
260
+
261
+ ---
262
+
263
+ ## Re-auth (401 handling)
264
+
265
+ When any Plaud call returns 401:
266
+
267
+ 1. Tell the human in chat: *"Your Plaud connection expired. Want me to re-pair?"* Don't silently fail.
268
+ 2. If they say yes, re-run the OTP flow from Step 1. Overwrite the `plaud_token` setting.
269
+ 3. If they signed up with Google/Apple originally, prompt for the paste-token fallback instead.
270
+ 4. Don't keep retrying with the dead token — pause the sync until re-paired.
271
+
272
+ ---
273
+
274
+ ## Disconnect
275
+
276
+ ```bash
277
+ curl -s -X POST 'http://localhost:7400/api/settings' \
278
+ -H 'Content-Type: application/json' \
279
+ -d '{"key":"plaud_token","value":""}'
280
+ curl -s -X POST 'http://localhost:7400/api/settings' \
281
+ -H 'Content-Type: application/json' \
282
+ -d '{"key":"plaud_api_base","value":""}'
283
+ ```
284
+
285
+ Recordings already on disk stay. The user can also disable the CRON entry (or remove it from `CRONS.json`).
286
+
287
+ ---
288
+
289
+ ## Workspaces (advanced)
290
+
291
+ Plaud's "workspace" is their multi-account team feature. Personal accounts don't usually need to worry about this — the API responds correctly without a workspace token. If a human ever reports recordings missing that they can see in the Plaud app, it's likely a workspace-scoped recording.
292
+
293
+ To resolve a workspace token: there's an undocumented `/workspace/...` endpoint that mints a workspace-scoped token. OpenPlaud's `src/lib/plaud/workspace.ts` is the reference if you ever need it. Don't bother unless the human hits this case.
294
+
295
+ ---
296
+
297
+ ## What This Skill Does NOT Do
298
+
299
+ - **No Plaud transcription.** We transcribe ourselves with Whisper. Plaud's own AI subscription is bypassed entirely.
300
+ - **No dashboard.** OpenPlaud has a slick UI for browsing recordings. We don't. The bloby's job is to *read* the transcripts and act on them — summaries, action items, emails — using the normal workspace tools. If the human wants a UI, build one into `workspace/client/` as a normal workspace app.
301
+ - **No push from Plaud.** No webhooks exist. You only know about new recordings when you ask.
302
+ - **No editing recordings.** The Plaud API technically supports `PATCH /file/<id>` to rename. We don't expose it here — keep canonical `<id>.mp3` names.
303
+ - **No real-time streaming.** Plaud syncs to its cloud *after* the recording finishes. Expect a lag of seconds-to-minutes between "user stopped recording" and "file appears in `/file/simple/web`."
304
+
305
+ ---
306
+
307
+ ## Quick Reference
308
+
309
+ | Action | curl |
310
+ |---|---|
311
+ | Send OTP | `POST <base>/auth/otp-send-code` body `{username}` |
312
+ | Verify OTP | `POST <base>/auth/otp-login` body `{code, token}` |
313
+ | List devices | `GET <base>/device/list` |
314
+ | List recordings | `GET <base>/file/simple/web?skip=0&limit=50&is_trash=0&sort_by=edit_time&is_desc=true` |
315
+ | Get download URL | `GET <base>/file/temp-url/<id>?is_opus=0` |
316
+ | Transcribe local file | `POST http://localhost:7400/api/whisper/transcribe-file` body `{path, saveTranscriptNext}` |
317
+ | Save setting | `POST http://localhost:7400/api/settings` body `{key, value}` |
318
+
319
+ All Plaud requests need `Authorization: Bearer <JWT>` + a browser-style `User-Agent`.
320
+
321
+ ---
322
+
323
+ ## Credit
324
+
325
+ Plaud API shape is the same one [OpenPlaud](https://github.com/openplaud/openplaud) uses — they did the reverse-engineering work. This skill reimplements just the parts a bloby needs.
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "plaud",
3
+ "version": "1.0.0",
4
+ "type": "skill",
5
+ "bloby_human": "Bruno Bertapeli",
6
+ "bloby": "bloby-bruno",
7
+ "author": "newbot-official",
8
+ "description": "Plaud Note integration. Pairs the user's Plaud account via email OTP, polls Plaud's cloud for new recordings, downloads the audio into workspace/files/audio/plaud/, and transcribes it via the user's Whisper key. Cadence (CRON vs PULSE memo) is chosen by the human and their bloby together.",
9
+ "depends": [],
10
+ "env_keys": [],
11
+ "has_telemetry": false,
12
+ "size": "8KB",
13
+ "contains_binaries": false,
14
+ "tags": ["plaud", "transcription", "audio", "recorder", "meeting"]
15
+ }