From fa97f3957080b0ae5ed156716a9834ab207d5461 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:21:56 +0000 Subject: [PATCH] feat(media): add the Studio specialist the main bot hands media work to Media work (cutting for a format, subtitles, exports) needs editing judgement and a focused tool set, so the main bot should be able to delegate it to a visible specialist instead of doing it itself. - ensureStudioBot creates "Studio" once per user, next to the main bot so it can join the main conversation and read its media, tagged media, with editor instructions (hook first, tight pacing, 9:16 safe areas, subtitles for muted playback) and a restricted builtin allowlist: every media operation plus files, questions, memory and web lookup. It has a thread, so the main bot's specialist directory lists it. The spawn key carries the user id so members of a shared space each get their own. - A Studio the user archived or deleted stays gone. Deleted bots now record their spawn key in bot_deletions so idempotent creation can tell. - Postgres suite for concurrency, shared spaces, archive and delete. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- packages/adapters/src/child-bots.test.ts | 5 +- packages/adapters/src/child-bots.ts | 7 +- .../migration.sql | 3 + packages/db/prisma/schema.prisma | 2 + packages/db/src/index.ts | 1 + packages/db/src/studio-agent.postgres.test.ts | 139 ++++++++++++++++++ packages/db/src/studio-agent.ts | 91 ++++++++++++ packages/testkit/src/cli/harness.ts | 1 + 8 files changed, 246 insertions(+), 3 deletions(-) create mode 100644 packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql create mode 100644 packages/db/src/studio-agent.postgres.test.ts create mode 100644 packages/db/src/studio-agent.ts diff --git a/packages/adapters/src/child-bots.test.ts b/packages/adapters/src/child-bots.test.ts index 6f5479e..f164116 100644 --- a/packages/adapters/src/child-bots.test.ts +++ b/packages/adapters/src/child-bots.test.ts @@ -295,7 +295,9 @@ describe("destroyBot", () => { const deleteArtifacts = vi.fn().mockResolvedValue({ count: 1 }); const transaction = vi.fn(async (callback: (tx: unknown) => Promise) => callback({ - $queryRaw: vi.fn().mockResolvedValue([]), + $queryRaw: vi + .fn() + .mockResolvedValue([{ id: "bot-1", webhookSecretId: null, spawnKey: "spawn-1" }]), chatGroup: { findMany: vi.fn().mockResolvedValue([]), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), @@ -355,6 +357,7 @@ describe("destroyBot", () => { id: "bot-1", spaceId: "workspace-1", name: "Researcher", + spawnKey: "spawn-1", deletedByUserId: "user-1", memoriesPreserved: true, }, diff --git a/packages/adapters/src/child-bots.ts b/packages/adapters/src/child-bots.ts index 9ab1df9..78a1506 100644 --- a/packages/adapters/src/child-bots.ts +++ b/packages/adapters/src/child-bots.ts @@ -370,8 +370,10 @@ export async function destroyBot( await removePiBotSessions(deps.dataDir, bot.userId, bot.id); const deletion = await withTransactionRetry(() => deps.prisma.$transaction(async (tx) => { - const locked = await tx.$queryRaw>` - SELECT id, "webhookSecretId" + const locked = await tx.$queryRaw< + Array<{ id: string; webhookSecretId: string | null; spawnKey: string | null }> + >` + SELECT id, "webhookSecretId", "spawnKey" FROM bots WHERE id = ${bot.id} AND "spaceId" = ${bot.spaceId} FOR UPDATE @@ -409,6 +411,7 @@ export async function destroyBot( id: bot.id, spaceId: bot.spaceId, name: bot.name, + spawnKey: locked[0]?.spawnKey ?? null, deletedByUserId: context.userId, memoriesPreserved: !options.deleteMemories, }, diff --git a/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql new file mode 100644 index 0000000..38bffd0 --- /dev/null +++ b/packages/db/prisma/migrations/20261003135000_bot_deletion_spawn_key/migration.sql @@ -0,0 +1,3 @@ +-- A deleted bot remembers its spawn key, so helpers that create a bot idempotently (such as the +-- Studio specialist) do not bring back one the user deleted. +ALTER TABLE "bot_deletions" ADD COLUMN "spawnKey" TEXT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index b9773da..c1785c1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -460,6 +460,8 @@ model BotDeletion { spaceId String space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) name String + /// The deleted bot's spawn key, so idempotent creation does not bring it back. + spawnKey String? deletedByUserId String memoriesPreserved Boolean deletedAt DateTime @default(now()) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..a5a142e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,6 +17,7 @@ export * from "./project-workspace.js"; export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; +export * from "./studio-agent.js"; export * from "./transaction-retry.js"; export * from "./user-profile.js"; export * from "./voice-credentials.js"; diff --git a/packages/db/src/studio-agent.postgres.test.ts b/packages/db/src/studio-agent.postgres.test.ts new file mode 100644 index 0000000..540b213 --- /dev/null +++ b/packages/db/src/studio-agent.postgres.test.ts @@ -0,0 +1,139 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import { createDb, type PrismaClient } from "./client.js"; +import { ensureMainBot } from "./main-agent.js"; +import { + ensureStudioBot, + STUDIO_BOT_DEFAULTS, + STUDIO_BOT_TOOLS, + studioBotSpawnKey, +} from "./studio-agent.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("Studio specialist (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(name = "Ada") { + const id = `st${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + return { userId: id, homeSpaceId: spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates one Studio next to the main bot, even under concurrent calls", async () => { + const { userId, homeSpaceId } = await newUser(); + const results = await Promise.all([ + ensureStudioBot(prisma, userId), + ensureStudioBot(prisma, userId), + ensureStudioBot(prisma, userId), + ]); + expect(new Set(results.map((bot) => bot?.id)).size).toBe(1); + const main = await ensureMainBot(prisma, userId); + const studios = await prisma.bot.findMany({ where: { userId, kind: "specialist" } }); + expect(studios).toHaveLength(1); + expect(studios[0]).toMatchObject({ + id: results[0]?.id, + spaceId: homeSpaceId, + name: "Studio", + instructions: STUDIO_BOT_DEFAULTS.instructions, + tags: ["media"], + restrictBuiltinTools: true, + allowedBuiltinTools: [...STUDIO_BOT_TOOLS], + spawnKey: studioBotSpawnKey(userId), + }); + expect(main.spaceId).toBe(homeSpaceId); + // A thread makes it addressable: the main bot's specialist directory lists bots with one. + expect(await prisma.thread.count({ where: { botId: studios[0]?.id } })).toBe(1); + expect(await ensureStudioBot(prisma, userId)).toEqual(results[0]); + }); + + it("gives every member of a shared space their own Studio", async () => { + const owner = await newUser(); + const ownerStudio = await ensureStudioBot(prisma, owner.userId); + const guestId = `st${randomUUID().replaceAll("-", "")}`; + userIds.push(guestId); + await prisma.user.create({ + data: { id: guestId, name: "Guest", email: `${guestId}@rakazo.test` }, + }); + const organization = await prisma.space.findUniqueOrThrow({ + where: { id: owner.homeSpaceId }, + select: { organizationId: true }, + }); + // The organization's default-space trigger makes the guest a member of the owner's space. + await prisma.member.create({ + data: { + id: `member-${guestId}`, + organizationId: organization.organizationId, + userId: guestId, + role: "member", + createdAt: new Date(), + }, + }); + const guestStudio = await ensureStudioBot(prisma, guestId); + expect(guestStudio).not.toBeNull(); + expect(guestStudio?.id).not.toBe(ownerStudio?.id); + expect(await prisma.bot.findUniqueOrThrow({ where: { id: guestStudio!.id } })).toMatchObject({ + userId: guestId, + spawnKey: studioBotSpawnKey(guestId), + }); + }); + + it("does not bring back a Studio the user archived or deleted", async () => { + const archiver = await newUser(); + const archived = await ensureStudioBot(prisma, archiver.userId); + await prisma.bot.update({ where: { id: archived!.id }, data: { archivedAt: new Date() } }); + await expect(ensureStudioBot(prisma, archiver.userId)).resolves.toBeNull(); + expect(await prisma.bot.count({ where: { userId: archiver.userId, name: "Studio" } })).toBe(1); + + const deleter = await newUser(); + const deleted = await ensureStudioBot(prisma, deleter.userId); + const row = await prisma.bot.findUniqueOrThrow({ where: { id: deleted!.id } }); + // What destroyBot records when the user deletes a bot. + await prisma.botDeletion.create({ + data: { + id: row.id, + spaceId: row.spaceId, + name: row.name, + spawnKey: row.spawnKey, + deletedByUserId: deleter.userId, + memoriesPreserved: true, + }, + }); + await prisma.bot.delete({ where: { id: row.id } }); + await expect(ensureStudioBot(prisma, deleter.userId)).resolves.toBeNull(); + expect(await prisma.bot.count({ where: { userId: deleter.userId, name: "Studio" } })).toBe(0); + }); +}); diff --git a/packages/db/src/studio-agent.ts b/packages/db/src/studio-agent.ts new file mode 100644 index 0000000..5e24154 --- /dev/null +++ b/packages/db/src/studio-agent.ts @@ -0,0 +1,91 @@ +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.", + "- 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 }; +} diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 27415a4..948ec91 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -108,6 +108,7 @@ async function main() { "packages/testkit/src/user-profile.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", + "packages/db/src/studio-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts",