diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts new file mode 100644 index 0000000..036126a --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,318 @@ +import type { MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; +import { type Prisma, type PrismaClient, updateUserProfile } from "@rakazo/db"; + +/** + * The questions the main bot asks a new user in the main conversation, one card at a time. + * Each step is a card plus what its answer changes; the order of `INTAKE_STEPS` is the order + * the user sees. Everything here is deterministic, so no model tokens are spent. + */ + +export type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; + +/** When the main bot checks in; the same shape the coach rhythm stores. */ +export interface CheckInRhythm { + morning: string | null; + evening: string | null; + weekly: { day: number; time: string } | null; +} + +export interface QuietHours { + start: string; + end: string; +} + +/** A validated answer: an option of the card, or the user's own words. */ +export type IntakeAnswer = + | { option: ChoiceOption; text?: undefined } + | { option?: undefined; text: string }; + +/** What a card may adapt to: times are written in the user's locale. */ +export interface IntakeContext { + locale: string; + timeZone: string; + now: Date; +} + +export interface IntakeEffects { + prisma: PrismaClient; + tx: Prisma.TransactionClient; + userId: string; +} + +interface IntakeStep { + id: OnboardingStep; + /** Asked only when this holds; otherwise onboarding moves past the step. */ + asks?: (device: { canEnablePush: boolean }) => boolean; + card: (context: IntakeContext) => ChoiceBlock; + /** Records a validated answer. Runs while the main conversation is locked. */ + save: (effects: IntakeEffects, answer: IntakeAnswer) => Promise; +} + +export interface FocusOption { + id: string; + letter: string; + label: string; + summary: string; + apps: string[]; + /** Stored as a `goals` fact, so it stays a plain English statement. */ + fact: string; +} + +export const FOCUS_OPTIONS: FocusOption[] = [ + { + id: "day", + letter: "A", + label: "Day-to-day work", + summary: "Slack, calendar, and email", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + summary: "email and calendar", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + summary: "the web, notes, and docs", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + summary: "a coordinated team of focused Oos", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants a coordinated crew of specialist bots", + }, +]; + +const SKIP: ChoiceOption = { id: "skip", letter: "", label: "Skip" }; + +/** The focus card. Onboarding adds a skip; another bot's card is dismissed instead. */ +export function focusCard(step?: "focus"): ChoiceBlock { + return { + kind: "choice", + question: "What do you want me on first?", + options: [ + ...FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), + ...(step ? [SKIP] : []), + ], + ...(step ? { step } : {}), + }; +} + +const RHYTHMS: Record = { + morning: { morning: "08:00", evening: null, weekly: null }, + morning_evening: { morning: "08:00", evening: "18:00", weekly: null }, + weekly: { morning: null, evening: null, weekly: { day: 0, time: "18:00" } }, + none: { morning: null, evening: null, weekly: null }, +}; + +const QUIET_HOURS: Record = { + "22-07": { start: "22:00", end: "07:00" }, + "23-08": { start: "23:00", end: "08:00" }, +}; + +// Coach and notification effects. They land with the coach (goals, rhythm) and notification +// settings modules; until then the answer stays on its card. +async function saveGoal(_effects: IntakeEffects, _title: string): Promise {} +async function saveRhythm(_effects: IntakeEffects, _rhythm: CheckInRhythm): Promise {} +async function saveQuietHours(_effects: IntakeEffects, _quietHours: QuietHours): Promise {} + +export const INTAKE_STEPS: IntakeStep[] = [ + { + id: "focus", + card: () => focusCard("focus"), + save: async ({ prisma, tx, userId }, answer) => { + const option = FOCUS_OPTIONS.find((entry) => entry.id === answer.option?.id); + if (!option) return; + await tx.userOnboarding.update({ where: { userId }, data: { focus: option.id } }); + await updateUserProfile(prisma, userId, { + addFacts: [{ category: "goals", value: option.fact, source: "onboarding" }], + }); + }, + }, + { + id: "goal", + card: () => ({ + kind: "choice", + step: "goal", + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [SKIP], + }), + save: async (effects, answer) => { + if (answer.text) await saveGoal(effects, answer.text); + }, + }, + { + id: "rhythm", + card: () => ({ + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "morning_evening", letter: "B", label: "Morning and evening" }, + { id: "weekly", letter: "C", label: "Once a week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }), + save: async (effects, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await saveRhythm(effects, rhythm); + }, + }, + { + id: "quiet_hours", + card: (context) => ({ + kind: "choice", + step: "quiet_hours", + question: "When should I stay quiet?", + options: [ + ...Object.entries(QUIET_HOURS).map(([id, range], index) => ({ + id, + letter: String.fromCharCode(65 + index), + label: `${formatClock(range.start, context.locale)} – ${formatClock(range.end, context.locale)}`, + })), + SKIP, + ], + }), + save: async (effects, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await saveQuietHours(effects, quietHours); + }, + }, + { + // The browser subscribes when the user taps "Turn on"; the answer only moves on. + id: "notifications", + asks: ({ canEnablePush }) => canEnablePush, + card: () => ({ + kind: "choice", + step: "notifications", + question: "Can I send you notifications?", + options: [ + { id: "on", letter: "A", label: "Turn on" }, + { id: "later", letter: "", label: "Not now" }, + ], + }), + save: async () => {}, + }, +]; + +export function intakeStep(id: OnboardingStep): IntakeStep { + const step = INTAKE_STEPS.find((entry) => entry.id === id); + if (!step) throw new Error(`Unknown onboarding step: ${id}`); + return step; +} + +/** The step after `id` that applies to this device, or null when onboarding is done. */ +export function nextIntakeStep( + id: OnboardingStep, + device: { canEnablePush: boolean }, +): IntakeStep | null { + const index = INTAKE_STEPS.findIndex((entry) => entry.id === id); + return INTAKE_STEPS.slice(index + 1).find((entry) => entry.asks?.(device) ?? true) ?? null; +} + +/** Checks an answer against the card it answers; null when the card does not offer it. */ +export function readIntakeAnswer( + card: ChoiceBlock, + optionId: string, + text: string | undefined, +): IntakeAnswer | null { + if (optionId === CHOICE_TEXT_ANSWER_ID) { + const words = text?.trim(); + return card.textInput && words ? { text: words } : null; + } + const option = card.options.find((entry) => entry.id === optionId); + return option ? { option } : null; +} + +/** The secondary answer ("Skip", "Not now") a dismissed card counts as. */ +export function secondaryOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.options.find((option) => !option.letter); +} + +export function rhythmForAnswer(answerId: string | undefined): CheckInRhythm | null { + return (answerId && RHYTHMS[answerId]) || null; +} + +/** Card times and the closing line follow the user's profile, falling back to English and UTC. */ +export function intakeContext( + profile: { locale: string | null; timeZone: string | null } | null, + now: Date, +): IntakeContext { + let locale = "en"; + try { + if (profile?.locale) locale = Intl.getCanonicalLocales(profile.locale)[0] ?? "en"; + } catch { + // Not a language tag; keep English. + } + return { locale, timeZone: knownTimeZone(profile?.timeZone) ?? "UTC", now }; +} + +function formatClock(time: string, locale: string): string { + const [hour = 0, minute = 0] = time.split(":").map(Number); + return new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: "UTC", + }).format(new Date(Date.UTC(2000, 0, 1, hour, minute))); +} + +function rhythmCrons(rhythm: CheckInRhythm): string[] { + const cron = (time: string, day = "*") => { + const [hour, minute] = time.split(":").map(Number); + return `${minute} ${hour} * * ${day}`; + }; + return [ + ...(rhythm.morning ? [cron(rhythm.morning)] : []), + ...(rhythm.evening ? [cron(rhythm.evening)] : []), + ...(rhythm.weekly ? [cron(rhythm.weekly.time, String(rhythm.weekly.day))] : []), + ]; +} + +function localDay(date: Date, timeZone: string): number { + const [year, month, day] = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .format(date) + .split("-") + .map(Number); + return Date.UTC(year ?? 1970, (month ?? 1) - 1, day ?? 1) / 86_400_000; +} + +/** One line that says what happens next: the first check-in, when there is one. */ +export function closingLine(rhythm: CheckInRhythm | null, context: IntakeContext): string { + const next = rhythm + ? nextCronDateAcross(rhythmCrons(rhythm), context.now, context.timeZone) + : null; + if (!next) return "All set. I’m here whenever you need me."; + const days = localDay(next, context.timeZone) - localDay(context.now, context.timeZone); + const day = + days === 0 + ? "today" + : days === 1 + ? "tomorrow" + : `on ${new Intl.DateTimeFormat("en-US", { timeZone: context.timeZone, weekday: "long" }).format(next)}`; + const time = new Intl.DateTimeFormat(context.locale, { + hour: "numeric", + minute: "2-digit", + timeZone: context.timeZone, + }).format(next); + return `All set. I’ll check in ${day} at ${time}.`; +} diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index b0bf565..8163991 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,5 +1,7 @@ +import { ORPCError } from "@orpc/server"; import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, @@ -10,12 +12,27 @@ import { type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import { + type ChoiceBlock, + closingLine, + FOCUS_OPTIONS, + type FocusOption, + focusCard, + type IntakeAnswer, + type IntakeContext, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + rhythmForAnswer, + secondaryOption, +} from "./onboarding-intake.js"; /** - * First-run conversational onboarding, seeded deterministically into the bot's - * thread: greeting, a focus choice, and available app cards the user authorizes - * inline. Focus must not rename the bot. No model tokens are spent. The main bot - * talks in the main conversation, where onboarding lands. + * Conversational onboarding, posted deterministically into a bot's thread; no model tokens are + * spent. The main bot asks the onboarding questions (`onboarding-intake.ts`) in the main + * conversation, one card after each answer. Any other bot gets one focus card that suggests + * apps the user authorizes inline. Focus never renames a bot. */ type OnboardingDeps = { @@ -24,44 +41,7 @@ type OnboardingDeps = { connectors: ConnectorRegistry; }; -type FocusOption = { - id: string; - letter: string; - label: string; - summary: string; - apps: string[]; -}; - -const FOCUS_OPTIONS: FocusOption[] = [ - { - id: "day", - letter: "A", - label: "Day-to-day work", - summary: "Slack, calendar, and email", - apps: ["slack", "gmail", "googlecalendar"], - }, - { - id: "inbox", - letter: "B", - label: "Inbox & email", - summary: "email and calendar", - apps: ["gmail", "googlecalendar", "slack"], - }, - { - id: "research", - letter: "C", - label: "Research & writing", - summary: "the web, notes, and docs", - apps: ["hackernews", "notion", "googledocs"], - }, - { - id: "crew", - letter: "D", - label: "Specialist crew", - summary: "a coordinated team of focused Oos", - apps: ["slack", "gmail", "googlecalendar"], - }, -]; +type Target = { spaceId: string; botId: string; threadId: string }; const APP_DESCRIPTIONS: Record = { slack: "Search, read, and send messages.", @@ -85,74 +65,98 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin return { bot, thread: bot.thread }; } -async function post( - deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, +async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR UPDATE`; +} + +async function postInTransaction( + tx: Prisma.TransactionClient, + target: Target, blocks: MessageBlock[], -): Promise { - const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; +): Promise<{ seq: number }> { + const message = await createThreadMessageInTransaction(tx, { + threadId: target.threadId, + role: "bot", + blocks, + botId: target.botId, + }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.created", + payload: { messageId: message.id, role: "bot", blocks }, + }); +} + +async function updateBlocksInTransaction( + tx: Prisma.TransactionClient, + target: Target, + messageId: string, + blocks: MessageBlock[], +): Promise<{ seq: number }> { + await tx.message.update({ where: { id: messageId }, data: { blocks } }); + return appendEventInTransaction(tx, { + spaceId: target.spaceId, + threadId: target.threadId, + botId: target.botId, + type: "thread.message.updated", + payload: { messageId, role: "bot", blocks }, }); - await deps.events.notify(target.threadId, committed.event.seq); - return committed.message.id; } async function updateBlocks( deps: OnboardingDeps, - target: { spaceId: string; botId: string; threadId: string }, + target: Target, messageId: string, blocks: MessageBlock[], ): Promise { - const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.message.update({ where: { id: messageId }, data: { blocks } }); - return appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId, role: "bot", blocks }, - }); - }); + const event = await deps.prisma.$transaction((tx: Prisma.TransactionClient) => + updateBlocksInTransaction(tx, target, messageId, blocks), + ); await deps.events.notify(target.threadId, event.seq); } /** Sentinel answerId for a focus card the user dismissed without choosing. */ export const FOCUS_DISMISSED_ANSWER_ID = "_dismissed"; +/** Checks that the caller may onboard this bot's thread; fresh threads start empty. */ export async function startOnboarding( deps: OnboardingDeps, actor: Actor, botId: string, ): Promise { - const { thread } = await requireBotThread(deps, actor, botId); - const existing = await deps.prisma.message.count({ where: { threadId: thread.id } }); - if (existing > 0) return; - // Fresh chats start empty (same as web). The focus card is posted later via - // promptFocus so non-first bots can wait ~10s for free typing, or skip if the - // user already engaged. + await requireBotThread(deps, actor, botId); } -function messageHasChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice"); +function isChoice(block: MessageBlock): block is ChoiceBlock { + return block.kind === "choice"; } -function messageHasPendingChoice(blocks: MessageBlock[]): boolean { - return blocks.some((block) => block.kind === "choice" && !block.answerId); +function pendingChoice(blocks: MessageBlock[]): ChoiceBlock | undefined { + return blocks.find((block): block is ChoiceBlock => isChoice(block) && !block.answerId); } +/** The newest card in the thread that still waits for an answer. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot" }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 50, + }); + for (const message of messages) { + const blocks = message.blocks as MessageBlock[]; + const card = pendingChoice(blocks); + if (card) return { messageId: message.id, blocks, card }; + } + return null; +} + +/** + * Posts the first card once. The main bot starts the onboarding questions, one set per user; + * any other bot posts its focus card only while its thread is still idle. + */ export async function promptFocus( deps: OnboardingDeps, actor: Actor, @@ -160,44 +164,31 @@ export async function promptFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const blocks: MessageBlock[] = [ - { - kind: "choice", - question: "What do you want me on first?", - options: FOCUS_OPTIONS.map(({ id, letter, label }) => ({ id, letter, label })), - }, - ]; - // Check + insert + event in one transaction so concurrent promptFocus calls - // cannot duplicate cards, and a concurrent user send cannot publish first. + const main = bot.kind === "main"; + // Check + insert + event in one transaction so concurrent callers cannot duplicate cards, + // and a concurrent user send cannot publish first. const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - // Serialize concurrent promptFocus callers on this thread before the gate check. - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; + await lockThread(tx, thread.id); + if (main && (await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }))) { + return null; + } const recent = await tx.message.findMany({ where: { threadId: thread.id }, select: { role: true, blocks: true }, - orderBy: { createdAt: "asc" }, }); - if (recent.some((message) => message.role === "user")) return null; - if (recent.some((message) => messageHasChoice(message.blocks as MessageBlock[]))) return null; - const message = await createThreadMessageInTransaction(tx, { - threadId: target.threadId, - role: "bot", - blocks, - botId: target.botId, - }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.created", - payload: { messageId: message.id, role: "bot", blocks }, - }); - return { message, event }; + if (!main && recent.some((message) => message.role === "user")) return null; + if (recent.some((message) => (message.blocks as MessageBlock[]).some(isChoice))) return null; + if (!main) return postInTransaction(tx, target, [focusCard()]); + const first = intakeStep("focus"); + await tx.userOnboarding.create({ data: { userId: actor.userId, step: first.id } }); + return postInTransaction(tx, target, [ + first.card(await loadIntakeContext(tx, actor.userId, new Date())), + ]); }); - if (!committed) return; - await deps.events.notify(target.threadId, committed.event.seq); + if (committed) await deps.events.notify(target.threadId, committed.seq); } +/** Dismisses the pending card. An onboarding question counts as skipped, so the next follows. */ export async function dismissFocus( deps: OnboardingDeps, actor: Actor, @@ -205,81 +196,143 @@ export async function dismissFocus( ): Promise { const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), + const pending = await findPendingCard(deps.prisma, thread.id); + const skip = pending?.card.step ? secondaryOption(pending.card) : undefined; + if (pending?.card.step && skip) { + await chooseFocus(deps, actor, botId, { step: pending.card.step, optionId: skip.id }); + return; + } + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (!current || current.card.step) return null; + const blocks = current.blocks.map((block) => + block === current.card ? { ...current.card, answerId: FOCUS_DISMISSED_ANSWER_ID } : block, ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" && !block.answerId - ? { ...block, answerId: FOCUS_DISMISSED_ANSWER_ID } - : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + return updateBlocksInTransaction(tx, target, current.messageId, blocks); }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); } +export interface ChooseInput { + step?: OnboardingStep; + optionId: string; + text?: string; + canEnablePush?: boolean; +} + +/** + * Answers the pending card. The answer is checked against that card, a stale answer (another + * card is pending by now) is ignored, and everything that follows is committed together with + * it: the focus card's app suggestions, then the next onboarding question or the closing line. + */ export async function chooseFocus( deps: OnboardingDeps, actor: Actor, botId: string, - optionId: string, + input: ChooseInput, ): Promise { - const option = FOCUS_OPTIONS.find((entry) => entry.id === optionId); - if (!option) throw new IsolationError(); const { bot, thread } = await requireBotThread(deps, actor, botId); const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id }; + const pending = await findPendingCard(deps.prisma, thread.id); + if (!pending || pending.card.step !== input.step) return; + const answer = readIntakeAnswer(pending.card, input.optionId, input.text); + if (!answer) throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + const focus = + !pending.card.step || pending.card.step === "focus" + ? FOCUS_OPTIONS.find((option) => option.id === answer.option?.id) + : undefined; + if (!pending.card.step && !focus) { + throw new ORPCError("BAD_REQUEST", { message: "This card has no such answer" }); + } + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus + ? await focusFollowUp(deps, actor, bot.id, focus, Boolean(pending.card.step)) + : []; - const claimed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.$executeRaw`SELECT id FROM threads WHERE id = ${thread.id} FOR UPDATE`; - const recent = await tx.message.findMany({ - where: { threadId: thread.id }, - orderBy: { createdAt: "asc" }, - }); - const pending = recent.find((message) => - messageHasPendingChoice(message.blocks as MessageBlock[]), - ); - if (!pending) return null; - const blocks = (pending.blocks as MessageBlock[]).map((block) => - block.kind === "choice" ? { ...block, answerId: option.id } : block, - ); - await tx.message.update({ where: { id: pending.id }, data: { blocks } }); - const event = await appendEventInTransaction(tx, { - spaceId: target.spaceId, - threadId: target.threadId, - botId: target.botId, - type: "thread.message.updated", - payload: { messageId: pending.id, role: "bot", blocks }, - }); - return { messageId: pending.id, blocks, event }; + const event = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => { + await lockThread(tx, thread.id); + const current = await findPendingCard(tx, thread.id); + if (current?.messageId !== pending.messageId || current.card.step !== input.step) return null; + const onboarding = current.card.step + ? await tx.userOnboarding.findUnique({ where: { userId: actor.userId } }) + : null; + if (current.card.step) { + if (onboarding?.step !== current.card.step) return null; + await intakeStep(current.card.step).save( + { prisma: deps.prisma, tx, userId: actor.userId }, + answer, + ); + } + const answered = answeredCard(current.card, answer); + const blocks = current.blocks.map((block) => (block === current.card ? answered : block)); + let last = await updateBlocksInTransaction(tx, target, current.messageId, blocks); + for (const message of followUp) last = await postInTransaction(tx, target, message); + if (current.card.step) { + last = await askNext(tx, target, actor.userId, current.card.step, input.canEnablePush); + } + return last; }); - if (!claimed) return; - await deps.events.notify(target.threadId, claimed.event.seq); + if (event) await deps.events.notify(target.threadId, event.seq); +} - // Keep the name and title the user chose when creating the bot; the focus - // step only suggests apps, it must not rename the bot. - await post(deps, target, [ - { - kind: "text", - text: `Got it. ${capitalize(option.summary)}. I’ll see what’s already connected so I don’t make you set something up twice.`, +function answeredCard(card: ChoiceBlock, answer: IntakeAnswer): ChoiceBlock { + return answer.option + ? { ...card, answerId: answer.option.id } + : { ...card, answerId: CHOICE_TEXT_ANSWER_ID, answerText: answer.text }; +} + +async function loadIntakeContext( + tx: Prisma.TransactionClient, + userId: string, + now: Date, +): Promise { + const profile = await tx.userProfile.findUnique({ + where: { userId }, + select: { locale: true, timeZone: true }, + }); + return intakeContext(profile, now); +} + +/** Posts the next onboarding question, or the closing line once every step is answered. */ +async function askNext( + tx: Prisma.TransactionClient, + target: Target, + userId: string, + answeredStep: OnboardingStep, + canEnablePush = false, +): Promise<{ seq: number }> { + const now = new Date(); + const context = await loadIntakeContext(tx, userId, now); + const next = nextIntakeStep(answeredStep, { canEnablePush }); + if (next) { + await tx.userOnboarding.update({ where: { userId }, data: { step: next.id } }); + return postInTransaction(tx, target, [next.card(context)]); + } + const rhythmCard = await tx.message.findFirst({ + where: { + threadId: target.threadId, + role: "bot", + blocks: { array_contains: [{ kind: "choice", step: "rhythm" }] }, }, - ]); + orderBy: { seq: "desc" }, + select: { blocks: true }, + }); + const rhythm = rhythmForAnswer( + (rhythmCard?.blocks as MessageBlock[] | undefined)?.find(isChoice)?.answerId, + ); + await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); + return postInTransaction(tx, target, [{ kind: "text", text: closingLine(rhythm, context) }]); +} +/** The messages after a focus answer: a short acknowledgement and the apps worth connecting. */ +async function focusFollowUp( + deps: OnboardingDeps, + actor: Actor, + botId: string, + option: FocusOption, + onboarding: boolean, +): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( await Promise.all( @@ -290,14 +343,14 @@ export async function chooseFocus( traceId: "onboarding.choose", spaceId: actor.spaceId, userId: actor.userId, - botId: bot.id, + botId, signal: AbortSignal.timeout(15_000), }) .catch(() => []), ), ) ).flat(); - const cards: MessageBlock[] = option.apps.flatMap((slug) => { + const cards = option.apps.flatMap((slug) => { const entry = catalog.find( (item) => item.slug.toLowerCase() === slug.toLowerCase() || @@ -317,26 +370,22 @@ export async function chooseFocus( ]; }); if (!cards.length) { - await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]); - return; + // In onboarding the next question follows; another bot asks what to start with. + const text = onboarding ? "Got it." : "Got it. What would you like to work on first?"; + return [[{ kind: "text", text }]]; } - const cardNames = cards - .map((card) => (card.kind === "app_connect" ? card.name : "")) - .filter(Boolean); - const named = `${cardNames.slice(0, -1).join(", ")}${cardNames.length > 1 ? ", and " : ""}${cardNames.at(-1)}`; - await post(deps, target, [ - { - kind: "text", - text: `${named} are a good place to start. Connect them here and I’ll use what you already have.`, - }, - ]); - await post(deps, target, cards); - await post(deps, target, [ - { - kind: "text", - text: `Hit those ${cards.length === 1 ? "one" : cards.length === 2 ? "two" : "three"} and I’ll start pulling the picture.`, - }, - ]); + const names = cards.map((card) => card.name); + const named = + names.length > 1 ? `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}` : names[0]; + return [ + [ + { + kind: "text", + text: `Got it. ${named} ${names.length > 1 ? "are" : "is"} a good place to start.`, + }, + ], + cards, + ]; } export async function markAppConnected( @@ -376,7 +425,3 @@ export async function markAppConnected( await updateBlocks(deps, target, message.id, next); } } - -function capitalize(value: string): string { - return value.length > 0 ? (value[0] ?? "").toUpperCase() + value.slice(1) : value; -} diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..a5b59dc 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -3417,7 +3417,7 @@ export function createRouter(deps: RouterDeps) { return { ok: true as const }; }), choose: authed.onboarding.choose.handler(async ({ context, input }) => { - await chooseFocus(onboardingDeps, context.actor, input.botId, input.optionId); + await chooseFocus(onboardingDeps, context.actor, input.botId, input); return { ok: true as const }; }), dismissFocus: authed.onboarding.dismissFocus.handler(async ({ context, input }) => { diff --git a/apps/web/src/lib/onboarding-profile.test.ts b/apps/web/src/lib/onboarding-profile.test.ts index d9ce98d..ba07e54 100644 --- a/apps/web/src/lib/onboarding-profile.test.ts +++ b/apps/web/src/lib/onboarding-profile.test.ts @@ -1,56 +1,25 @@ import { describe, expect, it } from "vitest"; import { onboardingProfileUpdate } from "./onboarding-profile"; -const blank = { name: "", timezone: "", hobbies: [], workStyle: "", communication: "" }; - describe("onboardingProfileUpdate", () => { - it("turns answers into profile fields and onboarding facts", () => { + it("fills the time zone, locale and chosen language from the browser", () => { expect( onboardingProfileUpdate({ - answers: { - name: " Alice ", - timezone: "Europe/Vienna", - hobbies: ["music", "tech", "none"], - workStyle: "deep-dive", - communication: "direct", - }, - accountName: "Alice Example", - browserTimeZone: "UTC", + browserTimeZone: "Europe/Vienna", locale: "de-AT", language: "de", }), - ).toEqual({ - name: "Alice", - timeZone: "Europe/Vienna", - locale: "de-AT", - language: "de", - communicationStyle: "Direct and concise", - addFacts: [ - { category: "preferences", value: "Interested in music", source: "onboarding" }, - { category: "preferences", value: "Interested in tech and gadgets", source: "onboarding" }, - { category: "habits", value: "Prefers focused deep dives", source: "onboarding" }, - ], - }); + ).toEqual({ timeZone: "Europe/Vienna", locale: "de-AT", language: "de" }); }); - it("keeps the account name and falls back to the browser time zone", () => { + it("leaves out a language the user did not pick and malformed values", () => { expect( onboardingProfileUpdate({ - answers: { ...blank, name: "Alice", timezone: "Not/AZone" }, - accountName: "Alice", - browserTimeZone: "America/Toronto", - }), - ).toEqual({ timeZone: "America/Toronto" }); - }); - - it("leaves out a language the user did not pick and malformed tags", () => { - expect( - onboardingProfileUpdate({ - answers: blank, - browserTimeZone: "UTC", + browserTimeZone: "Not/AZone", locale: "not a tag", language: null, }), - ).toEqual({ timeZone: "UTC" }); + ).toEqual({}); + expect(onboardingProfileUpdate({ browserTimeZone: "UTC" })).toEqual({ timeZone: "UTC" }); }); }); diff --git a/apps/web/src/lib/onboarding-profile.ts b/apps/web/src/lib/onboarding-profile.ts index 78d1d58..1d7d1de 100644 --- a/apps/web/src/lib/onboarding-profile.ts +++ b/apps/web/src/lib/onboarding-profile.ts @@ -1,73 +1,19 @@ -import type { UserFactCategory } from "@rakazo/contracts"; import { isLanguageTag, isTimeZone } from "@rakazo/contracts"; -export interface OnboardingAnswers { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -// Stored as data for the user's bots, so these stay plain English statements. -const HOBBY_FACTS: Record = { - coding: "Interested in coding", - design: "Interested in design", - writing: "Interested in writing", - music: "Interested in music", - gaming: "Interested in gaming", - reading: "Interested in reading", - sports: "Interested in sports", - cooking: "Interested in cooking", - tech: "Interested in tech and gadgets", -}; - -const WORK_STYLE_FACTS: Record = { - structured: "Works in a structured, planned way", - flexible: "Works flexibly and spontaneously", - "deep-dive": "Prefers focused deep dives", - quick: "Works quickly and pragmatically", -}; - -const COMMUNICATION_STYLES: Record = { - direct: "Direct and concise", - detailed: "Detailed and explanatory", - balanced: "Balanced", - casual: "Casual and friendly", -}; - -/** Turns onboarding answers into one profile update; empty or unknown answers are left out. */ +/** + * The profile fields onboarding fills in without asking: the browser's time zone and locale, + * and the language the user picked for their Oos. The name comes from the account. + */ export function onboardingProfileUpdate(input: { - answers: OnboardingAnswers; - /** The account name, which only changes when the user typed a different one. */ - accountName?: string; - /** Used when the typed time zone is empty or unknown. */ browserTimeZone: string; /** Browser locale, e.g. "de-AT". */ locale?: string; /** Language the user picked for their Oos, when they picked one. */ language?: string | null; }) { - const { answers } = input; - const name = answers.name.trim(); - const typedZone = answers.timezone.trim(); - const workStyle = WORK_STYLE_FACTS[answers.workStyle]; - const facts: Array<{ category: UserFactCategory; value: string }> = [ - ...answers.hobbies.flatMap((hobby) => { - const value = HOBBY_FACTS[hobby]; - return value ? [{ category: "preferences" as const, value }] : []; - }), - ...(workStyle ? [{ category: "habits" as const, value: workStyle }] : []), - ]; - const communicationStyle = COMMUNICATION_STYLES[answers.communication]; return { - ...(name && name !== input.accountName?.trim() ? { name } : {}), - timeZone: typedZone && isTimeZone(typedZone) ? typedZone : input.browserTimeZone, + ...(isTimeZone(input.browserTimeZone) ? { timeZone: input.browserTimeZone } : {}), ...(input.locale && isLanguageTag(input.locale) ? { locale: input.locale } : {}), ...(input.language && isLanguageTag(input.language) ? { language: input.language } : {}), - ...(communicationStyle ? { communicationStyle } : {}), - ...(facts.length > 0 - ? { addFacts: facts.map((fact) => ({ ...fact, source: "onboarding" as const })) } - : {}), }; } diff --git a/apps/web/src/lib/onboarding-push.ts b/apps/web/src/lib/onboarding-push.ts new file mode 100644 index 0000000..bedb187 --- /dev/null +++ b/apps/web/src/lib/onboarding-push.ts @@ -0,0 +1,14 @@ +/** + * Push for the onboarding notifications card. The server offers the card only when the browser + * that answered the card before it could still turn push on, and tapping "Turn on" is the user + * gesture that subscribes before the card is answered. Both wait for the web push client + * (`lib/web-push.ts`); until it lands the card is never offered. + */ +export async function canOfferPush(): Promise { + return false; +} + +/** Subscribes this browser; true when push is on afterwards. */ +export async function turnOnPush(): Promise { + return false; +} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..d740dfe 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { IntegrationSetupState, MainConversationRef } from "@rakazo/contracts"; -import { Button, Input } from "@rakazo/ui-web"; -import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Button } from "@rakazo/ui-web"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IntegrationSetup } from "../components/integrations/IntegrationSetup"; import { OnboardingDiscoveryShell } from "../components/onboarding/OnboardingDiscoveryShell"; @@ -24,7 +24,6 @@ import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-loo import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc, selectSpace } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; -import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { collectLocalProfileSignals, persistUserProfileHarvest } from "../lib/user-profile-harvest"; /** Selects the main conversation's space and returns its route. */ @@ -53,57 +52,11 @@ function defaultEmojiConfig(): OnboardingEmojiConfig { }; } -interface UserPreferences { - name: string; - timezone: string; - hobbies: string[]; - workStyle: string; - communication: string; -} - -const HOBBY_OPTIONS = [ - { key: "coding", label: "Coding" }, - { key: "design", label: "Design" }, - { key: "writing", label: "Writing" }, - { key: "music", label: "Music" }, - { key: "gaming", label: "Gaming" }, - { key: "reading", label: "Reading" }, - { key: "sports", label: "Sports" }, - { key: "cooking", label: "Cooking" }, - { key: "tech", label: "Tech & Gadgets" }, - { key: "none", label: Nothing specific }, -]; - -const WORK_STYLE_OPTIONS = [ - { key: "structured", label: Structured & planned }, - { key: "flexible", label: Flexible & spontaneous }, - { key: "deep-dive", label: Deep-dive focused }, - { key: "quick", label: Quick & pragmatic }, -]; - -const COMMUNICATION_OPTIONS = [ - { key: "direct", label: Direct & concise }, - { key: "detailed", label: Detailed & explanatory }, - { key: "balanced", label: Balanced }, - { key: "casual", label: Casual & friendly }, -]; - -function defaultPreferences(): UserPreferences { - return { - name: "", - timezone: localTimezone(), - hobbies: [], - workStyle: "", - communication: "", - }; -} - export function OnboardingPage() { const { t } = useLingui(); const navigate = useNavigate(); - const fieldId = useId(); const [step, setStep] = useState< - "loading" | "emoji" | "preferences" | "language" | "voice" | "integrations" | "scan" | "bot" + "loading" | "emoji" | "language" | "voice" | "integrations" | "scan" | "bot" >("emoji"); const [selectedVoiceId, setSelectedVoiceId] = useState(""); const [voiceSkipped, setVoiceSkipped] = useState(false); @@ -114,11 +67,9 @@ export function OnboardingPage() { const [bootstrapMe, setBootstrapMe] = useState> | null>(null); const [profilePersisted, setProfilePersisted] = useState(false); const [crewReady, setCrewReady] = useState(false); - const [_helpVibe, setHelpVibe] = useState(null); const [scanBotId, setScanBotId] = useState(null); const [emojiConfig, setEmojiConfig] = useState(defaultEmojiConfig); - const [prefs, setPrefs] = useState(defaultPreferences()); const [currentLocale, setCurrentLocale] = useState(() => { try { return localStorage.getItem("rakazo.uiLocale") ?? "en"; @@ -140,7 +91,6 @@ export function OnboardingPage() { .then(async ([me, integrations]) => { if (cancelled) return; setBootstrapMe(me); - setHelpVibe(inferHelpVibeHints(collectLocalProfileSignals(me))); // Get bot ID for scan phase try { const botList = await rpc.bots.list(); @@ -178,17 +128,10 @@ export function OnboardingPage() { }; }, [navigate]); - function toggleHobby(key: string) { - const hobbies = prefs.hobbies.includes(key) - ? prefs.hobbies.filter((h) => h !== key) - : [...prefs.hobbies, key]; - setPrefs({ ...prefs, hobbies }); - } - const discoveryPhase: OnboardingDiscoveryPhase = step === "loading" ? "loading" - : step === "emoji" || step === "preferences" || step === "language" || step === "voice" + : step === "emoji" || step === "language" || step === "voice" ? "emoji" : step === "integrations" ? "integrations" @@ -229,8 +172,6 @@ export function OnboardingPage() { await rpc.profile .update( onboardingProfileUpdate({ - answers: prefs, - accountName: bootstrapMe?.name, browserTimeZone: localTimezone(), locale: typeof navigator !== "undefined" ? navigator.language : undefined, language: languageChoice, @@ -252,22 +193,15 @@ export function OnboardingPage() { const me = await rpc.me().catch(() => null); if (me) setBootstrapMe(me); - const harvested = await persistUserProfileHarvest( - rpc, - collectLocalProfileSignals(me ?? undefined), - ).catch(() => null); - if (harvested) setHelpVibe(harvested); + await persistUserProfileHarvest(rpc, collectLocalProfileSignals(me ?? undefined)).catch( + () => null, + ); setProfilePersisted(true); for (const serverId of integrationServers) { await rpc.mcp.assignments.approve({ botId: bot.id, serverId }); } - const started = await rpc.onboarding - .start({ botId: bot.id }) - .then(() => true) - .catch(() => false); - if (started) { - await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); - } + // The main bot asks the rest in the main conversation. + await rpc.onboarding.promptFocus({ botId: bot.id }).catch(() => undefined); const main = await rpc.spaces .list() .then((navigation) => navigation.main) @@ -300,11 +234,7 @@ export function OnboardingPage() { {error && step !== "bot" ?

{error}

: null} @@ -313,140 +243,10 @@ export function OnboardingPage() { setStep("preferences")} + onContinue={() => setStep("language")} /> ) : null} - {step === "preferences" ? ( -
-

- Tell us about yourself -

-

- A few quick preferences so your Oo can be better tailored to you. -

- -
- - setPrefs({ ...prefs, name: e.target.value })} - placeholder={t`Your name`} - className="mt-2" - /> -
- -
- - setPrefs({ ...prefs, timezone: e.target.value })} - placeholder={t`Auto-detected or enter manually`} - className="mt-2" - /> -
- -
- - Hobbies & interests - -

