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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:21:56 +00:00
parent 3f85cb0420
commit fa97f39570
No known key found for this signature in database
8 changed files with 246 additions and 3 deletions

View File

@ -295,7 +295,9 @@ describe("destroyBot", () => {
const deleteArtifacts = vi.fn().mockResolvedValue({ count: 1 });
const transaction = vi.fn(async (callback: (tx: unknown) => Promise<void>) =>
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,
},

View File

@ -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<Array<{ id: string; webhookSecretId: string | null }>>`
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,
},

View File

@ -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;

View File

@ -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())

View File

@ -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";

View File

@ -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<void>;
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);
});
});

View File

@ -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<StudioBotRef | null> {
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 };
}

View File

@ -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",