@1agh/maude 0.43.0 → 0.45.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 (126) 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/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -0,0 +1,132 @@
1
+ // generation/captions.ts — word-timestamp JSON → SRT / VTT, with a re-flow pass
2
+ // (DDR-164, Phase 2). SHARED by every speech-to-text provider: ElevenLabs Scribe
3
+ // (adapters/elevenlabs.ts) and local whisper.cpp (bin/_transcribe.mjs) both emit
4
+ // word-level timings; this is the single place they become subtitle cues, so the
5
+ // two never drift on line-length / timing rules.
6
+ //
7
+ // Dependency-free (mirrors photo/schema.ts, footage/schema.ts, generation/
8
+ // types.ts). Pure functions over plain data — no node:*, no provider SDK.
9
+
10
+ /** One word with second-based start/end timings (the STT-normalized shape). */
11
+ export interface CaptionWord {
12
+ text: string;
13
+ /** Seconds from the start of the audio. */
14
+ start: number;
15
+ end: number;
16
+ }
17
+
18
+ /** One rendered subtitle cue (a line or two of grouped words). */
19
+ export interface CaptionCue {
20
+ start: number;
21
+ end: number;
22
+ text: string;
23
+ }
24
+
25
+ export interface ReflowOptions {
26
+ /** Max characters per cue before a new cue starts (subtitle legibility). */
27
+ maxChars?: number;
28
+ /** Max seconds a single cue may span. */
29
+ maxDuration?: number;
30
+ /** A silence gap (seconds) between words that forces a cue break. */
31
+ gapBreak?: number;
32
+ }
33
+
34
+ const DEFAULT_REFLOW: Required<ReflowOptions> = {
35
+ maxChars: 42, // one comfortable subtitle line
36
+ maxDuration: 6,
37
+ gapBreak: 0.8,
38
+ };
39
+
40
+ /**
41
+ * Group word-level timings into readable cues — breaking on a max line length, a
42
+ * max duration, a silence gap, or sentence-ending punctuation. Deterministic and
43
+ * allocation-light so both STT paths produce identical output for identical input.
44
+ */
45
+ export function reflowWords(words: CaptionWord[], opts: ReflowOptions = {}): CaptionCue[] {
46
+ const o = { ...DEFAULT_REFLOW, ...opts };
47
+ const cues: CaptionCue[] = [];
48
+ let cur: CaptionWord[] = [];
49
+
50
+ const flush = (): void => {
51
+ if (cur.length === 0) return;
52
+ cues.push({
53
+ start: cur[0].start,
54
+ end: cur[cur.length - 1].end,
55
+ text: cur
56
+ .map((w) => w.text)
57
+ .join(' ')
58
+ .replace(/\s+([,.!?;:])/g, '$1') // no space before punctuation
59
+ .trim(),
60
+ });
61
+ cur = [];
62
+ };
63
+
64
+ for (const w of words) {
65
+ if (!w || typeof w.text !== 'string') continue;
66
+ const text = w.text.trim();
67
+ if (!text) continue;
68
+ const start = Number.isFinite(w.start) ? w.start : cur.length ? cur[cur.length - 1].end : 0;
69
+ const end = Number.isFinite(w.end) ? w.end : start;
70
+ const word: CaptionWord = { text, start, end };
71
+
72
+ if (cur.length > 0) {
73
+ const prev = cur[cur.length - 1];
74
+ const projected = cur.map((x) => x.text).join(' ').length + 1 + text.length;
75
+ const gap = start - prev.end;
76
+ const span = end - cur[0].start;
77
+ if (projected > o.maxChars || span > o.maxDuration || gap >= o.gapBreak) {
78
+ flush();
79
+ }
80
+ }
81
+ cur.push(word);
82
+ // Break AFTER sentence-ending punctuation so a cue ends on a natural clause.
83
+ if (/[.!?]$/.test(text)) flush();
84
+ }
85
+ flush();
86
+ return cues;
87
+ }
88
+
89
+ /** SRT timestamp — `HH:MM:SS,mmm`. */
90
+ function srtTime(sec: number): string {
91
+ return formatTime(sec, ',');
92
+ }
93
+ /** VTT timestamp — `HH:MM:SS.mmm`. */
94
+ function vttTime(sec: number): string {
95
+ return formatTime(sec, '.');
96
+ }
97
+ function formatTime(sec: number, msSep: ',' | '.'): string {
98
+ const s = Math.max(0, sec);
99
+ const h = Math.floor(s / 3600);
100
+ const m = Math.floor((s % 3600) / 60);
101
+ const ss = Math.floor(s % 60);
102
+ const ms = Math.round((s - Math.floor(s)) * 1000);
103
+ const pad = (n: number, w = 2) => String(n).padStart(w, '0');
104
+ return `${pad(h)}:${pad(m)}:${pad(ss)}${msSep}${pad(ms, 3)}`;
105
+ }
106
+
107
+ /** Render cues as an SRT document. */
108
+ export function cuesToSrt(cues: CaptionCue[]): string {
109
+ const body = cues
110
+ .map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}`)
111
+ .join('\n\n');
112
+ return `${body}\n`;
113
+ }
114
+
115
+ /** Render cues as a WebVTT document. */
116
+ export function cuesToVtt(cues: CaptionCue[]): string {
117
+ return (
118
+ 'WEBVTT\n\n' +
119
+ cues.map((c) => `${vttTime(c.start)} --> ${vttTime(c.end)}\n${c.text}`).join('\n\n') +
120
+ '\n'
121
+ );
122
+ }
123
+
124
+ /** Word timings → SRT in one call (reflow + render). */
125
+ export function wordsToSrt(words: CaptionWord[], opts?: ReflowOptions): string {
126
+ return cuesToSrt(reflowWords(words, opts));
127
+ }
128
+
129
+ /** Word timings → VTT in one call (reflow + render). */
130
+ export function wordsToVtt(words: CaptionWord[], opts?: ReflowOptions): string {
131
+ return cuesToVtt(reflowWords(words, opts));
132
+ }
@@ -0,0 +1,76 @@
1
+ // generation/download.ts — localize a produced GenAsset into the content-
2
+ // addressed `assets/<sha8>.<ext>` store (DDR-088), so nothing generated is ever
3
+ // referenced by an expiring provider URL or an un-sniffed blob.
4
+ //
5
+ // EVERY produced artifact goes through here before it is referenced anywhere:
6
+ // • inline bytes / base64 (sync providers like Gemini) → api.saveAsset(bytes),
7
+ // which magic-byte-sniffs + category-caps + content-addresses in ONE tested
8
+ // path (the same route drag-drop/paste upload uses).
9
+ // • an expiring provider URL (fal/Veo, async) → downloaded through the
10
+ // SSRF-hardened egress path (_fetch-asset.mjs discipline) THEN saveAsset.
11
+ // That URL path is added in Phase 1 (fal); Phase 0 (Gemini base64) never
12
+ // hits it, so it fails loud rather than shipping an unhardened fetch.
13
+ //
14
+ // The route/job wires `AdapterContext.localize` to `localizeGenAsset(asset, …)`.
15
+
16
+ import type { GenAsset } from './types.ts';
17
+
18
+ /** Minimal seam onto api.saveAsset so this module stays dependency-light. */
19
+ export interface LocalizeDeps {
20
+ /** api.saveAsset — magic-byte sniff + category cap + sha8 content-address. */
21
+ saveAsset(bytes: Uint8Array): Promise<{ ok: boolean; path?: string; error?: string }>;
22
+ }
23
+
24
+ /** Decode a base64 string into bytes (data: URL prefix tolerated). */
25
+ function decodeBase64(data: string): Uint8Array {
26
+ const comma = data.indexOf(',');
27
+ const b64 = data.startsWith('data:') && comma !== -1 ? data.slice(comma + 1) : data;
28
+ // Bun/Node Buffer decodes base64 without a browser atob dependency.
29
+ return new Uint8Array(Buffer.from(b64, 'base64'));
30
+ }
31
+
32
+ /**
33
+ * Localize one produced asset → returns the `assets/<sha8>.<ext>` rel path.
34
+ * Throws on an empty/rejected asset (saveAsset enforces the sniff + cap, so a
35
+ * provider that returns an SVG/HTML/script blob is rejected there).
36
+ */
37
+ export async function localizeGenAsset(asset: GenAsset, deps: LocalizeDeps): Promise<string> {
38
+ // A transcription result is caption TEXT (SRT/VTT), not media — it must never
39
+ // hit the magic-byte-sniffed media store (saveAsset would reject it anyway).
40
+ // Captions are consumed by the `maude design transcribe` verb (which writes the
41
+ // SRT/VTT directly) and, once wired, the EDL caption track — never localized as
42
+ // an `assets/<sha8>` media path here (Phase 2.2 / 2.3). Fail loud rather than
43
+ // silently drop it, so a premature transcription-through-the-media-queue is
44
+ // caught at the seam instead of producing a mislabelled asset.
45
+ if (asset.kind === 'transcription' || typeof asset.text === 'string') {
46
+ throw new Error(
47
+ 'transcription/caption output is not localized to the media asset store — ' +
48
+ 'it is handled by the transcribe verb / EDL caption track (Phase 2.2/2.3)'
49
+ );
50
+ }
51
+
52
+ let bytes: Uint8Array | null = null;
53
+
54
+ const maybeBase64 = (asset as unknown as { base64?: unknown }).base64;
55
+ if (asset.bytes && asset.bytes.byteLength > 0) {
56
+ bytes = asset.bytes;
57
+ } else if (typeof maybeBase64 === 'string') {
58
+ // Some adapters stash base64 under `.base64`; tolerate it.
59
+ bytes = decodeBase64(maybeBase64);
60
+ } else if (asset.url) {
61
+ throw new Error(
62
+ 'URL-result localization (expiring provider URL) is added in Phase 1 (fal) — ' +
63
+ 'the SSRF-hardened egress path is not wired yet; Phase 0 uses inline bytes only.'
64
+ );
65
+ }
66
+
67
+ if (!bytes || bytes.byteLength === 0) {
68
+ throw new Error('generation produced an empty asset');
69
+ }
70
+
71
+ const saved = await deps.saveAsset(bytes);
72
+ if (!saved.ok || !saved.path) {
73
+ throw new Error(`asset rejected on save: ${saved.error ?? 'unknown'}`);
74
+ }
75
+ return saved.path;
76
+ }
@@ -0,0 +1,223 @@
1
+ // generation/elevenlabs.test.ts — the ElevenLabs adapter proven per-capability
2
+ // against stubbed HTTP (no real key, no network). Audio verbs yield audio bytes;
3
+ // Scribe yields an SRT built from word timings.
4
+
5
+ import { afterEach, describe, expect, test } from 'bun:test';
6
+
7
+ import { createElevenLabsAdapter, parseHistory } from './adapters/elevenlabs.ts';
8
+ import { createAdapter, providersForModality } from './registry.ts';
9
+ import type { AdapterContext, GenRequest } from './types.ts';
10
+
11
+ const realFetch = globalThis.fetch;
12
+ afterEach(() => {
13
+ globalThis.fetch = realFetch;
14
+ });
15
+
16
+ function ctxWith(overrides: Partial<AdapterContext> = {}): AdapterContext {
17
+ return {
18
+ apiKey: 'sk-eleven-test',
19
+ localize: async () => 'assets/stub.mp3',
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ /** Capture the last request + return a canned response. */
25
+ function stub(makeRes: () => Response): { calls: Array<{ url: string; init: RequestInit }> } {
26
+ const calls: Array<{ url: string; init: RequestInit }> = [];
27
+ globalThis.fetch = (async (url: string, init: RequestInit) => {
28
+ calls.push({ url: String(url), init });
29
+ return makeRes();
30
+ }) as typeof fetch;
31
+ return { calls };
32
+ }
33
+
34
+ const MP3 = new Uint8Array([0xff, 0xfb, 0x90, 0x00, 1, 2, 3, 4]); // MP3 frame-sync-ish
35
+ function audioRes(): Response {
36
+ return new Response(MP3, { status: 200, headers: { 'content-type': 'audio/mpeg' } });
37
+ }
38
+
39
+ async function firstAsset(adapter: ReturnType<typeof createElevenLabsAdapter>, req: GenRequest) {
40
+ const job = await adapter.submit(req);
41
+ const res = await job.result();
42
+ return { job, res };
43
+ }
44
+
45
+ describe('elevenlabs audio', () => {
46
+ test('music POSTs /v1/music and yields audio bytes', async () => {
47
+ const { calls } = stub(audioRes);
48
+ const { job, res } = await firstAsset(createElevenLabsAdapter(ctxWith()), {
49
+ modality: 'audio',
50
+ provider: 'elevenlabs',
51
+ prompt: 'a warm lo-fi loop',
52
+ });
53
+ expect(job.status()).toBe('done');
54
+ expect(calls[0].url).toContain('/v1/music');
55
+ expect(res.assets[0].kind).toBe('audio');
56
+ expect(res.assets[0].bytes?.byteLength).toBe(MP3.byteLength);
57
+ // The key rides the header only, never the URL.
58
+ expect(String(calls[0].init.headers['xi-api-key'])).toBe('sk-eleven-test');
59
+ expect(calls[0].url).not.toContain('sk-eleven-test');
60
+ });
61
+
62
+ test('sfx POSTs /v1/sound-generation with a duration', async () => {
63
+ const { calls } = stub(audioRes);
64
+ await firstAsset(createElevenLabsAdapter(ctxWith()), {
65
+ modality: 'audio',
66
+ provider: 'elevenlabs',
67
+ prompt: 'glass shatter',
68
+ params: { audioKind: 'sfx', durationSeconds: 2 },
69
+ });
70
+ expect(calls[0].url).toContain('/v1/sound-generation');
71
+ expect(JSON.parse(String(calls[0].init.body)).duration_seconds).toBe(2);
72
+ });
73
+
74
+ test('tts POSTs /v1/text-to-speech/{voice} and requires a voice_id', async () => {
75
+ const { calls } = stub(audioRes);
76
+ await firstAsset(createElevenLabsAdapter(ctxWith()), {
77
+ modality: 'audio',
78
+ provider: 'elevenlabs',
79
+ prompt: 'Welcome to Maude',
80
+ params: { audioKind: 'tts', voice_id: 'Rachel' },
81
+ });
82
+ expect(calls[0].url).toContain('/v1/text-to-speech/Rachel');
83
+
84
+ const noVoice = createElevenLabsAdapter(ctxWith());
85
+ const job = await noVoice.submit({
86
+ modality: 'audio',
87
+ provider: 'elevenlabs',
88
+ prompt: 'x',
89
+ params: { audioKind: 'tts' },
90
+ });
91
+ await expect(job.result()).rejects.toThrow(/voice_id/);
92
+ });
93
+
94
+ test('a missing key fails fast without a network call', async () => {
95
+ let called = false;
96
+ globalThis.fetch = (async () => {
97
+ called = true;
98
+ return audioRes();
99
+ }) as typeof fetch;
100
+ const job = await createElevenLabsAdapter(ctxWith({ apiKey: null })).submit({
101
+ modality: 'audio',
102
+ provider: 'elevenlabs',
103
+ prompt: 'x',
104
+ });
105
+ await expect(job.result()).rejects.toThrow(/no ElevenLabs key/);
106
+ expect(called).toBe(false);
107
+ });
108
+
109
+ test('an HTTP error surfaces the provider detail, never the key', async () => {
110
+ stub(
111
+ () =>
112
+ new Response(JSON.stringify({ detail: { message: 'quota exceeded' } }), {
113
+ status: 429,
114
+ headers: { 'content-type': 'application/json' },
115
+ })
116
+ );
117
+ const job = await createElevenLabsAdapter(ctxWith()).submit({
118
+ modality: 'audio',
119
+ provider: 'elevenlabs',
120
+ prompt: 'x',
121
+ });
122
+ await expect(job.result()).rejects.toThrow(/quota exceeded/);
123
+ });
124
+ });
125
+
126
+ describe('elevenlabs scribe (transcription)', () => {
127
+ test('word timings become an SRT text asset', async () => {
128
+ stub(
129
+ () =>
130
+ new Response(
131
+ JSON.stringify({
132
+ text: 'Hello there.',
133
+ words: [
134
+ { text: 'Hello', start: 0, end: 0.4, type: 'word' },
135
+ { text: ' ', start: 0.4, end: 0.4, type: 'spacing' },
136
+ { text: 'there.', start: 0.4, end: 0.8, type: 'word' },
137
+ ],
138
+ }),
139
+ { status: 200, headers: { 'content-type': 'application/json' } }
140
+ )
141
+ );
142
+ const adapter = createElevenLabsAdapter(
143
+ ctxWith({
144
+ readSourceAsset: async () => ({ bytes: new Uint8Array([1, 2, 3]), mime: 'audio/mpeg' }),
145
+ })
146
+ );
147
+ const { res } = await firstAsset(adapter, {
148
+ modality: 'transcription',
149
+ provider: 'elevenlabs',
150
+ sourceAsset: 'assets/deadbeef.mp3',
151
+ });
152
+ const asset = res.assets[0];
153
+ expect(asset.kind).toBe('transcription');
154
+ expect(asset.text).toContain('00:00:00,000 --> 00:00:00,800');
155
+ expect(asset.text).toContain('Hello there.');
156
+ // The spacing token is dropped, not emitted as a word.
157
+ expect(asset.text).not.toContain('Hello there');
158
+ });
159
+
160
+ test('transcription without a source asset fails', async () => {
161
+ const job = await createElevenLabsAdapter(ctxWith()).submit({
162
+ modality: 'transcription',
163
+ provider: 'elevenlabs',
164
+ sourceAsset: 'assets/deadbeef.mp3',
165
+ });
166
+ // no readSourceAsset wired → hard error
167
+ await expect(job.result()).rejects.toThrow(/source-asset access/);
168
+ });
169
+ });
170
+
171
+ describe('registry', () => {
172
+ test('elevenlabs is registered for audio + transcription', () => {
173
+ expect(providersForModality('audio').some((p) => p.id === 'elevenlabs')).toBe(true);
174
+ expect(providersForModality('transcription').some((p) => p.id === 'elevenlabs')).toBe(true);
175
+ const adapter = createAdapter('elevenlabs', ctxWith());
176
+ expect(adapter.descriptor.id).toBe('elevenlabs');
177
+ });
178
+ });
179
+
180
+ describe('elevenlabs history (Task 2.5 — reuse-before-you-pay)', () => {
181
+ test('parseHistory maps items, drops id-less, converts date_unix → ISO', () => {
182
+ const items = parseHistory({
183
+ history: [
184
+ { history_item_id: 'h1', text: 'warm lofi loop', voice_id: 'v1', date_unix: 1_700_000_000 },
185
+ { text: 'no id — dropped' },
186
+ { history_item_id: 'h2', text: 'sfx whoosh' },
187
+ ],
188
+ });
189
+ expect(items.map((i) => i.id)).toEqual(['h1', 'h2']);
190
+ expect(items[0].text).toBe('warm lofi loop');
191
+ expect(items[0].voiceId).toBe('v1');
192
+ expect(items[0].at).toBe(new Date(1_700_000_000 * 1000).toISOString());
193
+ expect(items[1].at).toBeUndefined();
194
+ });
195
+
196
+ test('parseHistory tolerates an empty / missing history', () => {
197
+ expect(parseHistory({})).toEqual([]);
198
+ expect(parseHistory({ history: [] })).toEqual([]);
199
+ });
200
+
201
+ test('listHistory GETs /v1/history with the key in the header only', async () => {
202
+ const { calls } = stub(
203
+ () =>
204
+ new Response(JSON.stringify({ history: [{ history_item_id: 'h1', text: 'x' }] }), {
205
+ status: 200,
206
+ headers: { 'content-type': 'application/json' },
207
+ })
208
+ );
209
+ const items = await createElevenLabsAdapter(ctxWith()).listHistory?.();
210
+ expect(items?.[0].id).toBe('h1');
211
+ expect(calls[0].url).toContain('/v1/history');
212
+ expect(String(calls[0].init.headers['xi-api-key'])).toBe('sk-eleven-test');
213
+ expect(calls[0].url).not.toContain('sk-eleven-test');
214
+ });
215
+
216
+ test('fetchHistoryAudio GETs the item audio and yields audio bytes', async () => {
217
+ const { calls } = stub(audioRes);
218
+ const asset = await createElevenLabsAdapter(ctxWith()).fetchHistoryAudio?.('h1');
219
+ expect(asset?.kind).toBe('audio');
220
+ expect(asset?.bytes?.byteLength).toBe(MP3.byteLength);
221
+ expect(calls[0].url).toContain('/v1/history/h1/audio');
222
+ });
223
+ });