osborn 0.9.43 → 0.9.45
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/.claude/skills/meetings/SKILL.md +106 -34
- package/dist/index.js +115 -17
- package/dist/recall-client.d.ts +13 -12
- package/dist/recall-client.js +30 -17
- package/package.json +1 -1
|
@@ -1,73 +1,145 @@
|
|
|
1
1
|
# Skill: Meetings
|
|
2
2
|
|
|
3
|
-
Silent note-taking and TODO-tracking when osborn is sitting in a live meeting
|
|
3
|
+
Silent note-taking and TODO-tracking when osborn is sitting in a live meeting,
|
|
4
|
+
and explicit on-demand transcript pulls from Recall.ai when the user asks.
|
|
4
5
|
|
|
5
6
|
## When to use
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Two trigger patterns:
|
|
8
9
|
|
|
9
|
-
**
|
|
10
|
+
**1. Auto-tagged meeting transcript chunks** (every ~30s while a Recall bot is active):
|
|
11
|
+
Any user message that starts with `[MEETING — <botId>]:`. Also a `[SYSTEM] You are now in a meeting ...` injection on bot join.
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
**2. Explicit user request to pull / write notes** (any of these keyphrases in voice-native chat):
|
|
14
|
+
- "grab the meeting transcripts"
|
|
15
|
+
- "pull the meeting transcripts"
|
|
16
|
+
- "fetch the meeting transcripts"
|
|
17
|
+
- "what was said in the meeting"
|
|
18
|
+
- "update the meeting notes"
|
|
19
|
+
- "compile the todos"
|
|
20
|
+
- "write the todos"
|
|
21
|
+
- "summarize the meeting"
|
|
22
|
+
|
|
23
|
+
**Do NOT use this skill** for normal user voice-native messages that don't fit those patterns — those get spoken responses as usual.
|
|
24
|
+
|
|
25
|
+
## How to behave (auto-tagged chunks)
|
|
12
26
|
|
|
13
27
|
For every `[MEETING — *]:` message:
|
|
14
28
|
|
|
15
|
-
1. **Do NOT speak.** No TTS output. No
|
|
16
|
-
2. **Update `meeting-todos.md`** in the session workspace
|
|
17
|
-
3. **Optionally trigger background research silently
|
|
18
|
-
4. **
|
|
29
|
+
1. **Do NOT speak.** No TTS output. No conversational reply.
|
|
30
|
+
2. **Update `meeting-todos.md`** in the session workspace. Append new action items, decisions, open questions. One file, evolving.
|
|
31
|
+
3. **Optionally trigger background research silently** via Task tool.
|
|
32
|
+
4. **Don't consume voice-native attention.** The user can interrupt with a voice-native message at any time — that's the only kind that gets spoken responses.
|
|
33
|
+
|
|
34
|
+
## How to pull transcripts on demand (Bash + curl)
|
|
35
|
+
|
|
36
|
+
When the user explicitly asks (see triggers above), run these commands. Speak briefly first ("On it"), do the work, then speak the result.
|
|
37
|
+
|
|
38
|
+
### Step 1: Get the bot ID
|
|
39
|
+
|
|
40
|
+
The bot ID is in `meeting-todos.md` on the `**Bot:**` line. If `meeting-todos.md` doesn't exist (user is asking about a meeting that already ended in a prior session), ask the user for the bot ID or meeting URL.
|
|
41
|
+
|
|
42
|
+
### Step 2: Fetch the bot record
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
curl -sS \
|
|
46
|
+
-H "Authorization: Token ${RECALL_API_KEY}" \
|
|
47
|
+
"https://us-west-2.recall.ai/api/v1/bot/<BOT_ID>"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**CRITICAL**: The endpoint MUST be `us-west-2.recall.ai`, NOT the default `recall.ai` or `us-east-1.recall.ai`. The osborn account is provisioned in the us-west-2 region. Using the default endpoint returns 401 "OAuth authentication is currently not supported" or region-mismatch errors.
|
|
51
|
+
|
|
52
|
+
`${RECALL_API_KEY}` is preset in the agent's env — pass it through. Do NOT echo or print the raw key value in your response.
|
|
53
|
+
|
|
54
|
+
### Step 3: Extract the transcript download URL
|
|
55
|
+
|
|
56
|
+
Parse the JSON response. The transcript's pre-signed S3 URL lives at:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
recordings[0].media_shortcuts.transcript.data.download_url
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Pipe through `jq` if needed:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
DOWNLOAD_URL=$(curl -sS \
|
|
66
|
+
-H "Authorization: Token ${RECALL_API_KEY}" \
|
|
67
|
+
"https://us-west-2.recall.ai/api/v1/bot/<BOT_ID>" \
|
|
68
|
+
| jq -r '.recordings[0].media_shortcuts.transcript.data.download_url')
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If `recordings[0]` doesn't exist yet, the meeting hasn't been processed — return "the recording isn't ready yet, give it a minute" and stop.
|
|
72
|
+
|
|
73
|
+
### Step 4: Download the transcript JSON
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
curl -sS "$DOWNLOAD_URL" -o /tmp/meeting-transcript.json
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The download URL is a pre-signed S3 link that **expires** (typically ~6 hours after issue). If you get a 403 or AccessDenied, re-fetch the bot record (step 2) to get a fresh URL.
|
|
80
|
+
|
|
81
|
+
### Step 5: Parse and distill into meeting-todos.md
|
|
82
|
+
|
|
83
|
+
The transcript JSON is an array of turns. Each turn has `participant.name` and `words[]` (each word has `text` + `start_timestamp.relative`). Concatenate words per turn to get the utterance.
|
|
84
|
+
|
|
85
|
+
Use `jq` to pull turns into readable lines:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
jq -r '.[] | "\(.participant.name // "Unknown"): \(.words | map(.text) | join(" "))"' /tmp/meeting-transcript.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then update `meeting-todos.md` — distill into TODOs / Decisions / Open Questions sections. Don't paste the whole transcript verbatim into the file; summarize.
|
|
19
92
|
|
|
20
93
|
## The `meeting-todos.md` file
|
|
21
94
|
|
|
95
|
+
Path: `{session_workspace}/meeting-todos.md` — get the workspace path from spec.md or from the `[SYSTEM]` injection.
|
|
96
|
+
|
|
22
97
|
Keep it scannable. Structure:
|
|
23
98
|
|
|
24
99
|
```markdown
|
|
25
100
|
# Meeting Notes
|
|
26
101
|
|
|
27
|
-
**Bot:** <botId>
|
|
102
|
+
**Bot:** <botId>
|
|
103
|
+
**Started:** <ISO timestamp>
|
|
104
|
+
**URL:** <meeting URL>
|
|
28
105
|
|
|
29
|
-
##
|
|
106
|
+
## Summary
|
|
107
|
+
<3-5 sentences distilling the meeting after it ends — added LAST>
|
|
30
108
|
|
|
109
|
+
## TODOs
|
|
31
110
|
- [ ] <person>: <action item> — <context>
|
|
32
|
-
- [ ] <person>: <action item>
|
|
33
111
|
|
|
34
112
|
## Decisions
|
|
35
|
-
|
|
36
|
-
- <date/time> — <what was decided> (raised by <person>)
|
|
113
|
+
- <what was decided> (raised by <person>)
|
|
37
114
|
|
|
38
115
|
## Open Questions
|
|
39
|
-
|
|
40
116
|
- <question> — raised by <person>, still unresolved
|
|
41
|
-
- <question> — answered by <person>: <answer>
|
|
42
117
|
|
|
43
118
|
## Highlights
|
|
44
|
-
|
|
45
119
|
- <key moment or quote worth surfacing>
|
|
46
120
|
```
|
|
47
121
|
|
|
48
|
-
Update the same file across
|
|
49
|
-
|
|
50
|
-
## Workspace path
|
|
51
|
-
|
|
52
|
-
The session workspace is `~/.claude/projects/<slug>/osb/<session-uuid>/`. Read the env variable or the spec.md header if you need to confirm the exact path. Write absolute paths in tool calls (e.g. `/Users/<user>/.claude/projects/.../osb/<uuid>/meeting-todos.md`).
|
|
122
|
+
Update the same file across all updates — one file, evolving. Don't create `meeting-todos-1.md`, `meeting-todos-2.md`.
|
|
53
123
|
|
|
54
124
|
## On meeting end
|
|
55
125
|
|
|
56
|
-
When
|
|
57
|
-
-
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
Still silent. The user will ask out loud if they want a recap.
|
|
126
|
+
When `[MEETING — *]:` messages stop OR the system says `[SYSTEM] meeting ended`:
|
|
127
|
+
- Pull the full final transcript (step 2-4 above)
|
|
128
|
+
- Add a `## Summary` section at the top with 3-5 lines
|
|
129
|
+
- Mark resolved open questions
|
|
130
|
+
- The next user voice-native question may be "what was the meeting about?" — answer normally (speak) from the updated file
|
|
62
131
|
|
|
63
|
-
## When the user asks about the meeting
|
|
132
|
+
## When the user asks about the meeting in voice-native
|
|
64
133
|
|
|
65
|
-
When a non-meeting-tagged message references the meeting ("what's on the todo list?", "what did we decide about X?"
|
|
134
|
+
When a non-meeting-tagged voice message references the meeting ("what's on the todo list?", "what did we decide about X?"), respond normally — **speak** the answer. Read `meeting-todos.md` first to ground the response. If `meeting-todos.md` is empty or missing relevant detail, pull a fresh transcript first (steps 2-4) and update the file, then answer.
|
|
66
135
|
|
|
67
136
|
## Anti-patterns
|
|
68
137
|
|
|
69
|
-
- ❌
|
|
70
|
-
- ❌
|
|
71
|
-
- ❌
|
|
72
|
-
- ❌
|
|
73
|
-
- ❌
|
|
138
|
+
- ❌ Using `recall.ai` or `us-east-1.recall.ai` — always `us-west-2.recall.ai`
|
|
139
|
+
- ❌ Using `WebFetch` for the S3 download URL — use `curl` via `Bash` (the URL has weird chars + pre-signed query strings that confuse WebFetch)
|
|
140
|
+
- ❌ Pasting the full raw transcript into `meeting-todos.md`
|
|
141
|
+
- ❌ Speaking in response to `[MEETING — *]:` messages
|
|
142
|
+
- ❌ Asking clarifying questions during a live meeting
|
|
143
|
+
- ❌ Creating a new file per pull instead of updating one
|
|
144
|
+
- ❌ Re-pulling the bot record over and over inside one user turn — fetch once, parse once
|
|
145
|
+
- ❌ Echoing or printing `${RECALL_API_KEY}` value in your response
|
package/dist/index.js
CHANGED
|
@@ -147,6 +147,24 @@ process.on('uncaughtException', (error) => {
|
|
|
147
147
|
// ============================================================
|
|
148
148
|
// Module-level room code so the HTTP server can expose it via GET /room-code
|
|
149
149
|
let currentRoomCode = null;
|
|
150
|
+
// Module-level LiveKit connection state. Shared between main() (which runs the
|
|
151
|
+
// connect-with-retry loop) and the /health handler in startApiServer (which
|
|
152
|
+
// reports it to the frontend so the user sees a meaningful error instead of a
|
|
153
|
+
// dashboard redirect when LiveKit is unreachable / out of quota / etc).
|
|
154
|
+
//
|
|
155
|
+
// We deliberately do NOT 503 /health on connect failure — Fly's machine
|
|
156
|
+
// health-check uses /health, and returning non-2xx triggers a restart loop
|
|
157
|
+
// which (a) burns the same failing LiveKit calls every 30s and (b) gets the
|
|
158
|
+
// machine killed after 3 failed restarts. By staying 200 OK and surfacing the
|
|
159
|
+
// status as a field, we keep the container alive long enough for LiveKit to
|
|
160
|
+
// recover (auto-retry) or for the user to read the error and upgrade quota.
|
|
161
|
+
const livekitState = {
|
|
162
|
+
status: 'connecting',
|
|
163
|
+
error: null,
|
|
164
|
+
errorCode: null,
|
|
165
|
+
lastAttemptAt: null,
|
|
166
|
+
attemptCount: 0,
|
|
167
|
+
};
|
|
150
168
|
function startApiServer(workingDir, port) {
|
|
151
169
|
const server = createServer(async (req, res) => {
|
|
152
170
|
// CORS headers for cloud frontend
|
|
@@ -221,7 +239,22 @@ function startApiServer(workingDir, port) {
|
|
|
221
239
|
}
|
|
222
240
|
catch { /* version optional */ }
|
|
223
241
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
224
|
-
res.end(JSON.stringify({
|
|
242
|
+
res.end(JSON.stringify({
|
|
243
|
+
status: 'ok',
|
|
244
|
+
workingDir,
|
|
245
|
+
version,
|
|
246
|
+
// LiveKit subsystem status — frontend can use this to surface a real
|
|
247
|
+
// error instead of treating the sandbox as totally broken. The HTTP
|
|
248
|
+
// status code stays 200 so Fly health-check stays green and the
|
|
249
|
+
// container isn't restart-looped while LiveKit is unreachable.
|
|
250
|
+
livekit: {
|
|
251
|
+
status: livekitState.status,
|
|
252
|
+
error: livekitState.error,
|
|
253
|
+
errorCode: livekitState.errorCode,
|
|
254
|
+
attemptCount: livekitState.attemptCount,
|
|
255
|
+
lastAttemptAt: livekitState.lastAttemptAt,
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
225
258
|
return;
|
|
226
259
|
}
|
|
227
260
|
// POST /webhook/recall — Recall.ai real-time transcript webhooks
|
|
@@ -3931,22 +3964,87 @@ async function main() {
|
|
|
3931
3964
|
// ============================================================
|
|
3932
3965
|
// Connect to Room
|
|
3933
3966
|
// ============================================================
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3967
|
+
// Connect to LiveKit with retry-on-failure.
|
|
3968
|
+
//
|
|
3969
|
+
// Earlier behavior: a single attempt followed by `process.exit(1)` on error.
|
|
3970
|
+
// Combined with Fly's restart policy this produced a tight restart loop that
|
|
3971
|
+
// (a) hit the same 429 / auth error every ~30s, (b) burned the LiveKit
|
|
3972
|
+
// quota's retry budget, and (c) hit Fly's max-restart-count (3) and killed
|
|
3973
|
+
// the machine — at which point the frontend's /api/sandbox probe saw the
|
|
3974
|
+
// sandbox as failed and bounced the user back to the dashboard with no
|
|
3975
|
+
// useful error.
|
|
3976
|
+
//
|
|
3977
|
+
// New behavior: bounded-backoff retry, infinite attempts. The API server
|
|
3978
|
+
// stays up the whole time serving /health (which surfaces the LiveKit error
|
|
3979
|
+
// as a field, NOT as an HTTP failure — see the /health handler comment).
|
|
3980
|
+
// When the underlying issue is resolved (quota reset, key fixed, LiveKit
|
|
3981
|
+
// service back), the next retry succeeds and the agent picks up where it
|
|
3982
|
+
// left off without anyone needing to manually restart.
|
|
3983
|
+
//
|
|
3984
|
+
// Backoff: 5s → 10s → 20s → 40s → 60s (capped). Resets to 5s after each
|
|
3985
|
+
// successful connect (so a single transient hiccup doesn't disable fast
|
|
3986
|
+
// recovery on the next disconnect).
|
|
3987
|
+
const connectWithRetry = async () => {
|
|
3988
|
+
const backoffSchedule = [5_000, 10_000, 20_000, 40_000, 60_000];
|
|
3989
|
+
let backoffIdx = 0;
|
|
3990
|
+
while (true) {
|
|
3991
|
+
livekitState.status = livekitState.attemptCount === 0 ? 'connecting' : 'retrying';
|
|
3992
|
+
livekitState.lastAttemptAt = Date.now();
|
|
3993
|
+
livekitState.attemptCount += 1;
|
|
3994
|
+
try {
|
|
3995
|
+
await room.connect(livekitUrl, jwt, {
|
|
3996
|
+
autoSubscribe: true,
|
|
3997
|
+
dynacast: true,
|
|
3998
|
+
});
|
|
3999
|
+
localParticipant = room.localParticipant;
|
|
4000
|
+
livekitState.status = 'connected';
|
|
4001
|
+
livekitState.error = null;
|
|
4002
|
+
livekitState.errorCode = null;
|
|
4003
|
+
backoffIdx = 0;
|
|
4004
|
+
console.log('✅ Connected to room:', roomName);
|
|
4005
|
+
console.log('\n⏳ Waiting for user to connect...');
|
|
4006
|
+
console.log(` Room: ${roomCode}\n`);
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
catch (err) {
|
|
4010
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4011
|
+
// Categorize the error so the frontend can show specific guidance.
|
|
4012
|
+
// Substring matching on the message because LiveKit's rtc-node SDK
|
|
4013
|
+
// wraps the underlying HTTP status in a generic ConnectError.
|
|
4014
|
+
let errorCode;
|
|
4015
|
+
if (/429|connection minutes limit/i.test(msg))
|
|
4016
|
+
errorCode = 'quota_exceeded';
|
|
4017
|
+
else if (/401|403|unauthorized|invalid/i.test(msg))
|
|
4018
|
+
errorCode = 'auth';
|
|
4019
|
+
else if (/ENOTFOUND|ECONNREFUSED|ETIMEDOUT|network/i.test(msg))
|
|
4020
|
+
errorCode = 'network';
|
|
4021
|
+
else
|
|
4022
|
+
errorCode = 'unknown';
|
|
4023
|
+
livekitState.status = 'failed';
|
|
4024
|
+
livekitState.error = msg;
|
|
4025
|
+
livekitState.errorCode = errorCode;
|
|
4026
|
+
const waitMs = backoffSchedule[Math.min(backoffIdx, backoffSchedule.length - 1)];
|
|
4027
|
+
backoffIdx += 1;
|
|
4028
|
+
console.error(`❌ LiveKit connect failed (${errorCode}, attempt ${livekitState.attemptCount}): ${msg.substring(0, 200)}`);
|
|
4029
|
+
console.error(` Retrying in ${waitMs / 1000}s — process staying alive; /health remains 200 with livekit.status='failed'`);
|
|
4030
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
4031
|
+
// loop — try again
|
|
4032
|
+
}
|
|
4033
|
+
}
|
|
4034
|
+
};
|
|
4035
|
+
// Fire and forget; the retry loop keeps the process alive on its own (so
|
|
4036
|
+
// we don't need the explicit `new Promise(() => {})` keepalive anymore).
|
|
4037
|
+
// Errors that escape the retry loop should never happen, but if they do,
|
|
4038
|
+
// log them rather than crash.
|
|
4039
|
+
connectWithRetry().catch(err => {
|
|
4040
|
+
console.error('❌ Unrecoverable error in LiveKit retry loop (should not happen):', err);
|
|
4041
|
+
livekitState.status = 'failed';
|
|
4042
|
+
livekitState.error = err instanceof Error ? err.message : String(err);
|
|
4043
|
+
livekitState.errorCode = 'unrecoverable';
|
|
4044
|
+
});
|
|
4045
|
+
// Keep main() alive forever — without this the await chain ends and Node
|
|
4046
|
+
// exits 0, which Fly treats as a clean shutdown.
|
|
4047
|
+
await new Promise(() => { });
|
|
3950
4048
|
}
|
|
3951
4049
|
// Run
|
|
3952
4050
|
main().catch(console.error);
|
package/dist/recall-client.d.ts
CHANGED
|
@@ -84,20 +84,21 @@ export declare class RecallClient extends EventEmitter {
|
|
|
84
84
|
}): Promise<string>;
|
|
85
85
|
/**
|
|
86
86
|
* Fetch the bot's current transcript. Returns an array of "transcript turns"
|
|
87
|
-
* (each turn = one speaker's utterance) sorted by start time.
|
|
88
|
-
* `recordings[0].id` from getBotStatus / bot record to locate the recording,
|
|
89
|
-
* then list its transcripts.
|
|
87
|
+
* (each turn = one speaker's utterance) sorted by start time.
|
|
90
88
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* GET /api/v1/transcript/{transcript_id} → transcript with download_url
|
|
94
|
-
* Download the transcript JSON from download_url to get the actual content.
|
|
89
|
+
* Verified 2026-05-22 against the real us-west-2 API: there is NO simple
|
|
90
|
+
* `GET /bot/{id}/transcript` convenience endpoint. The actual chain is:
|
|
95
91
|
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
92
|
+
* 1. GET /api/v1/bot/{bot_id}
|
|
93
|
+
* 2. recordings[0].media_shortcuts.transcript.data.download_url (S3 signed URL)
|
|
94
|
+
* 3. GET that URL → JSON array of TranscriptTurn objects
|
|
95
|
+
*
|
|
96
|
+
* The S3 URL is pre-signed and expires (~6h). Re-fetch step 1 each poll;
|
|
97
|
+
* don't cache the URL.
|
|
98
|
+
*
|
|
99
|
+
* If `recordings[0]` doesn't exist yet (bot still joining or pre-recording),
|
|
100
|
+
* returns []. Caller (MeetingTranscriptPoller) treats that as "no new turns
|
|
101
|
+
* yet" and waits for the next tick.
|
|
101
102
|
*/
|
|
102
103
|
getTranscript(botId: string): Promise<TranscriptTurn[]>;
|
|
103
104
|
leaveMeeting(botId: string): Promise<void>;
|
package/dist/recall-client.js
CHANGED
|
@@ -66,30 +66,43 @@ export class RecallClient extends EventEmitter {
|
|
|
66
66
|
}
|
|
67
67
|
/**
|
|
68
68
|
* Fetch the bot's current transcript. Returns an array of "transcript turns"
|
|
69
|
-
* (each turn = one speaker's utterance) sorted by start time.
|
|
70
|
-
* `recordings[0].id` from getBotStatus / bot record to locate the recording,
|
|
71
|
-
* then list its transcripts.
|
|
69
|
+
* (each turn = one speaker's utterance) sorted by start time.
|
|
72
70
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* GET /api/v1/transcript/{transcript_id} → transcript with download_url
|
|
76
|
-
* Download the transcript JSON from download_url to get the actual content.
|
|
71
|
+
* Verified 2026-05-22 against the real us-west-2 API: there is NO simple
|
|
72
|
+
* `GET /bot/{id}/transcript` convenience endpoint. The actual chain is:
|
|
77
73
|
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
74
|
+
* 1. GET /api/v1/bot/{bot_id}
|
|
75
|
+
* 2. recordings[0].media_shortcuts.transcript.data.download_url (S3 signed URL)
|
|
76
|
+
* 3. GET that URL → JSON array of TranscriptTurn objects
|
|
77
|
+
*
|
|
78
|
+
* The S3 URL is pre-signed and expires (~6h). Re-fetch step 1 each poll;
|
|
79
|
+
* don't cache the URL.
|
|
80
|
+
*
|
|
81
|
+
* If `recordings[0]` doesn't exist yet (bot still joining or pre-recording),
|
|
82
|
+
* returns []. Caller (MeetingTranscriptPoller) treats that as "no new turns
|
|
83
|
+
* yet" and waits for the next tick.
|
|
83
84
|
*/
|
|
84
85
|
async getTranscript(botId) {
|
|
85
|
-
const
|
|
86
|
+
const botRes = await fetch(`${RECALL_BASE_URL}/bot/${botId}`, {
|
|
86
87
|
headers: { 'Authorization': `Token ${this.#apiKey}` },
|
|
87
88
|
});
|
|
88
|
-
if (!
|
|
89
|
-
const err = await
|
|
90
|
-
throw new Error(`Recall.ai
|
|
89
|
+
if (!botRes.ok) {
|
|
90
|
+
const err = await botRes.text().catch(() => '');
|
|
91
|
+
throw new Error(`Recall.ai bot fetch failed: ${botRes.status} ${err.substring(0, 200)}`);
|
|
92
|
+
}
|
|
93
|
+
const bot = await botRes.json();
|
|
94
|
+
const downloadUrl = bot.recordings?.[0]?.media_shortcuts?.transcript?.data?.download_url;
|
|
95
|
+
if (!downloadUrl) {
|
|
96
|
+
// Recording / transcript not ready yet — pre-call, just-joined, or
|
|
97
|
+
// recording_done event hasn't fired. Empty result is expected here.
|
|
98
|
+
return [];
|
|
99
|
+
}
|
|
100
|
+
const txRes = await fetch(downloadUrl);
|
|
101
|
+
if (!txRes.ok) {
|
|
102
|
+
const err = await txRes.text().catch(() => '');
|
|
103
|
+
throw new Error(`Recall.ai transcript download failed: ${txRes.status} ${err.substring(0, 200)}`);
|
|
91
104
|
}
|
|
92
|
-
const turns = await
|
|
105
|
+
const turns = await txRes.json();
|
|
93
106
|
return Array.isArray(turns) ? turns : [];
|
|
94
107
|
}
|
|
95
108
|
async leaveMeeting(botId) {
|