import type { RealtimeFanout } from "@rakazo/adapter-kit"; import type { UserFactCategory } from "@rakazo/contracts"; import type { PrismaClient } from "./client.js"; import { appendEvent } from "./events.js"; import { createRepos } from "./repos.js"; import { IsolationError, requireMembership } from "./scope.js"; import { lockSpaceForContentCreation } from "./spaces.js"; /** Idempotency key of the first bot; the main bot keeps it in its home space. */ export const MAIN_BOT_SPAWN_KEY = "onboarding:first"; /** Name and role of a freshly created main bot; onboarding lets the user rename it. */ export const MAIN_BOT_DEFAULTS = { name: "Chief", title: "Chief of Staff", description: "Coordinates specialists — routes work, never a do-everything generalist.", } as const; /** Name and role of a project's main bot, the one the user talks to about that project. */ export const PROJECT_MAIN_BOT_DEFAULTS = { name: "Chief", title: "Project lead", description: "Leads this project and hands its work to specialists.", } as const; /** Upper bound on facts handed to a run, so the user block stays small. */ export const USER_CONTEXT_FACT_LIMIT = 60; export interface MainBotRef { id: string; spaceId: string; threadId: string; name: string; } export interface MainConversation { groupId: string; threadId: string; spaceId: string; leadBotId: string; } /** What every run may know about the user: the profile plus active facts visible in a space. */ export interface UserContext { name: string; timeZone: string | null; locale: string | null; language: string | null; communicationStyle: string; summary: string; facts: Array<{ category: UserFactCategory; value: string; confidence: number }>; } function isUniqueViolation(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "P2002"); } const mainBotSelect = { id: true, spaceId: true, name: true, archivedAt: true, thread: { select: { id: true } }, } as const; async function findMainBot(prisma: PrismaClient, userId: string, spaceId: string) { return prisma.bot.findFirst({ where: { userId, spaceId, kind: "main" }, select: mainBotSelect }); } /** * The user's active main bot in `spaceId`, or in their home space when it is omitted. Every * space the user works in has its own main bot: the home one is their personal main agent, a * project's leads that project. */ export async function findMainBotId( prisma: PrismaClient, userId: string, spaceId?: string, ): Promise { const space = spaceId ?? (await requireMembership(prisma, userId)).spaceId; const bot = await prisma.bot.findFirst({ where: { userId, spaceId: space, kind: "main", archivedAt: null }, select: { id: true }, }); return bot?.id ?? null; } /** * Returns the user's main bot in `spaceId` (default: the home space), creating it when it does * not exist yet. In the home space, a first bot that onboarding already created is promoted * instead of duplicated. An archived main bot is restored, because a main bot cannot be retired. */ export async function ensureMainBot( prisma: PrismaClient, userId: string, spaceId?: string, ): Promise { const actor = await requireMembership(prisma, userId, spaceId); let main = await findMainBot(prisma, userId, actor.spaceId); if (!main) { const space = await prisma.space.findUnique({ where: { id: actor.spaceId }, select: { isDefault: true }, }); const home = space?.isDefault === true || spaceId === undefined; const first = home ? await prisma.bot.findFirst({ where: { userId, spaceId: actor.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY, archivedAt: null, }, select: { id: true }, }) : null; try { if (first) { await prisma.bot.update({ where: { id: first.id }, data: { kind: "main" } }); } else { // spawnKey is unique per space, so in a space shared with another user who already // holds it, the main bot is created without one. Projects never carry it. const keyHolder = home ? await prisma.bot.findUnique({ where: { spaceId_spawnKey: { spaceId: actor.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } }, select: { userId: true }, }) : null; await createRepos(prisma).createBot(actor, { ...(home ? MAIN_BOT_DEFAULTS : PROJECT_MAIN_BOT_DEFAULTS), instructions: "", notifyOnFinish: true, ...(home && (!keyHolder || keyHolder.userId === userId) ? { spawnKey: MAIN_BOT_SPAWN_KEY } : {}), kind: "main", }); } } catch (error) { // A concurrent call promoted or created the main bot first. if (!isUniqueViolation(error)) throw error; } main = await findMainBot(prisma, userId, actor.spaceId); if (!main) throw new IsolationError("Main bot could not be created"); } if (main.archivedAt) { await prisma.bot.update({ where: { id: main.id }, data: { archivedAt: null } }); } if (!main.thread) throw new IsolationError("Main bot is missing its thread"); return { id: main.id, spaceId: main.spaceId, threadId: main.thread.id, name: main.name }; } async function findMainConversation( prisma: PrismaClient, main: { userId: string; spaceId: string; botId: string }, ) { return prisma.chatGroup.findFirst({ where: { userId: main.userId, spaceId: main.spaceId, kind: "main" }, select: { id: true, spaceId: true, leadBotId: true, archivedAt: true, thread: { select: { id: true } }, members: { where: { botId: main.botId }, select: { id: true } }, }, }); } /** * Returns the user's main conversation in `spaceId` (default: the home space): a group thread * whose lead and default responder is that space's main bot. It is created on first use and * repaired when its lead was lost or it was archived. */ export async function ensureMainConversation( prisma: PrismaClient, userId: string, spaceId?: string, ): Promise { const main = await ensureMainBot(prisma, userId, spaceId); const key = { userId, spaceId: main.spaceId, botId: main.id }; let group = await findMainConversation(prisma, key); if (!group) { try { await prisma.$transaction(async (tx) => { await lockSpaceForContentCreation(tx, { spaceId: main.spaceId, userId }); const created = await tx.chatGroup.create({ data: { spaceId: main.spaceId, userId, name: main.name, kind: "main", leadBotId: main.id, }, }); await tx.chatGroupMember.create({ data: { groupId: created.id, botId: main.id } }); await tx.thread.create({ data: { spaceId: main.spaceId, groupId: created.id, userId }, }); }); } catch (error) { // A concurrent call created the main conversation first. if (!isUniqueViolation(error)) throw error; } group = await findMainConversation(prisma, key); } else { const repairs: { leadBotId?: string; archivedAt?: null } = {}; if (group.leadBotId !== main.id) repairs.leadBotId = main.id; if (group.archivedAt) repairs.archivedAt = null; if (repairs.leadBotId !== undefined || repairs.archivedAt !== undefined) { await prisma.chatGroup.update({ where: { id: group.id }, data: repairs }); } if (group.members.length === 0) { await prisma.chatGroupMember.createMany({ data: [{ groupId: group.id, botId: main.id }], skipDuplicates: true, }); } } if (!group?.thread) throw new IsolationError("Main conversation could not be created"); return { groupId: group.id, threadId: group.thread.id, spaceId: group.spaceId, leadBotId: main.id, }; } /** * Adds bots to the user's main conversation in `spaceId` (default: the home space) so they * appear on its stage. Only active bots of the same user in that space qualify; others are * skipped. Returns the ids that were not members before, so callers can announce the change. */ export async function addMainConversationMembers( prisma: PrismaClient, input: { userId: string; spaceId?: string; botIds: string[] }, ): Promise { const requested = [...new Set(input.botIds)]; if (requested.length === 0) return []; const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId); const bots = await prisma.bot.findMany({ where: { id: { in: requested }, userId: input.userId, spaceId: conversation.spaceId, archivedAt: null, }, select: { id: true }, }); if (bots.length === 0) return []; const existing = await prisma.chatGroupMember.findMany({ where: { groupId: conversation.groupId, botId: { in: bots.map((bot) => bot.id) } }, select: { botId: true }, }); const present = new Set(existing.map((member) => member.botId)); const added = bots.map((bot) => bot.id).filter((botId) => !present.has(botId)); if (added.length > 0) { await prisma.chatGroupMember.createMany({ data: added.map((botId) => ({ groupId: conversation.groupId, botId })), skipDuplicates: true, }); } return added; } /** * Adds bots to the main conversation and, when any joined, appends a `group.updated` event to * its thread so open clients refresh the stage. Returns the ids that joined. */ export async function joinMainConversation( prisma: PrismaClient, input: { userId: string; spaceId?: string; botIds: string[] }, realtime?: RealtimeFanout, ): Promise { const added = await addMainConversationMembers(prisma, input); if (added.length === 0) return added; const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId); await appendEvent( prisma, { spaceId: conversation.spaceId, threadId: conversation.threadId, botId: conversation.leadBotId, type: "group.updated", payload: { groupId: conversation.groupId, addedBotIds: added }, }, realtime, ); return added; } /** Profile and active facts a run in `spaceId` may use; pending and other-space facts stay out. */ export async function loadUserContext( prisma: PrismaClient, input: { userId: string; spaceId: string }, ): Promise { const [user, profile, facts] = await Promise.all([ prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }), prisma.userProfile.findUnique({ where: { userId: input.userId } }), prisma.userFact.findMany({ where: { userId: input.userId, status: "active", OR: [{ allSpaces: true }, { visibleSpaces: { some: { spaceId: input.spaceId } } }], }, orderBy: [{ category: "asc" }, { updatedAt: "desc" }], take: USER_CONTEXT_FACT_LIMIT, select: { category: true, value: true, confidence: true }, }), ]); return { name: user?.name ?? "", timeZone: profile?.timeZone ?? null, locale: profile?.locale ?? null, language: profile?.language ?? null, communicationStyle: profile?.communicationStyle ?? "", summary: profile?.summary ?? "", facts: facts.map((fact) => ({ category: fact.category as UserFactCategory, value: fact.value, confidence: fact.confidence, })), }; }