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; /** 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 }>; /** The main bot is still asking its onboarding questions on cards in the main conversation. */ onboardingOpen: boolean; } 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) { return prisma.bot.findFirst({ where: { userId, kind: "main" }, select: mainBotSelect }); } export async function findMainBotId(prisma: PrismaClient, userId: string): Promise { const bot = await prisma.bot.findFirst({ where: { userId, kind: "main", archivedAt: null }, select: { id: true }, }); return bot?.id ?? null; } /** * Returns the user's main bot, creating it in the home space when it does not exist yet. * A first bot that onboarding already created there is promoted instead of duplicated, and * an archived main bot is restored, because the main bot cannot be retired. */ export async function ensureMainBot(prisma: PrismaClient, userId: string): Promise { let main = await findMainBot(prisma, userId); if (!main) { const home = await requireMembership(prisma, userId); const first = await prisma.bot.findFirst({ where: { userId, spaceId: home.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY, archivedAt: null, }, select: { id: true }, }); 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. const keyHolder = await prisma.bot.findUnique({ where: { spaceId_spawnKey: { spaceId: home.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } }, select: { userId: true }, }); await createRepos(prisma).createBot(home, { ...MAIN_BOT_DEFAULTS, instructions: "", notifyOnFinish: true, ...(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); 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, userId: string, mainBotId: string) { return prisma.chatGroup.findFirst({ where: { userId, kind: "main" }, select: { id: true, spaceId: true, leadBotId: true, archivedAt: true, thread: { select: { id: true } }, members: { where: { botId: mainBotId }, select: { id: true } }, }, }); } /** * Returns the user's main conversation: a group thread in the main bot's space whose lead and * default responder is the 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, ): Promise { const main = await ensureMainBot(prisma, userId); let group = await findMainConversation(prisma, userId, main.id); 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, userId, main.id); } 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 so they appear on its stage. Only active bots of * the same user in the conversation's 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; botIds: string[] }, ): Promise { const requested = [...new Set(input.botIds)]; if (requested.length === 0) return []; const conversation = await ensureMainConversation(prisma, input.userId); 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; botIds: string[] }, realtime?: RealtimeFanout, ): Promise { const added = await addMainConversationMembers(prisma, input); if (added.length === 0) return added; const conversation = await ensureMainConversation(prisma, input.userId); 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, onboarding: { select: { completedAt: 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, })), onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt), }; }