evOo-local/packages/db/src/main-agent.ts
cynarAI 8449dec6c2
feat: give every project its own main agent (#125)
Each project (space) now has a main agent and a main conversation of its
own. The user talks to it, and it delegates to the project's specialists.
Creating a project sets both up and opens the conversation, and deleting
an otherwise empty project removes them with it.


Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-04 00:38:39 +02:00

325 lines
11 KiB
TypeScript

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<string | null> {
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<MainBotRef> {
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<MainConversation> {
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<string[]> {
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<string[]> {
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<UserContext> {
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,
})),
};
}