evOo-local/apps/api/src/main-conversation.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

87 lines
2.8 KiB
TypeScript

import { ORPCError } from "@orpc/server";
import type { Actor, MainConversationRef } from "@rakazo/contracts";
import { hasMentionToken } from "@rakazo/core";
import {
ensureMainConversation,
findMainBotId,
joinMainConversation,
type PrismaClient,
} from "@rakazo/db";
/**
* Where the main conversation lives, repaired or created on the way. Null until onboarding has
* created the main bot: reading navigation must never create a bot by itself.
*/
export async function loadMainConversationRef(
prisma: PrismaClient,
userId: string,
): Promise<MainConversationRef | null> {
if (!(await findMainBotId(prisma, userId))) return null;
const conversation = await ensureMainConversation(prisma, userId);
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}
export function assertNotMainBot(bot: { kind: string }, message: string) {
if (bot.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** Unknown or foreign groups fall through so the caller's own lookup reports them as before. */
export async function assertNotMainConversation(
prisma: PrismaClient,
actor: Pick<Actor, "spaceId" | "userId">,
groupId: string,
message: string,
) {
const group = await prisma.chatGroup.findFirst({
where: { id: groupId, spaceId: actor.spaceId, userId: actor.userId },
select: { kind: true },
});
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** A main conversation carries its main bot's name. */
export async function renameMainConversation(
prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
}
/**
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them.
*/
export async function joinAddressedBots(
prisma: PrismaClient,
actor: Pick<Actor, "spaceId" | "userId">,
input: { memberBotIds: string[]; text: string; botMentionIds: string[] },
) {
const candidates = await prisma.bot.findMany({
where: {
userId: actor.userId,
spaceId: actor.spaceId,
archivedAt: null,
id: { notIn: input.memberBotIds },
},
select: { id: true, name: true },
});
const addressed = candidates
.filter((bot) => input.botMentionIds.includes(bot.id) || hasMentionToken(input.text, bot.name))
.map((bot) => bot.id);
if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
}