diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 15:08:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 15:08:51 +0900 |
| commit | 64823e68e8c6c3bc199f9cb82d97321c830687c3 (patch) | |
| tree | 9340fbaede62f86b0de9f8281d9415778adcda44 | |
| parent | 4ccdfd34c55bee279ce5e190412afd19cfbb5238 (diff) | |
| download | dispatch-64823e68e8c6c3bc199f9cb82d97321c830687c3.tar.gz dispatch-64823e68e8c6c3bc199f9cb82d97321c830687c3.zip | |
feat(tool-youtube-transcript): write full transcript to /tmp/dispatch on truncation
When the formatted transcript exceeds the 50K char output cap, the tool now
writes the full output to /tmp/dispatch/{video_id}.txt and returns the
truncated output with a notice pointing to the file path. The writeFile dep
is injectable so tests verify without touching the filesystem.
| -rw-r--r-- | packages/tool-youtube-transcript/src/format.test.ts | 7 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/format.ts | 14 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/tool.test.ts | 26 | ||||
| -rw-r--r-- | packages/tool-youtube-transcript/src/tool.ts | 22 |
4 files changed, 64 insertions, 5 deletions
diff --git a/packages/tool-youtube-transcript/src/format.test.ts b/packages/tool-youtube-transcript/src/format.test.ts index 7555318..79832da 100644 --- a/packages/tool-youtube-transcript/src/format.test.ts +++ b/packages/tool-youtube-transcript/src/format.test.ts @@ -116,4 +116,11 @@ describe("truncateOutput", () => { 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"); + }); }); diff --git a/packages/tool-youtube-transcript/src/format.ts b/packages/tool-youtube-transcript/src/format.ts index 8bb409b..0f3ecc3 100644 --- a/packages/tool-youtube-transcript/src/format.ts +++ b/packages/tool-youtube-transcript/src/format.ts @@ -97,14 +97,18 @@ export function formatFailed(data: FailedResponse): string { } /** - * 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). + * Truncate output to `cap` characters with a trailing notice. When `savePath` + * is provided, the notice tells the model where the full output was saved. + * Duplication across features is the intended trade (isolation over DRY). */ -export function truncateOutput(output: string, cap: number): string { +export function truncateOutput(output: string, cap: number, savePath?: string): string { if (output.length <= cap) { return output; } const truncated = output.slice(0, cap); - return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`; + 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/tool.test.ts b/packages/tool-youtube-transcript/src/tool.test.ts index 4fcb63e..59d90fa 100644 --- a/packages/tool-youtube-transcript/src/tool.test.ts +++ b/packages/tool-youtube-transcript/src/tool.test.ts @@ -110,4 +110,30 @@ describe("youtube_transcript", () => { expect(spanOpen).toBeDefined(); expect(spanOpen?.conversationId).toBe("conv-xyz"); }); + + it("writes full transcript to /tmp/dispatch/{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/vid5.txt"); + expect(writtenContent).toContain("x".repeat(60_000)); + expect(result.content).toContain("/tmp/dispatch/vid5.txt"); + expect(result.content).toContain("use read_file to access it"); + expect(result.content.length).toBeLessThan(writtenContent.length); + }); }); diff --git a/packages/tool-youtube-transcript/src/tool.ts b/packages/tool-youtube-transcript/src/tool.ts index 189e42c..a47d693 100644 --- a/packages/tool-youtube-transcript/src/tool.ts +++ b/packages/tool-youtube-transcript/src/tool.ts @@ -7,6 +7,7 @@ * rather than thrown, so the model can react to the message. */ +import { mkdirSync, writeFileSync } from "node:fs"; import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel"; import type { TranscriptClient } from "./client.js"; import { @@ -19,10 +20,13 @@ import { import { validateUrl } from "./validate.js"; const OUTPUT_CAP = 50_000; +const FULL_OUTPUT_DIR = "/tmp/dispatch"; export interface YoutubeTranscriptToolDeps { readonly client: TranscriptClient; readonly outputCap?: number; + /** Injected file writer (defaults to real fs write). */ + readonly writeFile?: (path: string, content: string) => void; } const DESCRIPTION = @@ -41,6 +45,12 @@ const DESCRIPTION = 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"); + }); return { name: "youtube_transcript", @@ -67,17 +77,29 @@ export function createYoutubeTranscriptTool(deps: YoutubeTranscriptToolDeps): To 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 (output.length > cap && videoId !== undefined) { + const filePath = `${FULL_OUTPUT_DIR}/${videoId}.txt`; + try { + writeFile(filePath, output); + return { content: truncateOutput(output, cap, filePath) }; + } catch { + return { content: truncateOutput(output, cap) }; + } + } return { content: truncateOutput(output, cap) }; } catch (err: unknown) { span.end({ err }); |
