summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-youtube-transcript/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/tool-youtube-transcript/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/tool-youtube-transcript/src')
-rw-r--r--packages/tool-youtube-transcript/src/client.test.ts218
-rw-r--r--packages/tool-youtube-transcript/src/client.ts92
-rw-r--r--packages/tool-youtube-transcript/src/extension.test.ts172
-rw-r--r--packages/tool-youtube-transcript/src/extension.ts22
-rw-r--r--packages/tool-youtube-transcript/src/format.test.ts206
-rw-r--r--packages/tool-youtube-transcript/src/format.ts98
-rw-r--r--packages/tool-youtube-transcript/src/index.ts32
-rw-r--r--packages/tool-youtube-transcript/src/tool.test.ts278
-rw-r--r--packages/tool-youtube-transcript/src/tool.ts168
-rw-r--r--packages/tool-youtube-transcript/src/validate.test.ts54
-rw-r--r--packages/tool-youtube-transcript/src/validate.ts24
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;
}