summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-youtube-transcript
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 14:58:38 +0900
committerAdam Malczewski <[email protected]>2026-06-21 14:58:38 +0900
commitdfb3a61afa545b67b85dbefe6b217affd14c16a7 (patch)
treefbe0d18323136cc19d971e18f0801428bcd2e4a7 /packages/tool-youtube-transcript
parentd56fe9cf64719bb330c17b2daee58c0bafa057c9 (diff)
downloaddispatch-dfb3a61afa545b67b85dbefe6b217affd14c16a7.tar.gz
dispatch-dfb3a61afa545b67b85dbefe6b217affd14c16a7.zip
feat(tool-youtube-transcript): YouTube transcription tool
New standard tool extension backed by a self-hosted transcriber service (http://100.102.55.49:41090, Tailscale, no API key). One tool youtube_transcript — fetches transcripts for YouTube videos. Returns completed (full text + timestamped segments), queued/processing (position + ETA + .youtube_subtitles_pending retry convention), or failed (error). Pure core: validateUrl + format* functions + truncateOutput. Injected edge: TranscriptClient (injectable fetchFn, AbortSignal.any for cancellation). concurrencySafe true, capabilities network. 30 tests. Verified: tsc EXIT 0, 1152 vitest, biome clean (327 files). Boot smoke clean.
Diffstat (limited to 'packages/tool-youtube-transcript')
-rw-r--r--packages/tool-youtube-transcript/package.json11
-rw-r--r--packages/tool-youtube-transcript/src/client.test.ts134
-rw-r--r--packages/tool-youtube-transcript/src/client.ts80
-rw-r--r--packages/tool-youtube-transcript/src/extension.test.ts118
-rw-r--r--packages/tool-youtube-transcript/src/extension.ts32
-rw-r--r--packages/tool-youtube-transcript/src/format.test.ts121
-rw-r--r--packages/tool-youtube-transcript/src/format.ts112
-rw-r--r--packages/tool-youtube-transcript/src/index.ts23
-rw-r--r--packages/tool-youtube-transcript/src/tool.test.ts114
-rw-r--r--packages/tool-youtube-transcript/src/tool.ts94
-rw-r--r--packages/tool-youtube-transcript/src/validate.test.ts35
-rw-r--r--packages/tool-youtube-transcript/src/validate.ts30
-rw-r--r--packages/tool-youtube-transcript/tsconfig.json6
13 files changed, 910 insertions, 0 deletions
diff --git a/packages/tool-youtube-transcript/package.json b/packages/tool-youtube-transcript/package.json
new file mode 100644
index 0000000..efcefda
--- /dev/null
+++ b/packages/tool-youtube-transcript/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@dispatch/tool-youtube-transcript",
+ "version": "0.0.0",
+ "type": "module",
+ "private": true,
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "dependencies": {
+ "@dispatch/kernel": "workspace:*"
+ }
+}
diff --git a/packages/tool-youtube-transcript/src/client.test.ts b/packages/tool-youtube-transcript/src/client.test.ts
new file mode 100644
index 0000000..a33f44a
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/client.test.ts
@@ -0,0 +1,134 @@
+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" },
+ });
+}
+
+interface CapturedCall {
+ 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 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());
+
+ 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 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("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("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
new file mode 100644
index 0000000..a088d7d
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/client.ts
@@ -0,0 +1,80 @@
+/**
+ * TranscriptClient — the injected outermost edge for the youtube_transcript
+ * tool.
+ *
+ * All effects (fetch, clock-via-abort-timeout) are injected so the pure
+ * decision logic remains testable without real I/O. The factory builds a
+ * single `getTranscript` method over a self-hosted transcriber instance (no
+ * API key). Mirrors the tool-web-search FirecrawlClient's request structure:
+ * per-request timeout combined with the caller's cancellation signal via
+ * `AbortSignal.any`.
+ */
+
+import type { TranscriptResponse } from "./format.js";
+
+export type FetchLike = typeof globalThis.fetch;
+
+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>;
+}
+
+export interface TranscriptClientDeps {
+ readonly baseUrl: string;
+ readonly fetchFn: FetchLike;
+ readonly timeoutMs?: number;
+}
+
+/**
+ * Create a TranscriptClient. `getTranscript` builds the request URL
+ * (`${baseUrl}/api/transcript?url=${encodeURIComponent(url)}`), calls the
+ * injected `fetchFn`, and handles HTTP + JSON errors. The per-request timeout
+ * 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;
+
+ 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
new file mode 100644
index 0000000..70cf227
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/extension.test.ts
@@ -0,0 +1,118 @@
+import { createLogger, type HostAPI, type ToolExecuteContext } from "@dispatch/kernel";
+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,
+ };
+}
+
+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 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;
+ }
+}
+
+afterEach(() => {
+ 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 };
+}
+
+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("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=");
+ });
+
+ 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=");
+ });
+});
+
+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("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
new file mode 100644
index 0000000..0669fa5
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/extension.ts
@@ -0,0 +1,32 @@
+/**
+ * tool-youtube-transcript extension — registers the `youtube_transcript` tool
+ * backed by a self-hosted transcriber service on activation.
+ *
+ * The base URL comes from `YOUTUBE_TRANSCRIBER_URL` (env) with a Tailscale
+ * default. Effects (`globalThis.fetch`) come from the ambient edge here, in
+ * the shell — never in the pure core. Logging is left to the host via
+ * `host.logger`/`ctx.log` (no `console.*`, no hand-rolled logger).
+ */
+
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
+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"] },
+};
+
+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 }));
+}
+
+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
new file mode 100644
index 0000000..00b5d05
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/format.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+import {
+ 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("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");
+ });
+});
+
+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);
+ });
+});
+
+describe("formatQueued", () => {
+ it("returns status + position + estimated time + pending file instruction", () => {
+ 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` +
+ `You must append this video URL to .youtube_subtitles_pending in the current ` +
+ `working directory: https://youtu.be/vid456`;
+ expect(out).toBe(expected);
+ });
+
+ it("includes the processing status + pending-file instruction", () => {
+ 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(".youtube_subtitles_pending");
+ 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",
+ );
+ });
+});
+
+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("returns as-is when under cap", () => {
+ expect(truncateOutput("short", 100)).toBe("short");
+ expect(truncateOutput("exact", 5)).toBe("exact");
+ });
+});
diff --git a/packages/tool-youtube-transcript/src/format.ts b/packages/tool-youtube-transcript/src/format.ts
new file mode 100644
index 0000000..d445be0
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/format.ts
@@ -0,0 +1,112 @@
+/**
+ * Pure formatters for the youtube_transcript tool — input → output, no I/O.
+ *
+ * These mirror the proven opencode youtube-subtitles tool's formatting,
+ * isolated (not imported) per the isolation-over-DRY rule. Tested directly
+ * with zero mocks.
+ *
+ * NOTE: `formatQueued` renders the estimated-available-at time as an ISO 8601
+ * string derived from the injected `now()`, rather than `toLocaleTimeString`.
+ * The opencode tool uses `toLocaleTimeString`, which reads ambient locale +
+ * timezone (hidden state) — a violation of the pure-core rule. ISO is fully
+ * deterministic from the injected `now` and is a valid `{time}` rendering.
+ */
+
+/** A single timestamped segment from a completed transcript. */
+export interface TranscriptSegment {
+ 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[];
+}
+
+/** `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;
+}
+
+/** `status: "failed"` response from the transcriber service. */
+export interface FailedResponse {
+ readonly status: "failed";
+ readonly video_id: string;
+ readonly error: string;
+ readonly error_type: string;
+}
+
+/** Discriminated union of all transcriber response shapes. */
+export type TranscriptResponse = CompletedResponse | QueuedResponse | FailedResponse;
+
+/**
+ * Format a segment start offset (seconds) as `m:ss` (e.g. `1:05`, `12:03`).
+ * 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")}`;
+}
+
+/**
+ * Format a completed transcript as markdown: header, video id, full text, then
+ * 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");
+}
+
+/**
+ * Format a queued/processing response: status, queue position, the estimated
+ * available-at time (ISO, derived from the injected `now`), and the
+ * `.youtube_subtitles_pending` append instruction the model must follow.
+ */
+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` +
+ `You must append this video URL to .youtube_subtitles_pending in the current ` +
+ `working directory: ${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}`;
+}
+
+/**
+ * Truncate output to `cap` characters with a trailing notice, identical in
+ * spirit to tool-web-search. Duplication across features is the intended trade
+ * (isolation over DRY).
+ */
+export function truncateOutput(output: string, cap: number): string {
+ if (output.length <= cap) {
+ return output;
+ }
+ const truncated = output.slice(0, cap);
+ return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`;
+}
diff --git a/packages/tool-youtube-transcript/src/index.ts b/packages/tool-youtube-transcript/src/index.ts
new file mode 100644
index 0000000..4fec6e4
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/index.ts
@@ -0,0 +1,23 @@
+export {
+ 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,
+} 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
new file mode 100644
index 0000000..adf95ed
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/tool.test.ts
@@ -0,0 +1,114 @@
+import { createLogger, type LogRecord, type ToolExecuteContext } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import type { TranscriptClient } from "./client.js";
+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,
+ };
+}
+
+function makeStubClient(
+ responder: (url: string, signal: AbortSignal) => Promise<TranscriptResponse>,
+): TranscriptClient {
+ 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 queued message with pending file instruction", 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(".youtube_subtitles_pending");
+ 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("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");
+ });
+});
diff --git a/packages/tool-youtube-transcript/src/tool.ts b/packages/tool-youtube-transcript/src/tool.ts
new file mode 100644
index 0000000..03244d5
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/tool.ts
@@ -0,0 +1,94 @@
+/**
+ * youtube_transcript tool factory — the imperative shell that binds the pure
+ * validate/format functions to the injected TranscriptClient edge.
+ *
+ * Mirrors the tool-web-search pattern: factory + injected dep + pure helpers +
+ * a `ToolResult` returned per call. Errors surface as `{ isError: true }`
+ * rather than thrown, so the model can react to the message.
+ */
+
+import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
+import type { TranscriptClient } from "./client.js";
+import {
+ formatCompleted,
+ formatFailed,
+ formatQueued,
+ type TranscriptResponse,
+ truncateOutput,
+} from "./format.js";
+import { validateUrl } from "./validate.js";
+
+const OUTPUT_CAP = 50_000;
+
+export interface YoutubeTranscriptToolDeps {
+ readonly client: TranscriptClient;
+ readonly outputCap?: number;
+}
+
+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. When the status is 'queued' or 'processing', " +
+ "you MUST append the video URL on a new line to the file .youtube_subtitles_pending " +
+ "in the current working directory (create it if it does not exist). Once " +
+ "available, the tool returns the full transcript text and timestamped " +
+ "segments. Accepted URL formats: youtube.com/watch?v=, youtu.be/, " +
+ "youtube.com/embed/, youtube.com/shorts/";
+
+/**
+ * Create the `youtube_transcript` tool. `concurrencySafe: true` — transcript
+ * fetches are idempotent and safe to run alongside other tools. The `network`
+ * 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;
+
+ 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;
+ // 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);
+ } else if (data.status === "failed") {
+ output = formatFailed(data);
+ } else {
+ output = formatQueued(url, data, Date.now);
+ }
+ span.end();
+ 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
new file mode 100644
index 0000000..3181bb1
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/validate.test.ts
@@ -0,0 +1,35 @@
+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("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 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
new file mode 100644
index 0000000..3a9d919
--- /dev/null
+++ b/packages/tool-youtube-transcript/src/validate.ts
@@ -0,0 +1,30 @@
+/**
+ * Pure argument validation for the youtube_transcript tool — input → output, no I/O.
+ *
+ * Validates that args is an object with a non-empty `url` string. Does NOT
+ * validate URL format — the transcriber service handles that (mirrors the
+ * opencode youtube-subtitles tool's contract). Returns the URL string on
+ * success or `{ error }` for invalid input, so the tool surfaces the message
+ * verbatim as an `isError` result.
+ */
+
+export type ValidationError = { readonly error: string };
+
+/**
+ * Validate raw tool args. Returns the URL string, or `{ error }` for invalid
+ * 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;
+}
diff --git a/packages/tool-youtube-transcript/tsconfig.json b/packages/tool-youtube-transcript/tsconfig.json
new file mode 100644
index 0000000..ff99a43
--- /dev/null
+++ b/packages/tool-youtube-transcript/tsconfig.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": { "rootDir": "src", "outDir": "dist", "composite": true },
+ "include": ["src/**/*.ts"],
+ "references": [{ "path": "../kernel" }]
+}