@1agh/maude 0.43.0 → 0.44.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 (122) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -0,0 +1,370 @@
1
+ // generation/adapters/elevenlabs.ts — ElevenLabs audio stack via the REST API
2
+ // (DDR-164, Phase 2). Direct BYOK: the user's own ElevenLabs key →
3
+ // api.elevenlabs.io directly, no aggregator, no SDK (plain `fetch`). ONE key
4
+ // covers the whole audio stack:
5
+ //
6
+ // • Music — POST /v1/music (prompt → mp3)
7
+ // • SFX — POST /v1/sound-generation (prompt → mp3)
8
+ // • TTS — POST /v1/text-to-speech/{voice} (text + voice_id → mp3)
9
+ // • Scribe — POST /v1/speech-to-text (audio → word-timestamps → SRT)
10
+ //
11
+ // The audio verbs return binary mp3 in ONE round trip → an already-`done` Job
12
+ // (callers never branch on sync/async), localized into assets/<sha8>.mp3.
13
+ // Scribe returns word-level timings that captions.ts turns into an SRT the EDL
14
+ // caption track consumes.
15
+ //
16
+ // Security mirrors the Gemini adapter (F1/F2): the key is sent ONLY in the
17
+ // `xi-api-key` header (never a query string → no redirect/log leak); the outbound
18
+ // base is https + host-allowlisted before the key-bearing request; and every
19
+ // response body is byte-capped before it is buffered (RAM-DoS guard).
20
+
21
+ import { wordsToSrt } from '../captions.ts';
22
+ import type {
23
+ AdapterContext,
24
+ GenAsset,
25
+ GenRequest,
26
+ GenResult,
27
+ HistoryAudioItem,
28
+ Job,
29
+ ModelDescriptor,
30
+ ProviderAdapter,
31
+ ProviderDescriptor,
32
+ } from '../types.ts';
33
+
34
+ const DEFAULT_BASE = 'https://api.elevenlabs.io';
35
+ const API_BASE = process.env.MAUDE_ELEVENLABS_API_BASE ?? DEFAULT_BASE;
36
+ const ALLOWED_HOST = 'api.elevenlabs.io';
37
+ const ALLOW_CUSTOM_BASE = /^(1|true|on|yes)$/i.test(process.env.MAUDE_GEN_ALLOW_CUSTOM_BASE ?? '');
38
+
39
+ // Hard ceiling on a provider response before it is buffered (F1 — the same guard
40
+ // the Gemini adapter added). Audio is heavier than a JSON image, so the cap is
41
+ // higher; still bounded against a wedged/hostile upstream.
42
+ const MAX_RESPONSE_BYTES = Math.max(
43
+ 1024 * 1024,
44
+ Number(process.env.MAUDE_ELEVENLABS_MAX_RESPONSE_BYTES) || 128 * 1024 * 1024
45
+ );
46
+
47
+ /** Validate the outbound base ONCE (https + fixed host allowlist), F1/F2. */
48
+ function assertSafeBase(base: string): void {
49
+ let u: URL;
50
+ try {
51
+ u = new URL(base);
52
+ } catch {
53
+ throw new Error('ElevenLabs API base is not a valid URL');
54
+ }
55
+ if (u.protocol !== 'https:') throw new Error('ElevenLabs API base must be https');
56
+ if (u.hostname !== ALLOWED_HOST && !ALLOW_CUSTOM_BASE) {
57
+ throw new Error(
58
+ `ElevenLabs API base host ${u.hostname} is not allowlisted (set MAUDE_GEN_ALLOW_CUSTOM_BASE=1 to override)`
59
+ );
60
+ }
61
+ }
62
+
63
+ /** Read a Response body into bytes with a hard cap (F1). */
64
+ async function readBytesCapped(res: Response, maxBytes: number): Promise<Uint8Array> {
65
+ const declared = Number(res.headers.get('content-length'));
66
+ if (Number.isFinite(declared) && declared > maxBytes) {
67
+ throw new Error(`ElevenLabs response too large (${declared} > ${maxBytes} bytes)`);
68
+ }
69
+ const body = res.body;
70
+ if (!body) return new Uint8Array(await res.arrayBuffer());
71
+ const reader = body.getReader();
72
+ const chunks: Uint8Array[] = [];
73
+ let total = 0;
74
+ for (;;) {
75
+ const { done, value } = await reader.read();
76
+ if (done) break;
77
+ if (value) {
78
+ total += value.byteLength;
79
+ if (total > maxBytes) {
80
+ await reader.cancel().catch(() => {});
81
+ throw new Error(`ElevenLabs response exceeded ${maxBytes} bytes`);
82
+ }
83
+ chunks.push(value);
84
+ }
85
+ }
86
+ return new Uint8Array(Buffer.concat(chunks));
87
+ }
88
+
89
+ /** Read a capped body as text (for the JSON STT response + error messages). */
90
+ async function readTextCapped(res: Response, maxBytes: number): Promise<string> {
91
+ return new TextDecoder().decode(await readBytesCapped(res, maxBytes));
92
+ }
93
+
94
+ export const ELEVENLABS_DESCRIPTOR: ProviderDescriptor = {
95
+ id: 'elevenlabs',
96
+ label: 'ElevenLabs (audio)',
97
+ kind: 'cloud',
98
+ auth: 'api-key',
99
+ keychainService: 'com.maude.app.elevenlabs',
100
+ modalities: ['audio', 'transcription'],
101
+ keyUrl: 'https://elevenlabs.io/app/settings/api-keys',
102
+ notes:
103
+ 'Music, sound effects, text-to-speech, and Scribe speech-to-text — one key for the whole stack, billing your own ElevenLabs account. Music carries commercial-rights tiers (check your plan) and rejects prompts naming artists, songs, or lyrics. Voice cloning requires the voice owner’s consent.',
104
+ };
105
+
106
+ export const ELEVENLABS_MODELS: ModelDescriptor[] = [
107
+ { id: 'eleven_music', label: 'Music', modality: 'audio', costNote: 'per second', sync: true },
108
+ { id: 'eleven_text_to_sound_v2', label: 'Sound effects', modality: 'audio', sync: true },
109
+ {
110
+ id: 'eleven_v3',
111
+ label: 'Text to speech (v3)',
112
+ modality: 'audio',
113
+ costNote: 'per character',
114
+ sync: true,
115
+ },
116
+ {
117
+ id: 'eleven_multilingual_v2',
118
+ label: 'Text to speech (multilingual v2)',
119
+ modality: 'audio',
120
+ sync: true,
121
+ },
122
+ { id: 'scribe_v1', label: 'Scribe (speech-to-text)', modality: 'transcription', sync: true },
123
+ ];
124
+
125
+ /** Which audio verb a request wants — from params.audioKind, else inferred. */
126
+ type AudioKind = 'music' | 'sfx' | 'tts';
127
+ function resolveAudioKind(req: GenRequest): AudioKind {
128
+ const explicit = req.params?.audioKind;
129
+ if (explicit === 'music' || explicit === 'sfx' || explicit === 'tts') return explicit;
130
+ // Infer: a voice_id means speech; otherwise default to music (the flagship).
131
+ if (req.params?.voice_id) return 'tts';
132
+ return 'music';
133
+ }
134
+
135
+ const num = (v: unknown): number | undefined =>
136
+ typeof v === 'number' && Number.isFinite(v) ? v : undefined;
137
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
138
+
139
+ interface ScribeWord {
140
+ text?: string;
141
+ start?: number;
142
+ end?: number;
143
+ type?: string;
144
+ }
145
+ interface ScribeResponse {
146
+ text?: string;
147
+ words?: ScribeWord[];
148
+ detail?: unknown;
149
+ }
150
+ interface ErrorResponse {
151
+ detail?: { message?: string } | string;
152
+ }
153
+
154
+ // Task 2.5 — ElevenLabs History (GET /v1/history). The user's re-downloadable
155
+ // past generations; matching + reuse spends NO credit (already paid).
156
+ interface HistoryItemRaw {
157
+ history_item_id?: string;
158
+ text?: string;
159
+ voice_id?: string;
160
+ date_unix?: number;
161
+ }
162
+ interface HistoryResponseRaw {
163
+ history?: HistoryItemRaw[];
164
+ }
165
+
166
+ /** Pure History-response → HistoryAudioItem[] (exported for unit test). */
167
+ export function parseHistory(json: HistoryResponseRaw): HistoryAudioItem[] {
168
+ const items = Array.isArray(json?.history) ? json.history : [];
169
+ const out: HistoryAudioItem[] = [];
170
+ for (const it of items) {
171
+ const id = typeof it.history_item_id === 'string' ? it.history_item_id : '';
172
+ if (!id) continue;
173
+ out.push({
174
+ id,
175
+ text: typeof it.text === 'string' ? it.text : '',
176
+ voiceId: typeof it.voice_id === 'string' ? it.voice_id : undefined,
177
+ at:
178
+ typeof it.date_unix === 'number' && Number.isFinite(it.date_unix)
179
+ ? new Date(it.date_unix * 1000).toISOString()
180
+ : undefined,
181
+ });
182
+ }
183
+ return out;
184
+ }
185
+
186
+ /** A sync Job wrapper — already resolved (mirrors the Gemini adapter). */
187
+ function doneJob(id: string, result: Promise<GenResult>): Job {
188
+ let settled: 'done' | 'failed' = 'done';
189
+ const guarded = result.catch((err) => {
190
+ settled = 'failed';
191
+ throw err;
192
+ });
193
+ return {
194
+ id,
195
+ status: () => settled,
196
+ async *events() {
197
+ try {
198
+ await guarded;
199
+ yield { status: 'done' as const };
200
+ } catch (err) {
201
+ yield {
202
+ status: 'failed' as const,
203
+ message: err instanceof Error ? err.message : String(err),
204
+ };
205
+ }
206
+ },
207
+ result: () => guarded,
208
+ cancel: () => {},
209
+ };
210
+ }
211
+
212
+ export function createElevenLabsAdapter(ctx: AdapterContext): ProviderAdapter {
213
+ function requireKey(): string {
214
+ if (!ctx.apiKey) throw new Error('no ElevenLabs key configured — add one in Settings');
215
+ return ctx.apiKey;
216
+ }
217
+
218
+ /** POST JSON, expect a binary audio body → an audio GenAsset. */
219
+ async function postForAudio(path: string, body: unknown): Promise<GenAsset> {
220
+ assertSafeBase(API_BASE);
221
+ const res = await fetch(`${API_BASE}${path}`, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json', 'xi-api-key': requireKey() },
224
+ body: JSON.stringify(body),
225
+ signal: ctx.signal ?? AbortSignal.timeout(180_000),
226
+ });
227
+ if (!res.ok) {
228
+ // Error bodies are JSON; the key is never echoed in them.
229
+ let msg = `HTTP ${res.status}`;
230
+ try {
231
+ const j = JSON.parse(await readTextCapped(res, 64 * 1024)) as ErrorResponse;
232
+ const d = j.detail;
233
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
234
+ } catch {
235
+ /* keep the status message */
236
+ }
237
+ throw new Error(`ElevenLabs error: ${msg}`);
238
+ }
239
+ const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
240
+ if (bytes.byteLength === 0) throw new Error('ElevenLabs returned an empty audio body');
241
+ return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
242
+ }
243
+
244
+ async function runAudio(req: GenRequest): Promise<GenResult> {
245
+ const started = Date.now();
246
+ const kind = resolveAudioKind(req);
247
+ const prompt = req.prompt ?? '';
248
+ let asset: GenAsset;
249
+ if (kind === 'tts') {
250
+ const voiceId = str(req.params?.voice_id);
251
+ if (!voiceId) throw new Error('text-to-speech requires a params.voice_id');
252
+ const modelId = str(req.params?.model_id) ?? str(req.model) ?? 'eleven_v3';
253
+ asset = await postForAudio(`/v1/text-to-speech/${encodeURIComponent(voiceId)}`, {
254
+ text: prompt,
255
+ model_id: modelId,
256
+ });
257
+ } else if (kind === 'sfx') {
258
+ const duration = num(req.params?.durationSeconds);
259
+ asset = await postForAudio('/v1/sound-generation', {
260
+ text: prompt,
261
+ ...(duration ? { duration_seconds: duration } : {}),
262
+ });
263
+ } else {
264
+ // music
265
+ const ms = num(req.params?.durationSeconds);
266
+ asset = await postForAudio('/v1/music', {
267
+ prompt,
268
+ ...(ms ? { music_length_ms: Math.round(ms * 1000) } : {}),
269
+ });
270
+ }
271
+ return { assets: [asset], usage: { ms: Date.now() - started } };
272
+ }
273
+
274
+ async function runTranscription(req: GenRequest): Promise<GenResult> {
275
+ const started = Date.now();
276
+ if (!req.sourceAsset) throw new Error('transcription requires a sourceAsset (an audio/video)');
277
+ if (!ctx.readSourceAsset) throw new Error('transcription requires source-asset access');
278
+ const src = await ctx.readSourceAsset(req.sourceAsset);
279
+ if (!src) throw new Error(`source asset not found or unreadable: ${req.sourceAsset}`);
280
+
281
+ assertSafeBase(API_BASE);
282
+ // Multipart: the audio file + model_id. Blob keeps the bytes out of a query.
283
+ // Copy to a plain ArrayBuffer (a valid BlobPart; also normalizes byteOffset).
284
+ const buf = src.bytes.buffer.slice(
285
+ src.bytes.byteOffset,
286
+ src.bytes.byteOffset + src.bytes.byteLength
287
+ ) as ArrayBuffer;
288
+ const form = new FormData();
289
+ form.append('file', new Blob([buf], { type: src.mime }), 'audio');
290
+ form.append('model_id', str(req.model) ?? 'scribe_v1');
291
+ const res = await fetch(`${API_BASE}/v1/speech-to-text`, {
292
+ method: 'POST',
293
+ headers: { 'xi-api-key': requireKey() }, // FormData sets its own content-type boundary
294
+ body: form,
295
+ signal: ctx.signal ?? AbortSignal.timeout(300_000),
296
+ });
297
+ const text = await readTextCapped(res, MAX_RESPONSE_BYTES);
298
+ if (!res.ok) {
299
+ let msg = `HTTP ${res.status}`;
300
+ try {
301
+ const j = JSON.parse(text) as ErrorResponse;
302
+ const d = j.detail;
303
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
304
+ } catch {
305
+ /* keep status */
306
+ }
307
+ throw new Error(`ElevenLabs Scribe error: ${msg}`);
308
+ }
309
+ const json = JSON.parse(text) as ScribeResponse;
310
+ // Scribe returns word-level entries with `type` ('word' | 'spacing'); keep words.
311
+ const words = (json.words ?? [])
312
+ .filter((w) => (w.type ?? 'word') === 'word' && typeof w.text === 'string')
313
+ .map((w) => ({ text: String(w.text), start: Number(w.start) || 0, end: Number(w.end) || 0 }));
314
+ const srt = wordsToSrt(words);
315
+ const asset: GenAsset = { kind: 'transcription', mime: 'application/x-subrip', text: srt };
316
+ return { assets: [asset], usage: { ms: Date.now() - started }, raw: { text: json.text } };
317
+ }
318
+
319
+ // Task 2.5 — the user's re-usable audio history. Re-downloading spends NO
320
+ // credit (already paid). Rate-limited LIST → the route caches the listing;
321
+ // the audio GET localizes through the host's saveAsset (magic-byte sniff).
322
+ async function listHistory(): Promise<HistoryAudioItem[]> {
323
+ assertSafeBase(API_BASE);
324
+ const res = await fetch(`${API_BASE}/v1/history?page_size=100`, {
325
+ headers: { 'xi-api-key': requireKey() },
326
+ signal: ctx.signal ?? AbortSignal.timeout(30_000),
327
+ });
328
+ const text = await readTextCapped(res, 8 * 1024 * 1024);
329
+ if (!res.ok) {
330
+ let msg = `HTTP ${res.status}`;
331
+ try {
332
+ const j = JSON.parse(text) as ErrorResponse;
333
+ const d = j.detail;
334
+ msg = (typeof d === 'string' ? d : d?.message) ?? msg;
335
+ } catch {
336
+ /* keep status */
337
+ }
338
+ throw new Error(`ElevenLabs history error: ${msg}`);
339
+ }
340
+ return parseHistory(JSON.parse(text) as HistoryResponseRaw);
341
+ }
342
+
343
+ async function fetchHistoryAudio(id: string): Promise<GenAsset> {
344
+ // The id is provider-scoped and used ONLY in a fixed, non-interpolated path
345
+ // segment (encodeURIComponent) — no query, no key leak.
346
+ assertSafeBase(API_BASE);
347
+ const res = await fetch(`${API_BASE}/v1/history/${encodeURIComponent(id)}/audio`, {
348
+ headers: { 'xi-api-key': requireKey() },
349
+ signal: ctx.signal ?? AbortSignal.timeout(120_000),
350
+ });
351
+ if (!res.ok) throw new Error(`ElevenLabs history audio error: HTTP ${res.status}`);
352
+ const bytes = await readBytesCapped(res, MAX_RESPONSE_BYTES);
353
+ if (bytes.byteLength === 0) throw new Error('ElevenLabs history returned an empty audio body');
354
+ return { kind: 'audio', mime: res.headers.get('content-type') || 'audio/mpeg', bytes };
355
+ }
356
+
357
+ return {
358
+ descriptor: ELEVENLABS_DESCRIPTOR,
359
+ async listModels() {
360
+ return ELEVENLABS_MODELS;
361
+ },
362
+ async submit(req: GenRequest): Promise<Job> {
363
+ const id = `gen_${crypto.randomUUID()}`;
364
+ const run = req.modality === 'transcription' ? runTranscription(req) : runAudio(req);
365
+ return doneJob(id, run);
366
+ },
367
+ listHistory,
368
+ fetchHistoryAudio,
369
+ };
370
+ }