diff options
Diffstat (limited to 'packages/tool-youtube-transcript/src')
| -rw-r--r-- | packages/tool-youtube-transcript/src/client.test.ts | 218 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/client.ts | 92 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/extension.test.ts | 172 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/extension.ts | 22 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/format.test.ts | 206 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/format.ts | 98 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/index.ts | 32 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/tool.test.ts | 278 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/tool.ts | 168 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/validate.test.ts | 54 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/validate.ts | 24 |
11 files changed, 682 insertions, 682 deletions
diff --git a/packages/tool-youtube-transcript/src/client.test.ts b/packages/tool-youtube-transcript/src/client.test.ts index a33f44a..1a8b7b8 100644 --- a/packages/tool-youtube-transcript/src/client.test.ts +++ b/packages/tool-youtube-transcript/src/client.test.ts @@ -2,133 +2,133 @@ import { describe, expect, it } from "vitest"; import { createTranscriptClient, type FetchLike } from "./client.js"; function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json" }, - }); + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); } interface CapturedCall { - url: string; - method?: string | undefined; + url: string; + method?: string | undefined; } /** Builds a fake fetch that returns scripted responses in order, capturing each call. */ function makeFetch(responses: Response[]): { fetchFn: FetchLike; calls: CapturedCall[] } { - const calls: CapturedCall[] = []; - let i = 0; - const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - calls.push({ url, method: init?.method }); - return responses[i++] ?? jsonResponse({}); - }) as unknown as FetchLike; - return { fetchFn, calls }; + const calls: CapturedCall[] = []; + let i = 0; + const fetchFn: FetchLike = (async (input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + calls.push({ url, method: init?.method }); + return responses[i++] ?? jsonResponse({}); + }) as unknown as FetchLike; + return { fetchFn, calls }; } const BASE = "http://test-transcriber.local"; const signal = (): AbortSignal => new AbortController().signal; describe("createTranscriptClient.getTranscript", () => { - it("sends GET /api/transcript?url=...", async () => { - const { fetchFn, calls } = makeFetch([ - jsonResponse({ - status: "completed", - video_id: "v1", - full_text: "", - segments: [], - }), - ]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - await client.getTranscript("https://youtu.be/v1", signal()); + it("sends GET /api/transcript?url=...", async () => { + const { fetchFn, calls } = makeFetch([ + jsonResponse({ + status: "completed", + video_id: "v1", + full_text: "", + segments: [], + }), + ]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + await client.getTranscript("https://youtu.be/v1", signal()); - const call = calls[0]; - if (!call) throw new Error("no call captured"); - expect(call.url).toBe( - `${BASE}/api/transcript?url=${encodeURIComponent("https://youtu.be/v1")}`, - ); - expect(call.method).toBe("GET"); - }); + const call = calls[0]; + if (!call) throw new Error("no call captured"); + expect(call.url).toBe( + `${BASE}/api/transcript?url=${encodeURIComponent("https://youtu.be/v1")}`, + ); + expect(call.method).toBe("GET"); + }); - it("returns completed response", async () => { - const body = { - status: "completed" as const, - video_id: "v1", - full_text: "hi", - segments: [{ text: "hi", start: 0, duration: 1 }], - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns completed response", async () => { + const body = { + status: "completed" as const, + video_id: "v1", + full_text: "hi", + segments: [{ text: "hi", start: 0, duration: 1 }], + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("returns queued response", async () => { - const body = { - status: "queued" as const, - video_id: "v1", - position: 2, - estimated_seconds: 60, - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns queued response", async () => { + const body = { + status: "queued" as const, + video_id: "v1", + position: 2, + estimated_seconds: 60, + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("returns failed response", async () => { - const body = { - status: "failed" as const, - video_id: "v1", - error: "boom", - error_type: "DownloadError", - }; - const { fetchFn } = makeFetch([jsonResponse(body)]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - const result = await client.getTranscript("https://youtu.be/v1", signal()); - expect(result).toEqual(body); - }); + it("returns failed response", async () => { + const body = { + status: "failed" as const, + video_id: "v1", + error: "boom", + error_type: "DownloadError", + }; + const { fetchFn } = makeFetch([jsonResponse(body)]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + const result = await client.getTranscript("https://youtu.be/v1", signal()); + expect(result).toEqual(body); + }); - it("throws on HTTP error", async () => { - const { fetchFn } = makeFetch([ - new Response("not found", { status: 404, statusText: "Not Found" }), - ]); - const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); - await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow("HTTP 404"); - }); + it("throws on HTTP error", async () => { + const { fetchFn } = makeFetch([ + new Response("not found", { status: 404, statusText: "Not Found" }), + ]); + const client = createTranscriptClient({ baseUrl: BASE, fetchFn }); + await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow("HTTP 404"); + }); - it("throws on timeout", async () => { - const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => - new Promise<Response>((_resolve, reject) => { - const sig = init?.signal; - if (!sig) return; - sig.addEventListener("abort", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - reject(err); - }); - })) as unknown as FetchLike; - const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 10 }); - await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow( - "timed out", - ); - }); + it("throws on timeout", async () => { + const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => + new Promise<Response>((_resolve, reject) => { + const sig = init?.signal; + if (!sig) return; + sig.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + })) as unknown as FetchLike; + const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 10 }); + await expect(client.getTranscript("https://youtu.be/v1", signal())).rejects.toThrow( + "timed out", + ); + }); - it("respects abort signal", async () => { - const controller = new AbortController(); - const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => - new Promise<Response>((_resolve, reject) => { - const sig = init?.signal; - if (!sig) return; - sig.addEventListener("abort", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - reject(err); - }); - })) as unknown as FetchLike; - const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 30_000 }); - const promise = client.getTranscript("https://youtu.be/v1", controller.signal); - controller.abort(); - await expect(promise).rejects.toThrow("aborted"); - }); + it("respects abort signal", async () => { + const controller = new AbortController(); + const fetchFn: FetchLike = ((_input: string | URL | Request, init?: RequestInit) => + new Promise<Response>((_resolve, reject) => { + const sig = init?.signal; + if (!sig) return; + sig.addEventListener("abort", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + reject(err); + }); + })) as unknown as FetchLike; + const client = createTranscriptClient({ baseUrl: BASE, fetchFn, timeoutMs: 30_000 }); + const promise = client.getTranscript("https://youtu.be/v1", controller.signal); + controller.abort(); + await expect(promise).rejects.toThrow("aborted"); + }); }); diff --git a/packages/tool-youtube-transcript/src/client.ts b/packages/tool-youtube-transcript/src/client.ts index a088d7d..576eb3a 100644 --- a/packages/tool-youtube-transcript/src/client.ts +++ b/packages/tool-youtube-transcript/src/client.ts @@ -18,13 +18,13 @@ export const DEFAULT_BASE_URL = "http://100.102.55.49:41090"; export const DEFAULT_TIMEOUT_MS = 30_000; export interface TranscriptClient { - readonly getTranscript: (url: string, signal: AbortSignal) => Promise<TranscriptResponse>; + readonly getTranscript: (url: string, signal: AbortSignal) => Promise<TranscriptResponse>; } export interface TranscriptClientDeps { - readonly baseUrl: string; - readonly fetchFn: FetchLike; - readonly timeoutMs?: number; + readonly baseUrl: string; + readonly fetchFn: FetchLike; + readonly timeoutMs?: number; } /** @@ -34,47 +34,47 @@ export interface TranscriptClientDeps { * is combined with the caller's cancellation signal via `AbortSignal.any`. */ export function createTranscriptClient(deps: TranscriptClientDeps): TranscriptClient { - const baseUrl = deps.baseUrl; - const fetchFn = deps.fetchFn; - const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const baseUrl = deps.baseUrl; + const fetchFn = deps.fetchFn; + const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS; - return { - async getTranscript(url: string, signal: AbortSignal): Promise<TranscriptResponse> { - const endpoint = `${baseUrl}/api/transcript?url=${encodeURIComponent(url)}`; - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - const combined = AbortSignal.any([signal, controller.signal]); - try { - let response: Response; - try { - response = await fetchFn(endpoint, { - method: "GET", - headers: { Accept: "application/json" }, - signal: combined, - }); - } catch (err) { - if (signal.aborted) { - throw new Error("Request aborted."); - } - if (controller.signal.aborted) { - throw new Error(`Transcriber request timed out after ${timeoutMs / 1000} seconds.`); - } - throw err; - } - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, - ); - } - try { - return (await response.json()) as TranscriptResponse; - } catch { - throw new Error("Failed to parse transcriber response as JSON"); - } - } finally { - clearTimeout(timeout); - } - }, - }; + return { + async getTranscript(url: string, signal: AbortSignal): Promise<TranscriptResponse> { + const endpoint = `${baseUrl}/api/transcript?url=${encodeURIComponent(url)}`; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + const combined = AbortSignal.any([signal, controller.signal]); + try { + let response: Response; + try { + response = await fetchFn(endpoint, { + method: "GET", + headers: { Accept: "application/json" }, + signal: combined, + }); + } catch (err) { + if (signal.aborted) { + throw new Error("Request aborted."); + } + if (controller.signal.aborted) { + throw new Error(`Transcriber request timed out after ${timeoutMs / 1000} seconds.`); + } + throw err; + } + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error( + `HTTP ${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, + ); + } + try { + return (await response.json()) as TranscriptResponse; + } catch { + throw new Error("Failed to parse transcriber response as JSON"); + } + } finally { + clearTimeout(timeout); + } + }, + }; } diff --git a/packages/tool-youtube-transcript/src/extension.test.ts b/packages/tool-youtube-transcript/src/extension.test.ts index 70cf227..7b5c23b 100644 --- a/packages/tool-youtube-transcript/src/extension.test.ts +++ b/packages/tool-youtube-transcript/src/extension.test.ts @@ -3,116 +3,116 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { activate, extension, manifest } from "./extension.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } function makeFakeHost(): { host: HostAPI; defineTool: ReturnType<typeof vi.fn> } { - const defineTool = vi.fn(); - const host = { - defineTool, - logger: { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - span: vi.fn(() => ({ end: vi.fn() })), - }, - } as unknown as HostAPI; - return { host, defineTool }; + const defineTool = vi.fn(); + const host = { + defineTool, + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + span: vi.fn(() => ({ end: vi.fn() })), + }, + } as unknown as HostAPI; + return { host, defineTool }; } const ORIG_FETCH = globalThis.fetch; const ORIG_ENV = process.env.YOUTUBE_TRANSCRIBER_URL; function restoreEnv(): void { - if (ORIG_ENV === undefined) { - delete process.env.YOUTUBE_TRANSCRIBER_URL; - } else { - process.env.YOUTUBE_TRANSCRIBER_URL = ORIG_ENV; - } + if (ORIG_ENV === undefined) { + delete process.env.YOUTUBE_TRANSCRIBER_URL; + } else { + process.env.YOUTUBE_TRANSCRIBER_URL = ORIG_ENV; + } } afterEach(() => { - globalThis.fetch = ORIG_FETCH; - restoreEnv(); + globalThis.fetch = ORIG_FETCH; + restoreEnv(); }); function stubFetchCapture(): { calls: Array<{ url: string }> } { - const calls: Array<{ url: string }> = []; - globalThis.fetch = vi.fn(async (input: string | URL | Request) => { - calls.push({ url: String(input) }); - return new Response( - JSON.stringify({ - status: "completed", - video_id: "v", - full_text: "", - segments: [], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); - }) as unknown as typeof globalThis.fetch; - return { calls }; + const calls: Array<{ url: string }> = []; + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + calls.push({ url: String(input) }); + return new Response( + JSON.stringify({ + status: "completed", + video_id: "v", + full_text: "", + segments: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }) as unknown as typeof globalThis.fetch; + return { calls }; } describe("tool-youtube-transcript activation", () => { - it("registers the 'youtube_transcript' tool (defineTool called)", () => { - const { host, defineTool } = makeFakeHost(); - activate(host); - expect(defineTool).toHaveBeenCalledTimes(1); - const registered = defineTool.mock.calls[0]?.[0]; - if (!registered) throw new Error("no tool registered"); - expect(registered.name).toBe("youtube_transcript"); - expect(registered.concurrencySafe).toBe(true); - }); + it("registers the 'youtube_transcript' tool (defineTool called)", () => { + const { host, defineTool } = makeFakeHost(); + activate(host); + expect(defineTool).toHaveBeenCalledTimes(1); + const registered = defineTool.mock.calls[0]?.[0]; + if (!registered) throw new Error("no tool registered"); + expect(registered.name).toBe("youtube_transcript"); + expect(registered.concurrencySafe).toBe(true); + }); - it("uses YOUTUBE_TRANSCRIBER_URL from env", async () => { - process.env.YOUTUBE_TRANSCRIBER_URL = "http://env-transcriber.local"; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses YOUTUBE_TRANSCRIBER_URL from env", async () => { + process.env.YOUTUBE_TRANSCRIBER_URL = "http://env-transcriber.local"; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("http://env-transcriber.local/api/transcript?url="); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("http://env-transcriber.local/api/transcript?url="); + }); - it("uses default base URL when env unset", async () => { - delete process.env.YOUTUBE_TRANSCRIBER_URL; - const { calls } = stubFetchCapture(); - const { host, defineTool } = makeFakeHost(); - activate(host); + it("uses default base URL when env unset", async () => { + delete process.env.YOUTUBE_TRANSCRIBER_URL; + const { calls } = stubFetchCapture(); + const { host, defineTool } = makeFakeHost(); + activate(host); - const tool = defineTool.mock.calls[0]?.[0]; - if (!tool) throw new Error("no tool registered"); - await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(calls.length).toBeGreaterThan(0); - expect(calls[0]?.url).toContain("100.102.55.49:41090/api/transcript?url="); - }); + const tool = defineTool.mock.calls[0]?.[0]; + if (!tool) throw new Error("no tool registered"); + await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0]?.url).toContain("100.102.55.49:41090/api/transcript?url="); + }); }); describe("tool-youtube-transcript manifest", () => { - it("declares network capability + youtube_transcript contribution", () => { - expect(manifest.id).toBe("tool-youtube-transcript"); - expect(manifest.capabilities).toEqual({ network: true }); - expect(manifest.contributes).toEqual({ tools: ["youtube_transcript"] }); - expect(manifest.trust).toBe("bundled"); - expect(manifest.activation).toBe("eager"); - }); + it("declares network capability + youtube_transcript contribution", () => { + expect(manifest.id).toBe("tool-youtube-transcript"); + expect(manifest.capabilities).toEqual({ network: true }); + expect(manifest.contributes).toEqual({ tools: ["youtube_transcript"] }); + expect(manifest.trust).toBe("bundled"); + expect(manifest.activation).toBe("eager"); + }); - it("extension bundles the manifest + activate", () => { - expect(extension.manifest).toBe(manifest); - expect(typeof extension.activate).toBe("function"); - }); + it("extension bundles the manifest + activate", () => { + expect(extension.manifest).toBe(manifest); + expect(typeof extension.activate).toBe("function"); + }); }); diff --git a/packages/tool-youtube-transcript/src/extension.ts b/packages/tool-youtube-transcript/src/extension.ts index 0669fa5..7c75aa4 100644 --- a/packages/tool-youtube-transcript/src/extension.ts +++ b/packages/tool-youtube-transcript/src/extension.ts @@ -13,20 +13,20 @@ import { createTranscriptClient, DEFAULT_BASE_URL } from "./client.js"; import { createYoutubeTranscriptTool } from "./tool.js"; export const manifest: Manifest = { - id: "tool-youtube-transcript", - name: "YouTube Transcript Tool", - version: "0.0.0", - apiVersion: "^0.1.0", - trust: "bundled", - activation: "eager", - capabilities: { network: true }, - contributes: { tools: ["youtube_transcript"] }, + id: "tool-youtube-transcript", + name: "YouTube Transcript Tool", + version: "0.0.0", + apiVersion: "^0.1.0", + trust: "bundled", + activation: "eager", + capabilities: { network: true }, + contributes: { tools: ["youtube_transcript"] }, }; export function activate(host: HostAPI): void { - const baseUrl = process.env.YOUTUBE_TRANSCRIBER_URL ?? DEFAULT_BASE_URL; - const client = createTranscriptClient({ baseUrl, fetchFn: globalThis.fetch }); - host.defineTool(createYoutubeTranscriptTool({ client })); + const baseUrl = process.env.YOUTUBE_TRANSCRIBER_URL ?? DEFAULT_BASE_URL; + const client = createTranscriptClient({ baseUrl, fetchFn: globalThis.fetch }); + host.defineTool(createYoutubeTranscriptTool({ client })); } export const extension: Extension = { manifest, activate }; diff --git a/packages/tool-youtube-transcript/src/format.test.ts b/packages/tool-youtube-transcript/src/format.test.ts index 79832da..c615839 100644 --- a/packages/tool-youtube-transcript/src/format.test.ts +++ b/packages/tool-youtube-transcript/src/format.test.ts @@ -1,126 +1,126 @@ import { describe, expect, it } from "vitest"; import { - type CompletedResponse, - type FailedResponse, - formatCompleted, - formatFailed, - formatQueued, - formatTimestamp, - type QueuedResponse, - truncateOutput, + type CompletedResponse, + type FailedResponse, + formatCompleted, + formatFailed, + formatQueued, + formatTimestamp, + type QueuedResponse, + truncateOutput, } from "./format.js"; describe("formatTimestamp", () => { - it("formats seconds as m:ss", () => { - expect(formatTimestamp(65)).toBe("1:05"); - expect(formatTimestamp(723)).toBe("12:03"); - }); + it("formats seconds as m:ss", () => { + expect(formatTimestamp(65)).toBe("1:05"); + expect(formatTimestamp(723)).toBe("12:03"); + }); - it("handles zero", () => { - expect(formatTimestamp(0)).toBe("0:00"); - }); + it("handles zero", () => { + expect(formatTimestamp(0)).toBe("0:00"); + }); - it("handles minutes over 60", () => { - // 3700s = 61m40s; 4530s = 75m30s. - expect(formatTimestamp(3700)).toBe("61:40"); - expect(formatTimestamp(4530)).toBe("75:30"); - }); + it("handles minutes over 60", () => { + // 3700s = 61m40s; 4530s = 75m30s. + expect(formatTimestamp(3700)).toBe("61:40"); + expect(formatTimestamp(4530)).toBe("75:30"); + }); }); describe("formatCompleted", () => { - it("formats markdown with full text + segments", () => { - const data: CompletedResponse = { - status: "completed", - video_id: "vid123", - full_text: "Hello world.", - segments: [ - { text: "Hello world.", start: 0, duration: 2.5 }, - { text: "Second line.", start: 65, duration: 1.0 }, - ], - }; - const out = formatCompleted("https://youtu.be/vid123", data); - const expected = [ - "## Transcript for https://youtu.be/vid123", - "**Video ID:** vid123", - "", - "### Full text", - "", - "Hello world.", - "", - "### Timestamped segments", - "", - "[0:00] Hello world.", - "[1:05] Second line.", - ].join("\n"); - expect(out).toBe(expected); - }); + it("formats markdown with full text + segments", () => { + const data: CompletedResponse = { + status: "completed", + video_id: "vid123", + full_text: "Hello world.", + segments: [ + { text: "Hello world.", start: 0, duration: 2.5 }, + { text: "Second line.", start: 65, duration: 1.0 }, + ], + }; + const out = formatCompleted("https://youtu.be/vid123", data); + const expected = [ + "## Transcript for https://youtu.be/vid123", + "**Video ID:** vid123", + "", + "### Full text", + "", + "Hello world.", + "", + "### Timestamped segments", + "", + "[0:00] Hello world.", + "[1:05] Second line.", + ].join("\n"); + expect(out).toBe(expected); + }); }); describe("formatQueued", () => { - it("returns status + position + estimated time", () => { - const data: QueuedResponse = { - status: "queued", - video_id: "vid456", - position: 3, - estimated_seconds: 120, - }; - const now = () => 1_000_000_000_000; - const out = formatQueued("https://youtu.be/vid456", data, now); - const expectedTime = new Date(1_000_000_000_000 + 120_000).toISOString(); - const expected = - `Transcript not yet available (status: queued, queue position: 3).\n` + - `Estimated available at: ${expectedTime} (in ~120s).\n` + - `URL: https://youtu.be/vid456`; - expect(out).toBe(expected); - }); + it("returns status + position + estimated time", () => { + const data: QueuedResponse = { + status: "queued", + video_id: "vid456", + position: 3, + estimated_seconds: 120, + }; + const now = () => 1_000_000_000_000; + const out = formatQueued("https://youtu.be/vid456", data, now); + const expectedTime = new Date(1_000_000_000_000 + 120_000).toISOString(); + const expected = + `Transcript not yet available (status: queued, queue position: 3).\n` + + `Estimated available at: ${expectedTime} (in ~120s).\n` + + `URL: https://youtu.be/vid456`; + expect(out).toBe(expected); + }); - it("includes the processing status", () => { - const data: QueuedResponse = { - status: "processing", - video_id: "vid457", - position: 0, - estimated_seconds: 45.5, - }; - const out = formatQueued("https://youtu.be/vid457", data, () => 0); - expect(out).toContain("status: processing"); - expect(out).toContain("queue position: 0"); - expect(out).toContain("(in ~46s)"); - expect(out).toContain("https://youtu.be/vid457"); - }); + it("includes the processing status", () => { + const data: QueuedResponse = { + status: "processing", + video_id: "vid457", + position: 0, + estimated_seconds: 45.5, + }; + const out = formatQueued("https://youtu.be/vid457", data, () => 0); + expect(out).toContain("status: processing"); + expect(out).toContain("queue position: 0"); + expect(out).toContain("(in ~46s)"); + expect(out).toContain("https://youtu.be/vid457"); + }); }); describe("formatFailed", () => { - it("returns error type + details", () => { - const data: FailedResponse = { - status: "failed", - video_id: "vid789", - error: "Video unavailable", - error_type: "NotFoundError", - }; - expect(formatFailed(data)).toBe( - "Transcript fetch failed. Error type: NotFoundError. Details: Video unavailable", - ); - }); + it("returns error type + details", () => { + const data: FailedResponse = { + status: "failed", + video_id: "vid789", + error: "Video unavailable", + error_type: "NotFoundError", + }; + expect(formatFailed(data)).toBe( + "Transcript fetch failed. Error type: NotFoundError. Details: Video unavailable", + ); + }); }); describe("truncateOutput", () => { - it("truncates with notice when over cap", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50); - expect(result).toContain("a".repeat(50)); - expect(result).toContain("[Output truncated: exceeded 50 characters]"); - expect(result.length).toBeLessThan(output.length + 100); - }); + it("truncates with notice when over cap", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50); + expect(result).toContain("a".repeat(50)); + expect(result).toContain("[Output truncated: exceeded 50 characters]"); + expect(result.length).toBeLessThan(output.length + 100); + }); - it("returns as-is when under cap", () => { - expect(truncateOutput("short", 100)).toBe("short"); - expect(truncateOutput("exact", 5)).toBe("exact"); - }); + it("returns as-is when under cap", () => { + expect(truncateOutput("short", 100)).toBe("short"); + expect(truncateOutput("exact", 5)).toBe("exact"); + }); - it("includes save path in notice when provided", () => { - const output = "a".repeat(100); - const result = truncateOutput(output, 50, "/tmp/dispatch/vid123.txt"); - expect(result).toContain("/tmp/dispatch/vid123.txt"); - expect(result).toContain("use read_file to access it"); - }); + it("includes save path in notice when provided", () => { + const output = "a".repeat(100); + const result = truncateOutput(output, 50, "/tmp/dispatch/vid123.txt"); + expect(result).toContain("/tmp/dispatch/vid123.txt"); + expect(result).toContain("use read_file to access it"); + }); }); diff --git a/packages/tool-youtube-transcript/src/format.ts b/packages/tool-youtube-transcript/src/format.ts index 0f3ecc3..23bd39f 100644 --- a/packages/tool-youtube-transcript/src/format.ts +++ b/packages/tool-youtube-transcript/src/format.ts @@ -14,33 +14,33 @@ /** A single timestamped segment from a completed transcript. */ export interface TranscriptSegment { - readonly text: string; - readonly start: number; - readonly duration: number; + readonly text: string; + readonly start: number; + readonly duration: number; } /** `status: "completed"` response from the transcriber service. */ export interface CompletedResponse { - readonly status: "completed"; - readonly video_id: string; - readonly full_text: string; - readonly segments: readonly TranscriptSegment[]; + readonly status: "completed"; + readonly video_id: string; + readonly full_text: string; + readonly segments: readonly TranscriptSegment[]; } /** `status: "queued" | "processing"` response from the transcriber service. */ export interface QueuedResponse { - readonly status: "queued" | "processing"; - readonly video_id: string; - readonly position: number; - readonly estimated_seconds: number; + readonly status: "queued" | "processing"; + readonly video_id: string; + readonly position: number; + readonly estimated_seconds: number; } /** `status: "failed"` response from the transcriber service. */ export interface FailedResponse { - readonly status: "failed"; - readonly video_id: string; - readonly error: string; - readonly error_type: string; + readonly status: "failed"; + readonly video_id: string; + readonly error: string; + readonly error_type: string; } /** Discriminated union of all transcriber response shapes. */ @@ -51,9 +51,9 @@ export type TranscriptResponse = CompletedResponse | QueuedResponse | FailedResp * Minutes are not capped — durations over an hour render as `61:40` etc. */ export function formatTimestamp(seconds: number): string { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${s.toString().padStart(2, "0")}`; + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}:${s.toString().padStart(2, "0")}`; } /** @@ -61,20 +61,20 @@ export function formatTimestamp(seconds: number): string { * timestamped segment lines `[m:ss] text`. Mirrors the opencode tool's layout. */ export function formatCompleted(url: string, data: CompletedResponse): string { - const lines: string[] = []; - lines.push(`## Transcript for ${url}`); - lines.push(`**Video ID:** ${data.video_id}`); - lines.push(""); - lines.push("### Full text"); - lines.push(""); - lines.push(data.full_text); - lines.push(""); - lines.push("### Timestamped segments"); - lines.push(""); - for (const segment of data.segments) { - lines.push(`[${formatTimestamp(segment.start)}] ${segment.text}`); - } - return lines.join("\n"); + const lines: string[] = []; + lines.push(`## Transcript for ${url}`); + lines.push(`**Video ID:** ${data.video_id}`); + lines.push(""); + lines.push("### Full text"); + lines.push(""); + lines.push(data.full_text); + lines.push(""); + lines.push("### Timestamped segments"); + lines.push(""); + for (const segment of data.segments) { + lines.push(`[${formatTimestamp(segment.start)}] ${segment.text}`); + } + return lines.join("\n"); } /** @@ -82,18 +82,18 @@ export function formatCompleted(url: string, data: CompletedResponse): string { * estimated available-at time (ISO, derived from the injected `now`). */ export function formatQueued(url: string, data: QueuedResponse, now: () => number): string { - const availableAt = new Date(now() + data.estimated_seconds * 1000); - const timeStr = availableAt.toISOString(); - return ( - `Transcript not yet available (status: ${data.status}, queue position: ${data.position}).\n` + - `Estimated available at: ${timeStr} (in ~${Math.ceil(data.estimated_seconds)}s).\n` + - `URL: ${url}` - ); + const availableAt = new Date(now() + data.estimated_seconds * 1000); + const timeStr = availableAt.toISOString(); + return ( + `Transcript not yet available (status: ${data.status}, queue position: ${data.position}).\n` + + `Estimated available at: ${timeStr} (in ~${Math.ceil(data.estimated_seconds)}s).\n` + + `URL: ${url}` + ); } /** Format a failed response: error type + details. Mirrors the opencode tool. */ export function formatFailed(data: FailedResponse): string { - return `Transcript fetch failed. Error type: ${data.error_type}. Details: ${data.error}`; + return `Transcript fetch failed. Error type: ${data.error_type}. Details: ${data.error}`; } /** @@ -102,13 +102,13 @@ export function formatFailed(data: FailedResponse): string { * Duplication across features is the intended trade (isolation over DRY). */ export function truncateOutput(output: string, cap: number, savePath?: string): string { - if (output.length <= cap) { - return output; - } - const truncated = output.slice(0, cap); - const notice = - savePath !== undefined - ? `\n\n[Output truncated: exceeded ${cap} characters. Full transcript saved to ${savePath} — use read_file to access it.]` - : `\n\n[Output truncated: exceeded ${cap} characters]`; - return `${truncated}${notice}`; + if (output.length <= cap) { + return output; + } + const truncated = output.slice(0, cap); + const notice = + savePath !== undefined + ? `\n\n[Output truncated: exceeded ${cap} characters. Full transcript saved to ${savePath} — use read_file to access it.]` + : `\n\n[Output truncated: exceeded ${cap} characters]`; + return `${truncated}${notice}`; } diff --git a/packages/tool-youtube-transcript/src/index.ts b/packages/tool-youtube-transcript/src/index.ts index 4fec6e4..859ba61 100644 --- a/packages/tool-youtube-transcript/src/index.ts +++ b/packages/tool-youtube-transcript/src/index.ts @@ -1,23 +1,23 @@ export { - createTranscriptClient, - DEFAULT_BASE_URL, - DEFAULT_TIMEOUT_MS, - type FetchLike, - type TranscriptClient, - type TranscriptClientDeps, + createTranscriptClient, + DEFAULT_BASE_URL, + DEFAULT_TIMEOUT_MS, + type FetchLike, + type TranscriptClient, + type TranscriptClientDeps, } from "./client.js"; export { activate, extension, manifest } from "./extension.js"; export { - type CompletedResponse, - type FailedResponse, - formatCompleted, - formatFailed, - formatQueued, - formatTimestamp, - type QueuedResponse, - type TranscriptResponse, - type TranscriptSegment, - truncateOutput, + type CompletedResponse, + type FailedResponse, + formatCompleted, + formatFailed, + formatQueued, + formatTimestamp, + type QueuedResponse, + type TranscriptResponse, + type TranscriptSegment, + truncateOutput, } from "./format.js"; export { createYoutubeTranscriptTool, type YoutubeTranscriptToolDeps } from "./tool.js"; export { type ValidationError, validateUrl } from "./validate.js"; diff --git a/packages/tool-youtube-transcript/src/tool.test.ts b/packages/tool-youtube-transcript/src/tool.test.ts index 7cdfd0e..1f52d22 100644 --- a/packages/tool-youtube-transcript/src/tool.test.ts +++ b/packages/tool-youtube-transcript/src/tool.test.ts @@ -5,156 +5,156 @@ import type { TranscriptResponse } from "./format.js"; import { createYoutubeTranscriptTool } from "./tool.js"; function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { - return { - toolCallId: "test-call-1", - onOutput: () => {}, - signal: new AbortController().signal, - log: createLogger( - { extensionId: "test" }, - { emit: () => {} }, - { now: () => 0, newId: () => "id" }, - ), - ...overrides, - }; + return { + toolCallId: "test-call-1", + onOutput: () => {}, + signal: new AbortController().signal, + log: createLogger( + { extensionId: "test" }, + { emit: () => {} }, + { now: () => 0, newId: () => "id" }, + ), + ...overrides, + }; } function makeStubClient( - responder: (url: string, signal: AbortSignal) => Promise<TranscriptResponse>, + responder: (url: string, signal: AbortSignal) => Promise<TranscriptResponse>, ): TranscriptClient { - return { getTranscript: (url, signal) => responder(url, signal) }; + return { getTranscript: (url, signal) => responder(url, signal) }; } describe("youtube_transcript", () => { - it("returns formatted transcript on completed", async () => { - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid1", - full_text: "Hello world.", - segments: [{ text: "Hello world.", start: 0, duration: 2 }], - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("## Transcript for https://youtu.be/vid1"); - expect(result.content).toContain("**Video ID:** vid1"); - expect(result.content).toContain("Hello world."); - expect(result.content).toContain("[0:00] Hello world."); - }); + it("returns formatted transcript on completed", async () => { + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid1", + full_text: "Hello world.", + segments: [{ text: "Hello world.", start: 0, duration: 2 }], + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid1" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("## Transcript for https://youtu.be/vid1"); + expect(result.content).toContain("**Video ID:** vid1"); + expect(result.content).toContain("Hello world."); + expect(result.content).toContain("[0:00] Hello world."); + }); - it("returns queued message with status and ETA", async () => { - const client = makeStubClient(async () => ({ - status: "queued", - video_id: "vid2", - position: 1, - estimated_seconds: 30, - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid2" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("status: queued"); - expect(result.content).toContain("queue position: 1"); - expect(result.content).toContain("in ~30s"); - expect(result.content).toContain("https://youtu.be/vid2"); - }); + it("returns queued message with status and ETA", async () => { + const client = makeStubClient(async () => ({ + status: "queued", + video_id: "vid2", + position: 1, + estimated_seconds: 30, + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid2" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("status: queued"); + expect(result.content).toContain("queue position: 1"); + expect(result.content).toContain("in ~30s"); + expect(result.content).toContain("https://youtu.be/vid2"); + }); - it("returns failed message", async () => { - const client = makeStubClient(async () => ({ - status: "failed", - video_id: "vid3", - error: "Video unavailable", - error_type: "NotFoundError", - })); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "https://youtu.be/vid3" }, stubCtx()); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("Error type: NotFoundError"); - expect(result.content).toContain("Details: Video unavailable"); - }); + it("returns failed message", async () => { + const client = makeStubClient(async () => ({ + status: "failed", + video_id: "vid3", + error: "Video unavailable", + error_type: "NotFoundError", + })); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "https://youtu.be/vid3" }, stubCtx()); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("Error type: NotFoundError"); + expect(result.content).toContain("Details: Video unavailable"); + }); - it("validation error returns isError", async () => { - const client = makeStubClient(async () => { - throw new Error("should not be called"); - }); - const tool = createYoutubeTranscriptTool({ client }); - const result = await tool.execute({ url: "" }, stubCtx()); - expect(result.isError).toBe(true); - expect(result.content).toContain("url"); - }); + it("validation error returns isError", async () => { + const client = makeStubClient(async () => { + throw new Error("should not be called"); + }); + const tool = createYoutubeTranscriptTool({ client }); + const result = await tool.execute({ url: "" }, stubCtx()); + expect(result.isError).toBe(true); + expect(result.content).toContain("url"); + }); - it("uses conversationId from ctx (not required but passed through)", async () => { - const records: LogRecord[] = []; - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid4", - full_text: "text", - segments: [], - })); - const tool = createYoutubeTranscriptTool({ client }); - const ctx = stubCtx({ - conversationId: "conv-xyz", - log: createLogger( - { extensionId: "test", conversationId: "conv-xyz" }, - { - emit: (r) => { - records.push(r); - }, - }, - { now: () => 0, newId: () => "id" }, - ), - }); - const result = await tool.execute({ url: "https://youtu.be/vid4" }, ctx); - expect(result.isError).toBe(undefined); - expect(result.content).toContain("## Transcript for"); - // The execute span flows through ctx.log, which carries conversationId. - const spanOpen = records.find((r) => r.kind === "span-open"); - expect(spanOpen).toBeDefined(); - expect(spanOpen?.conversationId).toBe("conv-xyz"); - }); + it("uses conversationId from ctx (not required but passed through)", async () => { + const records: LogRecord[] = []; + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid4", + full_text: "text", + segments: [], + })); + const tool = createYoutubeTranscriptTool({ client }); + const ctx = stubCtx({ + conversationId: "conv-xyz", + log: createLogger( + { extensionId: "test", conversationId: "conv-xyz" }, + { + emit: (r) => { + records.push(r); + }, + }, + { now: () => 0, newId: () => "id" }, + ), + }); + const result = await tool.execute({ url: "https://youtu.be/vid4" }, ctx); + expect(result.isError).toBe(undefined); + expect(result.content).toContain("## Transcript for"); + // The execute span flows through ctx.log, which carries conversationId. + const spanOpen = records.find((r) => r.kind === "span-open"); + expect(spanOpen).toBeDefined(); + expect(spanOpen?.conversationId).toBe("conv-xyz"); + }); - it("writes full transcript to /tmp/dispatch/youtube-transcribe/{video_id}.txt when truncated", async () => { - const longText = "x".repeat(60_000); - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid5", - full_text: longText, - segments: [], - })); - let writtenPath = ""; - let writtenContent = ""; - const tool = createYoutubeTranscriptTool({ - client, - outputCap: 1000, - writeFile: (path, content) => { - writtenPath = path; - writtenContent = content; - }, - }); - const result = await tool.execute({ url: "https://youtu.be/vid5" }, stubCtx()); - expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid5.txt"); - expect(writtenContent).toContain("x".repeat(60_000)); - expect(result.content).toContain("/tmp/dispatch/youtube-transcribe/vid5.txt"); - expect(result.content).toContain("use read_file to access it"); - expect(result.content.length).toBeLessThan(writtenContent.length); - }); + it("writes full transcript to /tmp/dispatch/youtube-transcribe/{video_id}.txt when truncated", async () => { + const longText = "x".repeat(60_000); + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid5", + full_text: longText, + segments: [], + })); + let writtenPath = ""; + let writtenContent = ""; + const tool = createYoutubeTranscriptTool({ + client, + outputCap: 1000, + writeFile: (path, content) => { + writtenPath = path; + writtenContent = content; + }, + }); + const result = await tool.execute({ url: "https://youtu.be/vid5" }, stubCtx()); + expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid5.txt"); + expect(writtenContent).toContain("x".repeat(60_000)); + expect(result.content).toContain("/tmp/dispatch/youtube-transcribe/vid5.txt"); + expect(result.content).toContain("use read_file to access it"); + expect(result.content.length).toBeLessThan(writtenContent.length); + }); - it("writes transcript to file even when not truncated", async () => { - const client = makeStubClient(async () => ({ - status: "completed", - video_id: "vid6", - full_text: "short transcript", - segments: [{ text: "short transcript", start: 0, duration: 2 }], - })); - let writtenPath = ""; - let writtenContent = ""; - const tool = createYoutubeTranscriptTool({ - client, - writeFile: (path, content) => { - writtenPath = path; - writtenContent = content; - }, - }); - const result = await tool.execute({ url: "https://youtu.be/vid6" }, stubCtx()); - expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid6.txt"); - expect(writtenContent).toBe(result.content); - }); + it("writes transcript to file even when not truncated", async () => { + const client = makeStubClient(async () => ({ + status: "completed", + video_id: "vid6", + full_text: "short transcript", + segments: [{ text: "short transcript", start: 0, duration: 2 }], + })); + let writtenPath = ""; + let writtenContent = ""; + const tool = createYoutubeTranscriptTool({ + client, + writeFile: (path, content) => { + writtenPath = path; + writtenContent = content; + }, + }); + const result = await tool.execute({ url: "https://youtu.be/vid6" }, stubCtx()); + expect(writtenPath).toBe("/tmp/dispatch/youtube-transcribe/vid6.txt"); + expect(writtenContent).toBe(result.content); + }); }); diff --git a/packages/tool-youtube-transcript/src/tool.ts b/packages/tool-youtube-transcript/src/tool.ts index a11f739..b24a6c5 100644 --- a/packages/tool-youtube-transcript/src/tool.ts +++ b/packages/tool-youtube-transcript/src/tool.ts @@ -11,11 +11,11 @@ import { mkdirSync, writeFileSync } from "node:fs"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import type { TranscriptClient } from "./client.js"; import { - formatCompleted, - formatFailed, - formatQueued, - type TranscriptResponse, - truncateOutput, + formatCompleted, + formatFailed, + formatQueued, + type TranscriptResponse, + truncateOutput, } from "./format.js"; import { validateUrl } from "./validate.js"; @@ -23,21 +23,21 @@ const OUTPUT_CAP = 50_000; const FULL_OUTPUT_DIR = "/tmp/dispatch/youtube-transcribe"; export interface YoutubeTranscriptToolDeps { - readonly client: TranscriptClient; - readonly outputCap?: number; - /** Injected file writer (defaults to real fs write). */ - readonly writeFile?: (path: string, content: string) => void; + readonly client: TranscriptClient; + readonly outputCap?: number; + /** Injected file writer (defaults to real fs write). */ + readonly writeFile?: (path: string, content: string) => void; } const DESCRIPTION = - "Fetch the transcript/subtitles for a YouTube video from the local transcriber " + - "service. If the transcript has not been downloaded before, the video will be " + - "queued for processing and the tool will return the estimated time when the " + - "transcript will be available. Once available, the tool returns the transcript " + - "text and timestamped segments (truncated if very long). The full transcript " + - "is always saved to /tmp/dispatch/youtube-transcribe/{video_id}.txt — use " + - "read_file to access it. Accepted URL formats: " + - "youtube.com/watch?v=, youtu.be/, youtube.com/embed/, youtube.com/shorts/"; + "Fetch the transcript/subtitles for a YouTube video from the local transcriber " + + "service. If the transcript has not been downloaded before, the video will be " + + "queued for processing and the tool will return the estimated time when the " + + "transcript will be available. Once available, the tool returns the transcript " + + "text and timestamped segments (truncated if very long). The full transcript " + + "is always saved to /tmp/dispatch/youtube-transcribe/{video_id}.txt — use " + + "read_file to access it. Accepted URL formats: " + + "youtube.com/watch?v=, youtu.be/, youtube.com/embed/, youtube.com/shorts/"; /** * Create the `youtube_transcript` tool. `concurrencySafe: true` — transcript @@ -45,73 +45,73 @@ const DESCRIPTION = * capability is declared on the extension manifest (not the tool contract). */ export function createYoutubeTranscriptTool(deps: YoutubeTranscriptToolDeps): ToolContract { - const client = deps.client; - const cap = deps.outputCap ?? OUTPUT_CAP; - const writeFile = - deps.writeFile ?? - ((path, content) => { - mkdirSync(FULL_OUTPUT_DIR, { recursive: true }); - writeFileSync(path, content, "utf-8"); - }); + const client = deps.client; + const cap = deps.outputCap ?? OUTPUT_CAP; + const writeFile = + deps.writeFile ?? + ((path, content) => { + mkdirSync(FULL_OUTPUT_DIR, { recursive: true }); + writeFileSync(path, content, "utf-8"); + }); - return { - name: "youtube_transcript", - description: DESCRIPTION, - parameters: { - type: "object", - properties: { - url: { - type: "string", - description: - "YouTube video URL (e.g. https://www.youtube.com/watch?v=... or https://youtu.be/...)", - }, - }, - required: ["url"], - }, - concurrencySafe: true, - async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { - const validated = validateUrl(args); - if (typeof validated !== "string") { - return { content: validated.error, isError: true }; - } - const url = validated; - const span = ctx.log.span("youtube_transcript.execute", { url }); - try { - const data: TranscriptResponse = await client.getTranscript(url, ctx.signal); - let output: string; - let videoId: string | undefined; - // Check the single-literal discriminants ("completed"/"failed") first, - // so the final else narrows to QueuedResponse — whose `status` is itself - // a `"queued" | "processing"` union TS cannot negatively narrow. - if (data.status === "completed") { - output = formatCompleted(url, data); - videoId = data.video_id; - } else if (data.status === "failed") { - output = formatFailed(data); - } else { - output = formatQueued(url, data, Date.now); - } - span.end(); + return { + name: "youtube_transcript", + description: DESCRIPTION, + parameters: { + type: "object", + properties: { + url: { + type: "string", + description: + "YouTube video URL (e.g. https://www.youtube.com/watch?v=... or https://youtu.be/...)", + }, + }, + required: ["url"], + }, + concurrencySafe: true, + async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> { + const validated = validateUrl(args); + if (typeof validated !== "string") { + return { content: validated.error, isError: true }; + } + const url = validated; + const span = ctx.log.span("youtube_transcript.execute", { url }); + try { + const data: TranscriptResponse = await client.getTranscript(url, ctx.signal); + let output: string; + let videoId: string | undefined; + // Check the single-literal discriminants ("completed"/"failed") first, + // so the final else narrows to QueuedResponse — whose `status` is itself + // a `"queued" | "processing"` union TS cannot negatively narrow. + if (data.status === "completed") { + output = formatCompleted(url, data); + videoId = data.video_id; + } else if (data.status === "failed") { + output = formatFailed(data); + } else { + output = formatQueued(url, data, Date.now); + } + span.end(); - if (videoId !== undefined) { - const filePath = `${FULL_OUTPUT_DIR}/${videoId}.txt`; - try { - writeFile(filePath, output); - } catch { - // File write failed — continue with truncated output only. - } - if (output.length > cap) { - return { content: truncateOutput(output, cap, filePath) }; - } - } - return { content: truncateOutput(output, cap) }; - } catch (err: unknown) { - span.end({ err }); - return { - content: `Error: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - }, - }; + if (videoId !== undefined) { + const filePath = `${FULL_OUTPUT_DIR}/${videoId}.txt`; + try { + writeFile(filePath, output); + } catch { + // File write failed — continue with truncated output only. + } + if (output.length > cap) { + return { content: truncateOutput(output, cap, filePath) }; + } + } + return { content: truncateOutput(output, cap) }; + } catch (err: unknown) { + span.end({ err }); + return { + content: `Error: ${err instanceof Error ? err.message : String(err)}`, + isError: true, + }; + } + }, + }; } diff --git a/packages/tool-youtube-transcript/src/validate.test.ts b/packages/tool-youtube-transcript/src/validate.test.ts index 3181bb1..7bd70d9 100644 --- a/packages/tool-youtube-transcript/src/validate.test.ts +++ b/packages/tool-youtube-transcript/src/validate.test.ts @@ -2,34 +2,34 @@ import { describe, expect, it } from "vitest"; import { validateUrl } from "./validate.js"; describe("validateUrl", () => { - it("accepts valid URL", () => { - const result = validateUrl({ url: "https://www.youtube.com/watch?v=abc123" }); - expect(result).toBe("https://www.youtube.com/watch?v=abc123"); - }); + it("accepts valid URL", () => { + const result = validateUrl({ url: "https://www.youtube.com/watch?v=abc123" }); + expect(result).toBe("https://www.youtube.com/watch?v=abc123"); + }); - it("rejects missing url", () => { - const result = validateUrl({ query: "no url here" }); - expect(typeof result).toBe("object"); - if (typeof result === "object") { - expect(result.error).toContain("url"); - } - }); + it("rejects missing url", () => { + const result = validateUrl({ query: "no url here" }); + expect(typeof result).toBe("object"); + if (typeof result === "object") { + expect(result.error).toContain("url"); + } + }); - it("rejects empty url", () => { - const empty = validateUrl({ url: "" }); - expect(typeof empty).toBe("object"); - if (typeof empty === "object") { - expect(empty.error).toContain("url"); - } - const whitespace = validateUrl({ url: " " }); - expect(typeof whitespace).toBe("object"); - }); + it("rejects empty url", () => { + const empty = validateUrl({ url: "" }); + expect(typeof empty).toBe("object"); + if (typeof empty === "object") { + expect(empty.error).toContain("url"); + } + const whitespace = validateUrl({ url: " " }); + expect(typeof whitespace).toBe("object"); + }); - it("rejects null/non-object args", () => { - expect(typeof validateUrl(null)).toBe("object"); - expect(typeof validateUrl(undefined)).toBe("object"); - expect(typeof validateUrl("string")).toBe("object"); - expect(typeof validateUrl(42)).toBe("object"); - expect(typeof validateUrl(true)).toBe("object"); - }); + it("rejects null/non-object args", () => { + expect(typeof validateUrl(null)).toBe("object"); + expect(typeof validateUrl(undefined)).toBe("object"); + expect(typeof validateUrl("string")).toBe("object"); + expect(typeof validateUrl(42)).toBe("object"); + expect(typeof validateUrl(true)).toBe("object"); + }); }); diff --git a/packages/tool-youtube-transcript/src/validate.ts b/packages/tool-youtube-transcript/src/validate.ts index 3a9d919..040dc55 100644 --- a/packages/tool-youtube-transcript/src/validate.ts +++ b/packages/tool-youtube-transcript/src/validate.ts @@ -15,16 +15,16 @@ export type ValidationError = { readonly error: string }; * input — the tool surfaces the message verbatim. */ export function validateUrl(args: unknown): string | ValidationError { - if (args === null || args === undefined || typeof args !== "object") { - return { error: "Error: Arguments must be an object with a 'url' string." }; - } - const obj = args as Record<string, unknown>; - const raw = obj.url; - if (typeof raw !== "string") { - return { error: "Error: 'url' is required and must be a string." }; - } - if (raw.trim().length === 0) { - return { error: "Error: 'url' must not be empty." }; - } - return raw; + if (args === null || args === undefined || typeof args !== "object") { + return { error: "Error: Arguments must be an object with a 'url' string." }; + } + const obj = args as Record<string, unknown>; + const raw = obj.url; + if (typeof raw !== "string") { + return { error: "Error: 'url' is required and must be a string." }; + } + if (raw.trim().length === 0) { + return { error: "Error: 'url' must not be empty." }; + } + return raw; } |
