evOo-local/packages/db/src/main-agent.ts
Claude f4e02eb248
feat(onboarding): ask the onboarding questions in the main conversation
The wizard asked for the account name again and a "Tell us about yourself" form, and the
focus answer in the main conversation was never saved. The main bot now gets to know the
user in the main conversation instead, one card after each answer.

- UserOnboarding keeps the pending step, the focus and when onboarding finished.
- onboarding-intake.ts declares the steps (focus, goal, rhythm, quiet hours, notifications)
  as cards plus what their answers change; onboarding.ts commits an answer, the focus app
  suggestions and the next card or the closing line in one transaction.
- The choice block gains a step, an optional text input and the typed answer; choose takes
  the step, the text and whether the device can still turn push on.
- The focus answer becomes a goals fact; the goal, rhythm and quiet-hours effects and the
  push seam are placeholders until the coach and notification modules are merged.
- The wizard drops the preferences step and fills time zone, locale and language from the
  browser; the main bot's prompt notes open onboarding cards; agent history shows a card's
  question and answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 13:17:06 +00:00

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