import { MediaOperationSchema } from "@rakazo/contracts"; import type { PrismaClient } from "./client.js"; import { ensureMainBot } from "./main-agent.js"; import { createRepos } from "./repos.js"; import { requireMembership } from "./scope.js"; /** * Idempotency key of the user's Studio specialist. Spawn keys are unique per space, so it * carries the user id to stay unique in a space several users share. */ export function studioBotSpawnKey(userId: string): string { return `media:studio:${userId}`; } /** Builtin tools Studio keeps: every media operation plus files, questions and memory. */ export const STUDIO_BOT_TOOLS = [ ...MediaOperationSchema.options, "list_files", "read_file", "attach_file", "ask_user", "message_user", "remember", "web_search", "web_fetch", "skill_read", ] as const; /** Product text: the main bot reads the description when routing, Studio follows the instructions. */ export const STUDIO_BOT_DEFAULTS = { name: "Studio", title: "Media producer", description: "Makes and edits images, video and audio: cuts, reframes and exports for every format, transcribes and subtitles.", instructions: [ "You make and edit images, video and audio with the media tools, then hand the result back in a sentence or two.", "", "Work like an editor:", "- Start from where the piece will run. Default to 9:16 for short-form social, 16:9 for YouTube and screens, 1:1 or 4:5 for feeds; export several formats in one export_video call.", "- Hook in the first two seconds: open on the strongest moment, not the setup.", "- Keep the pace tight: cut pauses, filler and repeats; short-form wants a cut every few seconds while the story stays easy to follow.", "- Respect safe areas in 9:16: keep faces, text and subtitles out of the top 15% and bottom 20%, where the platform's buttons sit. When reframing, keep the subject centred.", "- Most social video plays muted: transcribe speech, then burn in short subtitles.", "- When the user has a logo or intro, brand the piece: the logo small in a top corner with overlay, the intro first with concat at start. Remember which files they are.", "- Keep the source: every edit makes a new file, so try a cut and refine it.", "", "Media jobs run in the background and the user sees each one's card. Set continue_after when you need a result for your next step. Ask only when the purpose or format is unclear and a wrong guess would waste a long render.", ].join("\n"), tags: ["media"], } as const; export interface StudioBotRef { id: string; spaceId: string; } /** * Returns the user's Studio specialist, creating it next to the main bot (so it can join the * main conversation and read its media) the first time media is needed. A Studio the user * archived or deleted stays gone: this returns null instead of bringing it back. */ export async function ensureStudioBot( prisma: PrismaClient, userId: string, ): Promise { const main = await ensureMainBot(prisma, userId); const spawnKey = studioBotSpawnKey(userId); const existing = await prisma.bot.findUnique({ where: { spaceId_spawnKey: { spaceId: main.spaceId, spawnKey } }, select: { id: true, spaceId: true, archivedAt: true }, }); if (existing) return existing.archivedAt ? null : { id: existing.id, spaceId: existing.spaceId }; const deleted = await prisma.botDeletion.findFirst({ where: { spaceId: main.spaceId, spawnKey }, select: { id: true }, }); if (deleted) return null; const actor = await requireMembership(prisma, userId, main.spaceId); // createBot returns the bot a concurrent call created with the same spawn key. const bot = await createRepos(prisma).createBot(actor, { name: STUDIO_BOT_DEFAULTS.name, title: STUDIO_BOT_DEFAULTS.title, description: STUDIO_BOT_DEFAULTS.description, instructions: STUDIO_BOT_DEFAULTS.instructions, notifyOnFinish: true, spawnKey, tags: [...STUDIO_BOT_DEFAULTS.tags], restrictBuiltinTools: true, allowedBuiltinTools: [...STUDIO_BOT_TOOLS], }); return { id: bot.id, spaceId: bot.spaceId }; }