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:
parent
3f85cb0420
commit
fa97f39570
@ -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,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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;
|
||||
@ -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())
|
||||
|
||||
@ -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";
|
||||
|
||||
139
packages/db/src/studio-agent.postgres.test.ts
Normal file
139
packages/db/src/studio-agent.postgres.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
packages/db/src/studio-agent.ts
Normal file
91
packages/db/src/studio-agent.ts
Normal 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 };
|
||||
}
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user