- Select all that apply -

-
- {HOBBY_OPTIONS.map((hobby) => ( - - ))} -
-
- -
- - Work style - -

- How do you like to work? -

-
- {WORK_STYLE_OPTIONS.map((ws) => ( - - ))} -
-
- -
- - Communication style - -

- How should your Oo talk to you? -

-
- {COMMUNICATION_OPTIONS.map((comm) => ( - - ))} -
-
- -
- - -
-
- ) : null} - {step === "language" ? (

@@ -477,7 +277,7 @@ export function OnboardingPage() { ))}

-
) : null} -
- {block.options - .filter((option) => !block.answerId || option.id === block.answerId) - .map((option) => ( - + ))} +
+ ) : null} + {block.textInput ? ( +
{ + event.preventDefault(); + const words = text.trim(); + if (words) void choose(CHOICE_TEXT_ANSWER_ID, words); + }} > - - {option.letter} - - - {option.label} - - {block.answerId === option.id ? ( - ✓ - ) : null} - - ))} - + + setText(event.target.value)} + placeholder={block.textInput.placeholder} + aria-label={block.question} + maxLength={200} + disabled={pending} + dir="auto" + /> + + + + + + +
+ ) : null} + {waysOut.length > 0 ? ( +
+ {waysOut.map((option) => ( + + ))} +
+ ) : null} + + )} {error ?

{error}

: null} - + ); } +function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) { + return ( + + {letter} + + ); +} + export function AppConnectCard({ botId, block, diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 71c3f14..b61504d 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -18,6 +18,7 @@ const user: UserContext = { { category: "work", value: "Owns a bakery", confidence: 1 }, { category: "preferences", value: "Prefers morning meetings", confidence: 0.8 }, ], + onboardingOpen: false, }; describe("formatBotIdentity", () => { diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 479f2dd..06cbb37 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -46,6 +46,13 @@ export function formatMainAgentRole(toolNames: ReadonlySet): string { .join(" "); } +/** + * While the main bot's onboarding cards are still open, it leaves their questions to them, so + * a reply does not ask what the next card asks. + */ +export const ONBOARDING_OPEN_INSTRUCTION = + "The user is still answering onboarding cards in the main conversation: what to focus on, a goal, when to check in, quiet hours and notifications. Do not ask these questions yourself; answer what the user writes."; + /** One line of escaped user text, clipped to `max` characters. */ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): string { const text = (value ?? "").replace(/\s+/g, " ").trim(); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 5cb9181..344429e 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -248,6 +248,7 @@ import { formatMainAgentRole, formatSubagentContext, formatUserContext, + ONBOARDING_OPEN_INSTRUCTION, } from "./executor-prompt.js"; import { isProtectedComputerLifecycleCommandReadingScripts, @@ -3723,6 +3724,11 @@ export function createRunExecutor(deps: ExecutorDeps) { bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined, timeInstruction, userBlock, + bot.kind === "main" && + userContext.onboardingOpen && + !(messagingChannelRun || foreignAgentRun) + ? ONBOARDING_OPEN_INSTRUCTION + : undefined, groupContext?.instructions, inboundHandoffInstruction, delegatedTaskInstruction, diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index c15d8d0..98babd6 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -100,6 +100,20 @@ export const DELEGATED_TASK_STATUSES = [ export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES); export type DelegatedTaskStatus = z.infer; +/** Questions the main bot asks a new user, one card at a time, in the main conversation. */ +export const ONBOARDING_STEPS = [ + "focus", + "goal", + "rhythm", + "quiet_hours", + "notifications", +] as const; +export const OnboardingStepSchema = z.enum(ONBOARDING_STEPS); +export type OnboardingStep = z.infer; + +/** `optionId` of a choice answered in the user's own words. */ +export const CHOICE_TEXT_ANSWER_ID = "_text"; + /** A question or approval that pauses its run until the user answers. */ export const AskBlockSchema = z.object({ kind: z.literal("ask"), @@ -134,9 +148,16 @@ export const MessageBlock = z.discriminatedUnion("kind", [ kind: z.literal("choice"), question: z.string(), subtitle: z.string().optional(), + /** An option without a letter is a secondary answer such as "Skip". */ options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), + /** The onboarding step this card asks; absent on a specialist's focus card. */ + step: OnboardingStepSchema.optional(), + /** Lets the user answer in their own words. */ + textInput: z.object({ placeholder: z.string() }).optional(), /** Set once the user picks an option; renders the picker as answered. */ answerId: z.string().optional(), + /** The user's words when they answered through the text input. */ + answerText: z.string().optional(), }), z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..4be4f65 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -75,7 +75,7 @@ import { VoiceInfoSchema, VoiceStatusSchema, } from "./domain.js"; -import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js"; +import { DelegatedTaskStatusSchema, OnboardingStepSchema, ProductEventSchema } from "./events.js"; import { Id, IsoDate } from "./ids.js"; import { InboxListOutputSchema } from "./inbox.js"; import { @@ -660,15 +660,31 @@ export const appContract = { }, }, onboarding: { - /** Seed the first-run greeting into the bot's thread (focus card is separate). */ + /** Checks that the caller may onboard this bot's thread. Posts nothing. */ start: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Post the focus choice card when the thread is still idle. */ + /** + * Posts the first card: for the main bot the first onboarding question, once per user; + * for another bot its focus card while the thread is still idle. + */ promptFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), - /** Answer the focus choice; posts the app cards. Does not rename the bot. */ + /** + * Answers the pending card and posts what follows it: the focus card's app suggestions, + * then the next onboarding question. A stale answer (another step is pending) is ignored. + */ choose: oc - .input(z.object({ botId: Id, optionId: z.string() })) + .input( + z.object({ + botId: Id, + step: OnboardingStepSchema.optional(), + optionId: z.string(), + /** The answer in the user's words, with `optionId` "_text". */ + text: z.string().trim().min(1).max(200).optional(), + /** This device could still turn on push notifications. */ + canEnablePush: z.boolean().optional(), + }), + ) .output(z.object({ ok: z.literal(true) })), - /** Dismiss the unanswered focus card without choosing an option. */ + /** Dismisses the pending card; an onboarding question counts as skipped. */ dismissFocus: oc.input(z.object({ botId: Id })).output(z.object({ ok: z.literal(true) })), /** Flip an app_connect card to connected after authorization completes. */ appConnected: oc diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 0a3d661..45653a0 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -109,3 +109,27 @@ describe("peer message history", () => { ).toBe("[to Analyst] chart it"); }); }); + +describe("choice card history", () => { + const card = { + kind: "choice" as const, + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + { id: "none", letter: "", label: "Not now" }, + ], + }; + + it("keeps the question and the answer the user picked or typed", () => { + expect(blocksToAgentHistoryText([card])).toBe("When should I check in with you?"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "morning" }])).toBe( + "When should I check in with you?\n[answered: Every morning]", + ); + expect( + blocksToAgentHistoryText([{ ...card, answerId: "_text", answerText: "Before lunch" }]), + ).toBe("When should I check in with you?\n[answered: Before lunch]"); + expect(blocksToAgentHistoryText([{ ...card, answerId: "_dismissed" }])).toBe( + "When should I check in with you?", + ); + }); +}); diff --git a/packages/core/src/attachments.ts b/packages/core/src/attachments.ts index 9afe692..7a3a666 100644 --- a/packages/core/src/attachments.ts +++ b/packages/core/src/attachments.ts @@ -105,6 +105,12 @@ export function blocksToAgentHistoryText(blocks: MessageBlock[]): string { if (block.kind === "handoff") { return `[handoff ${block.fromBotId} -> ${block.toBotId}] ${block.text}`; } + // A card's question and answer, so a later turn knows what the user already chose. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n[answered: ${answer}]` : block.question; + } if ("text" in block && typeof block.text === "string") return block.text; return ""; }) diff --git a/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql new file mode 100644 index 0000000..1ebd459 --- /dev/null +++ b/packages/db/prisma/migrations/20261003120000_user_onboarding/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "user_onboarding" ( + "userId" TEXT NOT NULL, + "step" TEXT, + "focus" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_onboarding_pkey" PRIMARY KEY ("userId") +); + +-- AddForeignKey +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The table is new and empty, so its constraint applies immediately. +ALTER TABLE "user_onboarding" ADD CONSTRAINT "user_onboarding_step_chk" CHECK ("step" IS NULL OR "step" IN ('focus', 'goal', 'rhythm', 'quiet_hours', 'notifications')); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a6dc085..bce92fe 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -46,6 +46,7 @@ model User { botSecrets BotSecret[] agentSecrets AgentSecret[] profile UserProfile? + onboarding UserOnboarding? facts UserFact[] @@unique([email]) @@ -1429,6 +1430,21 @@ model UserProfile { @@map("user_profiles") } +/// Where the user is in the onboarding questions the main bot asks in the main conversation. +model UserOnboarding { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The step whose card is waiting for an answer; null once onboarding is complete. + step String? + focus String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // CHECK constraint user_onboarding_step_chk is created in SQL. + @@map("user_onboarding") +} + /// One durable statement about the user, with its origin and consent state. model UserFact { id String @id @default(cuid()) diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts index 6546da4..dd1f606 100644 --- a/packages/db/src/main-agent.ts +++ b/packages/db/src/main-agent.ts @@ -42,6 +42,8 @@ export interface UserContext { 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 { @@ -258,7 +260,10 @@ export async function loadUserContext( input: { userId: string; spaceId: string }, ): Promise { const [user, profile, facts] = await Promise.all([ - prisma.user.findUnique({ where: { id: input.userId }, select: { name: true } }), + 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: { @@ -283,5 +288,6 @@ export async function loadUserContext( value: fact.value, confidence: fact.confidence, })), + onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt), }; }