import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; import { spawn, type ChildProcess } from "node:child_process"; // ── Config (no hardcoded paths — everything env-overridable) ────────────── // PIDEO_DIR: where inbox/outbox/signal live (default ~/.pideo) // PIDEO_DAEMON: explicit daemon path override const PIDIR = process.env.PIDEO_DIR ?? join(homedir(), ".pideo"); const POLL_INTERVAL_MS = 1_000; const POLL_TIMEOUT_MS = Number(process.env.PIDEO_TIMEOUT_MS ?? 300_000); // 5 min const DAEMON_READY_TIMEOUT = Number(process.env.PIDEO_DAEMON_READY_MS ?? 8_000); interface PideoResult { success: boolean; content?: string; error?: string; cost?: { model: string; provider?: string; tokens: number; prompt_tokens: number; completion_tokens: number; cost: number; }; } // ── Lazy daemon lifecycle (ported from pimage-bridge) ────────── let daemonProcess: ChildProcess | null = null; let daemonStarting: Promise | null = null; function resolveDaemonScript(): string { // 1. explicit override if (process.env.PIDEO_DAEMON) return process.env.PIDEO_DAEMON; // 2. legacy location ~/.pideo/pideo-daemon.py (kept for existing installs) const legacy = join(PIDIR, "pideo-daemon.py"); if (existsSync(legacy)) return legacy; // 3. package daemons dir return join(dirname(fileURLToPath(import.meta.url)), "..", "daemons", "pideo-daemon.py"); } function dirname(p: string): string { return p.slice(0, Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"))); } async function ensureDaemon(signal: AbortSignal): Promise { if (daemonProcess && daemonProcess.exitCode === null) return true; if (daemonStarting) return daemonStarting; daemonStarting = startDaemon(signal); try { return await daemonStarting; } finally { daemonStarting = null; } } function startDaemon(signal: AbortSignal): Promise { const script = resolveDaemonScript(); if (!existsSync(script)) { console.error(`[pideo] daemon script not found at ${script}`); return Promise.resolve(false); } return new Promise((resolve) => { const proc = spawn("python3", [script], { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, PIDEO_DIR: PIDIR }, }); daemonProcess = proc; let settled = false; const finish = (ok: boolean) => { if (settled) return; settled = true; resolve(ok); }; proc.stdout?.on("data", (data: Buffer) => { const text = data.toString(); if (text.includes("Watching")) { finish(true); } }); proc.on("error", () => finish(false)); proc.on("exit", (code) => { daemonProcess = null; finish(code === 0); }); const timeout = setTimeout(() => finish(true), DAEMON_READY_TIMEOUT); const onAbort = () => { clearTimeout(timeout); proc.kill(); daemonProcess = null; finish(false); }; if (signal.aborted) { onAbort(); } else { signal.addEventListener("abort", onAbort, { once: true }); } }); } function stopDaemon(): void { if (daemonProcess && daemonProcess.exitCode === null) { daemonProcess.kill(); daemonProcess = null; } } export default function (pi: ExtensionAPI) { mkdirSync(join(PIDIR, "in"), { recursive: true }); mkdirSync(join(PIDIR, "out"), { recursive: true }); pi.registerTool({ name: "forward_to_pideo", label: "Forward Video to Pideo Agent", description: "Forward a YouTube/TikTok video URL to the pideo video-analysis agent for analysis, transcription, or question-answering.", promptSnippet: "Forward a video to the pideo agent for analysis", promptGuidelines: [ "Use forward_to_pideo when the user provides a YouTube or TikTok video URL and wants it analyzed, summarized, transcribed, or has questions about the video content.", "Pass the exact video URL and a clear instruction about what to do with it.", 'Example: forward_to_pideo({ url: "https://www.youtube.com/watch?v=xxx", instruction: "Summarize this video" })', ], parameters: Type.Object({ url: Type.String({ description: "YouTube or TikTok video URL" }), instruction: Type.String({ description: "Clear instruction for the pideo agent — summarize, transcribe, answer questions, etc.", }), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const uuid = randomUUID(); const inboxDir = join(PIDIR, "in", uuid); const outDir = join(PIDIR, "out", uuid); const outFile = join(outDir, "result.json"); const reqFile = join(inboxDir, "request.json"); const signalFile = join(PIDIR, "current"); // Lazy-start pideo daemon if not running if (!(await ensureDaemon(signal))) { return { content: [ { type: "text" as const, text: `❌ Failed to start pideo daemon (script not found). Set PIDEO_DAEMON to point at pideo-daemon.py if your install layout differs.`, }, ], details: {}, isError: true, }; } // Write request mkdirSync(inboxDir, { recursive: true }); writeFileSync( reqFile, JSON.stringify({ id: uuid, url: params.url, instruction: params.instruction, }), ); // Signal the daemon writeFileSync(signalFile, uuid); await onUpdate({ content: [ { type: "text" as const, text: `⏳ Forwarded video to pideo — fetching metadata + transcript...`, }, ], details: {}, }); // Poll for result (default 5 minutes) const maxAttempts = Math.max(1, Math.floor(POLL_TIMEOUT_MS / POLL_INTERVAL_MS)); for (let i = 0; i < maxAttempts; i++) { if (signal.aborted) { return { content: [ { type: "text" as const, text: "Cancelled" }, ], details: {}, }; } if (existsSync(outFile)) { const raw = readFileSync(outFile, "utf-8"); const result: PideoResult = JSON.parse(raw); // Cleanup try { rmSync(inboxDir, { recursive: true, force: true }); } catch {} if (result.success && result.content) { let text = result.content; if (result.cost) { const c = result.cost; const usd = typeof c.cost === "number" ? c.cost : 0; text += `\n\n──\n📊 Model: ${c.model} | Tokens: ${c.tokens} (${c.prompt_tokens}↑ ${c.completion_tokens}↓) | Cost: \$${usd.toFixed(6)}`; } return { content: [{ type: "text" as const, text }], details: { cost: result.cost }, }; } else { return { content: [ { type: "text" as const, text: result.error ?? "Unknown error from pideo agent", }, ], details: {}, isError: true, }; } } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } // Timeout return { content: [ { type: "text" as const, text: `Pideo agent did not respond within ${POLL_TIMEOUT_MS / 1000}s. The daemon may not be running.`, }, ], details: {}, isError: true, }; }, }); // ── Clean up daemon on session end ───────────────────────── pi.on("session_shutdown", async () => { stopDaemon(); }); }