From dfb3a61afa545b67b85dbefe6b217affd14c16a7 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Sun, 21 Jun 2026 14:58:38 +0900 Subject: feat(tool-youtube-transcript): YouTube transcription tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/tool-youtube-transcript/package.json | 11 ++ .../tool-youtube-transcript/src/client.test.ts | 134 +++++++++++++++++++++ packages/tool-youtube-transcript/src/client.ts | 80 ++++++++++++ .../tool-youtube-transcript/src/extension.test.ts | 118 ++++++++++++++++++ packages/tool-youtube-transcript/src/extension.ts | 32 +++++ .../tool-youtube-transcript/src/format.test.ts | 121 +++++++++++++++++++ packages/tool-youtube-transcript/src/format.ts | 112 +++++++++++++++++ packages/tool-youtube-transcript/src/index.ts | 23 ++++ packages/tool-youtube-transcript/src/tool.test.ts | 114 ++++++++++++++++++ packages/tool-youtube-transcript/src/tool.ts | 94 +++++++++++++++ .../tool-youtube-transcript/src/validate.test.ts | 35 ++++++ packages/tool-youtube-transcript/src/validate.ts | 30 +++++ packages/tool-youtube-transcript/tsconfig.json | 6 + 13 files changed, 910 insertions(+) create mode 100644 packages/tool-youtube-transcript/package.json create mode 100644 packages/tool-youtube-transcript/src/client.test.ts create mode 100644 packages/tool-youtube-transcript/src/client.ts create mode 100644 packages/tool-youtube-transcript/src/extension.test.ts create mode 100644 packages/tool-youtube-transcript/src/extension.ts create mode 100644 packages/tool-youtube-transcript/src/format.test.ts create mode 100644 packages/tool-youtube-transcript/src/format.ts create mode 100644 packages/tool-youtube-transcript/src/index.ts create mode 100644 packages/tool-youtube-transcript/src/tool.test.ts create mode 100644 packages/tool-youtube-transcript/src/tool.ts create mode 100644 packages/tool-youtube-transcript/src/validate.test.ts create mode 100644 packages/tool-youtube-transcript/src/validate.ts create mode 100644 packages/tool-youtube-transcript/tsconfig.json (limited to 'packages/tool-youtube-transcript') 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((_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((_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; +} + +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 { + 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 { + 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 } { + 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 { + 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, +): 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 { + 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; + 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" }] +} -- cgit v1.2.3