diff --git a/.env.example b/.env.example index 3565c80..5e52003 100644 --- a/.env.example +++ b/.env.example @@ -25,9 +25,9 @@ PUBLIC_POSTHOG_KEY= PUBLIC_POSTHOG_HOST=https://us.i.posthog.com SIGNUPS_ENABLED=true SIGNUP_ALLOWLIST= -# First-run discovery. Leave unset and the onboarding scan never touches the host filesystem. -# Set a home-style directory (its Projects, Documents, src, ... subfolders are scanned) to let the -# deployment owner's own onboarding discover local projects. Other accounts never scan the host. +# First-run discovery. Leave unset and onboarding never looks at the host filesystem. +# Set a folder (for example a home folder) and the deployment owner's onboarding offers to list the +# git projects in it, reading file and folder names only. Other accounts never see the step. # ONBOARDING_SCAN_HOME= ENCRYPTION_KEY=replace-with-64-random-hex-characters DATA_DIR=./data @@ -166,8 +166,12 @@ SMTP_URL= EMAIL_FROM= # Local development only: capture password-reset email and print its link to the API console. EMAIL_EMULATOR= +# Optional web push keys (set both). Unset: generated once and stored encrypted in the database. +# Browsers only allow push on https (or localhost). VAPID_PUBLIC_KEY= VAPID_PRIVATE_KEY= +# Contact for push services: mailto: or https: URL. Defaults to WEB_ORIGIN when it is https. +VAPID_SUBJECT= # Structured logs to stdout (json in production, pretty in development unless overridden). LOG_LEVEL=info LOG_FORMAT= diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 05ee689..d5da6b7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { rm } from "node:fs/promises"; import { ORPCError, onError } from "@orpc/server"; import { RPCHandler } from "@orpc/server/fetch"; import type { @@ -35,13 +34,14 @@ import { destroyBot, EmailEmulator, EncryptedSecretStore, - ExpoPushProvider, + ExpoPushSender, GraphileJobPublisher, GraphileJobWorkerHost, InMemoryJobQueue, InMemoryRealtimeFanout, InstalledConnectorProvider, IntegrationProviderSettings, + importLegacyPushTokens, isComposioEnabled, isMessagingSurfaceEnabled, isPipedreamEnabled, @@ -54,9 +54,9 @@ import { PiOAuthLogins, PipedreamConnector, PostgresRealtimeFanout, + PushNotificationProvider, pipedreamConfigFromEnv, piSessionsRoot, - pushTokenPath, reconcileCloudAgents, reconcileComputerUpdates, reconcileDelegatedTasks, @@ -65,6 +65,9 @@ import { SmtpEmailProvider, SpaceMemoryProviderResolver, toTeamChatInbound, + VapidKeys, + vapidEnvFromProcess, + WebPushSender, } from "@rakazo/adapters"; import { blockedAuthPaths, createAuth } from "@rakazo/auth"; import { signupPolicyFromEnv } from "@rakazo/core"; @@ -308,7 +311,14 @@ export async function createApp( : new PiAgentRuntime({ sessionRoot: env.piSessionRecording ? piSessionsRoot(env.dataDir) : undefined, }); - const notifications = new ExpoPushProvider(env.dataDir); + const vapid = new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env)); + const notifications = new PushNotificationProvider(prisma, { + webpush: new WebPushSender(vapid), + expo: new ExpoPushSender(), + }); + void importLegacyPushTokens(prisma, env.dataDir).catch((error) => + getLogger().error("legacy push token import failed", error), + ); const auth = createAuth(prisma, { secret: env.authSecret, baseURL: env.authUrl, @@ -364,7 +374,6 @@ export async function createApp( ), ); await removePiUserSessions(env.dataDir, userId); - await rm(pushTokenPath(env.dataDir, userId), { force: true }).catch(() => undefined); }, }); // One provider instance so emulator launches and polls share the same Map. @@ -462,6 +471,7 @@ export async function createApp( secrets, oauthLogins, integrationSettings, + vapid, mcpOAuth, composio: stack.composio, connectors: stack.connector, @@ -559,7 +569,7 @@ export async function createApp( } const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", - context: { actor, signal: c.req.raw.signal }, + context: { actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent") }, }); if (matched) return c.newResponse(response.body, response); await next(); diff --git a/apps/api/src/coach.ts b/apps/api/src/coach.ts new file mode 100644 index 0000000..2bf3fcc --- /dev/null +++ b/apps/api/src/coach.ts @@ -0,0 +1,96 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { getCoachRhythm, setCoachRhythm } from "@rakazo/adapters"; +import type { + Actor, + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + LogCheckIn, +} from "@rakazo/contracts"; +import type { CheckInFilter, GoalPatch, HabitPatch, PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, +} from "@rakazo/db"; + +export interface CoachDeps { + prisma: PrismaClient; + jobs: JobPublisher; +} + +/** Another user's ids read as missing, so a caller cannot probe them. */ +async function mapErrors(work: () => Promise): Promise { + try { + return await work(); + } catch (error) { + if (error instanceof CoachNotFoundError) { + throw new ORPCError("NOT_FOUND", { message: error.message }); + } + if (error instanceof InvalidCoachInputError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } +} + +/** Coach procedures for the signed-in user only; nothing here is space-scoped. */ +export function createCoachService(deps: CoachDeps) { + const { prisma } = deps; + return { + today: (actor: Actor): Promise => loadCoachToday(prisma, actor.userId), + + listGoals: (actor: Actor, input: { status?: Goal["status"] }) => + listGoals(prisma, actor.userId, input), + createGoal: ( + actor: Actor, + input: { + title: string; + detail?: string; + targetDate?: string | null; + spaceId?: string | null; + }, + ) => mapErrors(() => createGoal(prisma, actor.userId, { ...input, source: "user" })), + updateGoal: (actor: Actor, { goalId, ...patch }: GoalPatch & { goalId: string }) => + mapErrors(() => updateGoal(prisma, actor.userId, goalId, patch)), + removeGoal: async (actor: Actor, input: { goalId: string }) => { + await mapErrors(() => removeGoal(prisma, actor.userId, input.goalId)); + return { ok: true as const }; + }, + + listHabits: (actor: Actor, input: { includeInactive?: boolean }): Promise => + listHabits(prisma, actor.userId, input), + createHabit: ( + actor: Actor, + input: { title: string; cadence: Habit["cadence"]; goalId?: string | null }, + ) => mapErrors(() => createHabit(prisma, actor.userId, { ...input, source: "user" })), + updateHabit: (actor: Actor, { habitId, ...patch }: HabitPatch & { habitId: string }) => + mapErrors(() => updateHabit(prisma, actor.userId, habitId, patch)), + removeHabit: async (actor: Actor, input: { habitId: string }) => { + await mapErrors(() => removeHabit(prisma, actor.userId, input.habitId)); + return { ok: true as const }; + }, + + logCheckIn: (actor: Actor, input: LogCheckIn): Promise => + mapErrors(() => logCheckIn(prisma, actor.userId, input)), + listCheckIns: (actor: Actor, input: CheckInFilter) => + mapErrors(() => listCheckIns(prisma, actor.userId, input)), + + getRhythm: (actor: Actor): Promise => getCoachRhythm(prisma, actor.userId), + setRhythm: (actor: Actor, rhythm: CoachRhythm): Promise => + setCoachRhythm(deps, { userId: actor.userId, rhythm }), + }; +} diff --git a/apps/api/src/connected-app-facts.ts b/apps/api/src/connected-app-facts.ts new file mode 100644 index 0000000..5b83abe --- /dev/null +++ b/apps/api/src/connected-app-facts.ts @@ -0,0 +1,50 @@ +import { FEATURED_CONNECTOR_LABELS, matchFeaturedConnectorId } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { updateUserProfile } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +/** + * The app's own name: a featured app's label, otherwise the label of the user's first account for + * it (later accounts may be named "Gmail 2" or renamed). + */ +async function connectedAppName( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + const featured = matchFeaturedConnectorId(connection.provider); + if (featured) return FEATURED_CONNECTOR_LABELS[featured]; + const first = await prisma.connection.findFirst({ + where: { + userId: connection.userId, + connectorId: connection.connectorId, + provider: connection.provider, + }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { displayName: true }, + }); + const name = first?.displayName.trim().slice(0, 80); + return name || null; +} + +/** + * Remembers "Uses " in the user's profile when one of their connections becomes connected. + * Revoking the connection keeps the fact; the user removes it in About you. Best effort: the + * connection itself has already succeeded, so a full profile or a database error only logs. + */ +export async function recordConnectedApp( + prisma: PrismaClient, + connection: { userId: string; connectorId: string; provider: string }, +): Promise { + try { + const name = await connectedAppName(prisma, connection); + if (!name) return; + await updateUserProfile(prisma, connection.userId, { + addFacts: [{ category: "tools", value: `Uses ${name}`, source: "connector" }], + }); + } catch (error) { + getLogger().error("connected app fact was not recorded", error, { + connectorId: connection.connectorId, + provider: connection.provider, + }); + } +} diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..5da36aa 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, + spaceId = "space-home", ): InboxRunRow { return { id, + spaceId, status, updatedAt: at(0), bot, @@ -48,8 +50,10 @@ const approval = { function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, + spaceId = "space-home", ): InboxUnreadThreadRow { return { + spaceId, createdAt: at(0), bot, messages: latest @@ -227,4 +231,64 @@ describe("buildInboxItems", () => { }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); + + it("lists asks from another space's main conversation, but not the open space's own", () => { + const project = { id: "space-launch", name: "Launch" }; + const main = (groupId: string) => ({ + botId: null, + groupId, + group: { kind: "main", archivedAt: null }, + }); + const items = buildInboxItems({ + runs: [ + run("home-main", "waiting_input", main("group-main-home")), + run("project-main", "waiting_input", main("group-main-launch"), writer, project.id), + ], + messages: [ + message("ask-home", "home-main", [question], 1), + message("ask-project", "project-main", [question], 2), + ], + unreadThreads: [], + otherSpaces: new Map([[project.id, project]]), + }); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + runId: "project-main", + space: project, + target: { groupId: "group-main-launch" }, + }); + }); + + it("names the space of items from the user's other spaces", () => { + const side = { id: "space-side", name: "Side project" }; + const items = buildInboxItems({ + runs: [ + run("run-home", "waiting_input"), + run("run-side", "waiting_input", {}, writer, side.id), + run("takeover-side", "waiting_takeover", {}, writer, side.id), + ], + messages: [ + message("ask-home", "run-home", [question], 1), + message("ask-side", "run-side", [question], 2), + ], + unreadThreads: [ + unreadThread( + { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, + { id: "quiet-latest", text: "Done", minute: 3 }, + side.id, + ), + ], + otherSpaces: new Map([[side.id, side]]), + }); + + expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ + ["takeover", "Side project"], + ["ask", null], + ["ask", "Side project"], + ["unread", "Side project"], + ]); + expect(items[1]).not.toHaveProperty("space"); + expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..b430f85 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { promptSnippet } from "./runs.js"; -/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */ +/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */ const INBOX_RUN_LIMIT = 50; const INBOX_UNREAD_LIMIT = 50; const INBOX_PREVIEW_LENGTH = 280; @@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280; type AskBlock = Extract; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; export interface InboxRunRow { id: string; + spaceId: string; status: string; updatedAt: Date; bot: InboxBot; @@ -33,6 +35,7 @@ export interface InboxMessageRow { } export interface InboxUnreadThreadRow { + spaceId: string; createdAt: Date; bot: InboxBot | null; /** The newest bot message, if any. */ @@ -73,12 +76,14 @@ function previewText(blocks: unknown): string { } /** - * Where an ask can be answered from. The main conversation already shows its own asks, and an - * archived group or an external conversation has no thread target to answer through. + * Where an ask can be answered from. The open space's main conversation already shows its own + * asks (a project's main conversation does not), and an archived group or an external + * conversation has no thread target to answer through. */ -function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null { +function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null { if (thread.groupId) { - if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null; + if (!thread.group || thread.group.archivedAt) return null; + if (thread.group.kind === "main" && currentSpace) return null; return { groupId: thread.groupId }; } return thread.botId ? { botId: thread.botId } : null; @@ -95,14 +100,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number { /** * Turns waiting runs, their ask and takeover messages, and unread threads into inbox items: * everything that waits on the user first, then unread threads not already covered, each - * oldest first. + * oldest first. Items from another space name it. */ export function buildInboxItems(input: { runs: InboxRunRow[]; /** Ask and takeover messages of those runs, newest first. */ messages: InboxMessageRow[]; unreadThreads: InboxUnreadThreadRow[]; + /** The user's other spaces by id; rows of any space not listed belong to the current one. */ + otherSpaces?: ReadonlyMap; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); for (const message of input.messages) { if (!message.runId) continue; @@ -115,13 +126,14 @@ export function buildInboxItems(input: { for (const run of input.runs) { const messages = messagesByRun.get(run.id) ?? []; if (run.status === "waiting_input") { - const target = askTarget(run.thread); + const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId)); const pending = target ? newest(messages, pendingAsk) : undefined; if (!target || !pending) continue; waiting.push({ kind: isApprovalAskBlock(pending.block) ? "approval" : "ask", bot: run.bot, target, + ...spaceOf(run.spaceId), runId: run.id, messageId: pending.message.id, ask: pending.block, @@ -135,6 +147,7 @@ export function buildInboxItems(input: { kind: "takeover", bot: run.bot, target: { botId: run.bot.id }, + ...spaceOf(run.spaceId), runId: run.id, messageId: request?.message.id ?? null, text: request?.block.text ?? "", @@ -154,6 +167,7 @@ export function buildInboxItems(input: { kind: "unread", bot: thread.bot, target, + ...spaceOf(thread.spaceId), messageId: latest?.id ?? null, text: previewText(latest?.blocks), createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(), @@ -165,13 +179,34 @@ export function buildInboxItems(input: { const botSelect = { id: true, name: true, color: true } as const; -/** The current space's inbox: only the actor's own bots, runs and threads. */ +/** + * The other spaces whose items the inbox shows: every space the user belongs to, except spaces + * being deleted. + */ +async function otherInboxSpaces( + prisma: PrismaClient, + actor: Actor, +): Promise> { + const memberships = await prisma.spaceMember.findMany({ + where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } }, + select: { space: { select: { id: true, name: true } } }, + }); + return new Map(memberships.map(({ space }) => [space.id, space])); +} + +/** + * The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are + * answered and opened in their own space, where the thread procedures check membership and + * ownership again. + */ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise { + const otherSpaces = await otherInboxSpaces(prisma, actor); + const spaceIds = [actor.spaceId, ...otherSpaces.keys()]; const ownBot = { userId: actor.userId, archivedAt: null }; const [runs, unreadThreads] = await Promise.all([ prisma.run.findMany({ where: { - spaceId: actor.spaceId, + spaceId: { in: spaceIds }, userId: actor.userId, status: { in: ["waiting_input", "waiting_takeover"] }, bot: ownBot, @@ -179,6 +214,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise text.replace(/\s/g, " "); +const off = { morning: null, evening: null, weekly: null }; + +describe("onboarding steps", () => { + it("asks in order and offers notifications only to a device that can still turn push on", () => { + expect(nextIntakeStep("focus", { canEnablePush: false })?.id).toBe("goal"); + expect(nextIntakeStep("goal", { canEnablePush: false })?.id).toBe("rhythm"); + expect(nextIntakeStep("rhythm", { canEnablePush: false })?.id).toBe("quiet_hours"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: true })?.id).toBe("notifications"); + expect(nextIntakeStep("quiet_hours", { canEnablePush: false })).toBeNull(); + expect(nextIntakeStep("notifications", { canEnablePush: true })).toBeNull(); + }); + + it("gives every onboarding card a quiet way out, and another bot's focus card none", () => { + const context = intakeContext(null, new Date()); + for (const id of ["focus", "goal", "rhythm", "quiet_hours", "notifications"] as const) { + const card = intakeStep(id).card(context); + expect(card.step).toBe(id); + expect(secondaryOption(card)).toBeDefined(); + } + expect(focusCard().step).toBeUndefined(); + expect(secondaryOption(focusCard())).toBeUndefined(); + }); + + it("writes quiet hours in the user's locale", () => { + const labels = (locale: string | null) => + intakeStep("quiet_hours") + .card(intakeContext({ locale, timeZone: null }, new Date())) + .options.map((option) => plain(option.label)); + expect(labels("de-AT")).toEqual(["22:00 – 7:00", "23:00 – 8:00", "Skip"]); + expect(labels("en-US")).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + expect(labels("not a tag")).toEqual(labels("en")); + }); +}); + +describe("readIntakeAnswer", () => { + const goal = intakeStep("goal").card(intakeContext(null, new Date())); + const rhythm = intakeStep("rhythm").card(intakeContext(null, new Date())); + + it("accepts the card's own options and words only where the card takes them", () => { + expect(readIntakeAnswer(rhythm, "weekly", undefined)).toEqual({ + option: { id: "weekly", letter: "C", label: "Once a week" }, + }); + expect(readIntakeAnswer(rhythm, "skip", undefined)).toBeNull(); + expect(readIntakeAnswer(rhythm, "_text", "Mornings")).toBeNull(); + expect(readIntakeAnswer(goal, "_text", " Run a 10K ")).toEqual({ text: "Run a 10K" }); + expect(readIntakeAnswer(goal, "_text", " ")).toBeNull(); + expect(readIntakeAnswer(goal, "skip", undefined)?.option?.label).toBe("Skip"); + }); +}); + +describe("closingNote", () => { + const vienna = (iso: string, locale = "de-AT") => + intakeContext({ locale, timeZone: "Europe/Vienna" }, new Date(iso)); + const note = (rhythm: Parameters[0], context: ReturnType) => { + const { nextCheckInAt, text } = closingNote(rhythm, context); + return { nextCheckInAt, text: plain(text) }; + }; + + it("names the first check-in in the user's time zone", () => { + const morning = { ...off, morning: "08:00" }; + expect(note(morning, vienna("2026-10-07T04:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-07T06:00:00.000Z", + text: "All set. I’ll check in today at 8:00.", + }); + expect(note(morning, vienna("2026-10-07T09:00:00Z"))).toEqual({ + nextCheckInAt: "2026-10-08T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00.", + }); + expect(note({ ...morning, evening: "18:00" }, vienna("2026-10-07T09:00:00Z")).text).toBe( + "All set. I’ll check in today at 18:00.", + ); + // Wednesday in Vienna; the weekly review is on Sunday. + const weekly = { ...off, weekly: { day: 0, time: "18:00" } }; + expect(note(weekly, vienna("2026-10-07T09:00:00Z", "en-US"))).toEqual({ + nextCheckInAt: "2026-10-11T16:00:00.000Z", + text: "All set. I’ll check in on Sunday at 6:00 PM.", + }); + }); + + it("says the user can just write when no check-in is set", () => { + expect(closingNote(off, vienna("2026-10-07T09:00:00Z"))).toEqual({ + kind: "intake_note", + note: "done", + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }); + }); + + it("falls back to English and UTC for an unusable profile", () => { + expect(intakeContext({ locale: "??", timeZone: "Mars/Olympus" }, new Date(0))).toMatchObject({ + locale: "en", + timeZone: "UTC", + }); + }); +}); diff --git a/apps/api/src/onboarding-intake.ts b/apps/api/src/onboarding-intake.ts new file mode 100644 index 0000000..1ed62a6 --- /dev/null +++ b/apps/api/src/onboarding-intake.ts @@ -0,0 +1,297 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { COACH_KEYS, coachCron, setCoachRhythm } from "@rakazo/adapters"; +import type { CoachRhythm, MessageBlock, OnboardingStep, QuietHours } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { knownTimeZone, nextCronDateAcross } from "@rakazo/core"; +import type { Prisma, PrismaClient } from "@rakazo/db"; +import { createGoal, updateNotificationSettings, 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]; + +/** 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; + jobs: JobPublisher; + 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; + 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", + apps: ["slack", "gmail", "googlecalendar"], + fact: "Wants help with day-to-day work first", + }, + { + id: "inbox", + letter: "B", + label: "Inbox & email", + apps: ["gmail", "googlecalendar", "slack"], + fact: "Wants help with inbox and email first", + }, + { + id: "research", + letter: "C", + label: "Research & writing", + apps: ["hackernews", "notion", "googledocs"], + fact: "Wants help with research and writing first", + }, + { + id: "crew", + letter: "D", + label: "Specialist crew", + 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" }, +}; + +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 ({ prisma, userId }, answer) => { + if (answer.text) + await createGoal(prisma, userId, { title: answer.text, source: "onboarding" }); + }, + }, + { + 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 ({ prisma, jobs, userId }, answer) => { + const rhythm = answer.option && RHYTHMS[answer.option.id]; + if (rhythm) await setCoachRhythm({ prisma, jobs }, { userId, 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 ({ prisma, userId }, answer) => { + const quietHours = answer.option && QUIET_HOURS[answer.option.id]; + if (quietHours) await updateNotificationSettings(prisma, userId, { 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); +} + +/** 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 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; +} + +export type IntakeNote = Extract; + +/** + * The closing note: what happens next, i.e. the first check-in when there is one. The web says + * it in the user's language from `nextCheckInAt`; `text` is the English line for everything else. + */ +export function closingNote(rhythm: CoachRhythm, context: IntakeContext): IntakeNote { + const crons = COACH_KEYS.flatMap((key) => coachCron(key, rhythm) ?? []); + const next = nextCronDateAcross(crons, context.now, context.timeZone); + return { + kind: "intake_note", + note: "done", + nextCheckInAt: next?.toISOString() ?? null, + text: closingText(next, context), + }; +} + +function closingText(next: Date | null, context: IntakeContext): string { + 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-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index 549a45b..9cbafd9 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -1,166 +1,192 @@ -import { mkdir, mkdtemp, realpath, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import type * as db from "@rakazo/db"; -import { SpaceLimitError } from "@rakazo/db"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; - -const createSpaceForMember = vi.hoisted(() => vi.fn()); -const ensureMainConversation = vi.hoisted(() => vi.fn()); -vi.mock("@rakazo/db", async (original) => ({ - ...(await original()), - createSpaceForMember, - ensureMainConversation, -})); +import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { + applyOnboardingScan, + discoverOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true }; const member = { userId: "user-member", spaceId: "space-member", isDeploymentOwner: false }; +const roots: string[] = []; -type SpaceUpdate = { where: { id: string }; data: { workspacePath: string } }; - -/** The spaces the scan reads and links; new ones come from `createSpaceForMember`. */ -function fakeDatabase({ spaceLimit = Number.POSITIVE_INFINITY } = {}) { - const spaces: Array<{ id: string; name: string; workspacePath: string | null }> = []; - createSpaceForMember.mockImplementation(async (_prisma: unknown, input: { name: string }) => { - if (spaces.length >= spaceLimit) throw new SpaceLimitError(); - const space = { id: `space-${spaces.length + 1}`, name: input.name, workspacePath: null }; - spaces.push(space); - return { id: space.id, name: space.name }; - }); - const prisma = { - space: { - findMany: async () => spaces.filter((space) => space.workspacePath), - update: async ({ where, data }: SpaceUpdate) => { - const space = spaces.find((candidate) => candidate.id === where.id); - if (space) space.workspacePath = data.workspacePath; - }, +/** Fails the test if the scan writes anything before its checks pass. */ +const untouchedPrisma = new Proxy( + {}, + { + get() { + throw new Error("the database must not be touched"); }, - } as unknown as db.PrismaClient; - return { prisma, spaces }; + }, +) as PrismaClient; + +async function tempDir(): Promise { + const dir = await realpath(await mkdtemp(path.join(tmpdir(), "evoo-scan-"))); + roots.push(dir); + return dir; } -async function scanHomeWithOneFile(): Promise { - const home = await mkdtemp(join(tmpdir(), "evoo-scan-")); - await mkdir(join(home, "Projects")); - await writeFile(join(home, "Projects", "notes.ts"), "export const x = 1;\n"); - return home; +async function files(root: string, entries: string[]): Promise { + for (const entry of entries) { + const file = path.join(root, entry); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, "x\n"); + } } -async function scanHomeWithRepos(...repos: string[]): Promise { - const home = await scanHomeWithOneFile(); - for (const repo of repos) await mkdir(join(home, repo, ".git"), { recursive: true }); - return home; +async function project(root: string, relative: string, entries: string[]): Promise { + await mkdir(path.join(root, relative, ".git"), { recursive: true }); + await files(path.join(root, relative), entries); } async function completed(scanId: string, userId: string) { return await vi.waitFor( - async () => { - const status = await getScanStatus(scanId, userId); - if (status.status !== "complete" && status.status !== "failed") throw new Error("pending"); + () => { + const status = onboardingScanStatus(scanId, userId); + if (status.status === "scanning") throw new Error("pending"); return status; }, - { timeout: 10_000, interval: 50 }, + { timeout: 10_000, interval: 20 }, ); } -/** The emulated runtime that the shared test setup pins keeps the scan simulated. */ -function allowHostScan() { - vi.stubEnv("AGENT_RUNTIME", ""); - vi.stubEnv("SANDBOX_PROVIDER", ""); -} +afterAll(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); +}); describe("onboarding scan", () => { - afterEach(() => { - vi.unstubAllEnvs(); - createSpaceForMember.mockReset(); - ensureMainConversation.mockReset(); + it("lists git projects with their languages and tools and never follows a symlink", async () => { + const home = await tempDir(); + const outside = await tempDir(); + // A home folder tracked by git is not itself a project. + await mkdir(path.join(home, ".git")); + await project(home, "code/atlas", [ + "src/a.ts", + "src/b.ts", + "src/c.ts", + "src/d.tsx", + "scripts/build.js", + "Dockerfile", + ".github/workflows/ci.yml", + "infra/main.tf", + "src/odd.constructor", + "node_modules/dep/index.js", + "node_modules/dep/more.js", + ]); + await project(home, "code/garden", ["main.py"]); + await project(home, "deep/a/b/edge", ["tool.py"]); + await project(home, "deep/a/b/c/too-deep", ["main.go"]); + await project(home, ".hidden/secret", ["main.go"]); + await project(home, "notes/node_modules/vendored", ["main.go"]); + await files(home, ["notes/todo.md"]); + await project(outside, "escaped", ["main.rs"]); + await symlink(path.join(outside, "escaped"), path.join(home, "linked")); + await symlink(outside, path.join(home, "code/atlas/src/outside")); + + const findings = await discoverOnboardingScan(home); + + expect(findings.projects).toEqual([ + { + id: "project-0", + name: "atlas", + path: "code/atlas", + languages: ["TypeScript", "JavaScript"], + tools: ["Docker", "GitHub Actions", "Terraform"], + }, + { id: "project-1", name: "garden", path: "code/garden", languages: ["Python"], tools: [] }, + { id: "project-2", name: "edge", path: "deep/a/b/edge", languages: ["Python"], tools: [] }, + ]); + expect(findings.facts).toEqual([ + { id: "language:Python", label: "Python", category: "work", value: "Codes in Python" }, + { + id: "language:JavaScript", + label: "JavaScript", + category: "work", + value: "Codes in JavaScript", + }, + { + id: "language:TypeScript", + label: "TypeScript", + category: "work", + value: "Codes in TypeScript", + }, + { id: "tool:Docker", label: "Docker", category: "tools", value: "Uses Docker" }, + { + id: "tool:GitHub Actions", + label: "GitHub Actions", + category: "tools", + value: "Uses GitHub Actions", + }, + { id: "tool:Terraform", label: "Terraform", category: "tools", value: "Uses Terraform" }, + ]); }); - it("hides a scan from every account except the one that started it", async () => { - const { prisma } = fakeDatabase(); - const { scanId } = await startOnboardingScan({ actor: member }, prisma); - await expect(getScanStatus(scanId, owner.userId)).rejects.toThrow(/Scan not found/); - await expect(getScanStatus(scanId, member.userId)).resolves.toMatchObject({ - status: expect.any(String), - }); - await expect(getScanStatus("scan_missing", member.userId)).rejects.toThrow(/Scan not found/); + it("offers a scan folder only to the deployment owner and only when it exists", async () => { + const home = await tempDir(); + await files(home, ["file.txt"]); + await expect(onboardingScanFolder(member, home)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, undefined)).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "missing"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, path.join(home, "file.txt"))).resolves.toBeNull(); + await expect(onboardingScanFolder(owner, home)).resolves.toBe(home); + await expect(startOnboardingScan(member, home)).rejects.toMatchObject({ code: "FORBIDDEN" }); }); - it("never reads the host without a configured scan home", async () => { - const { prisma } = fakeDatabase(); - const { scanId } = await startOnboardingScan({ actor: owner }, prisma); + it("keeps a scan with the account that started it and returns only relative paths", async () => { + const home = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + const { scanId } = await startOnboardingScan(owner, home); + const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ localRepos: [], fileSummary: { totalFiles: 0 } }); - }); - - it("lets only the deployment owner discover the configured scan home", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app"); - const { prisma } = fakeDatabase(); - - const asMember = await startOnboardingScan({ actor: member, scanHome }, prisma); - const memberStatus = await completed(asMember.scanId, member.userId); - expect(memberStatus.result).toMatchObject({ fileSummary: { totalFiles: 0 } }); - expect(createSpaceForMember).not.toHaveBeenCalled(); - - const asOwner = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const ownerStatus = await completed(asOwner.scanId, owner.userId); - expect(ownerStatus.status).toBe("complete"); - expect(ownerStatus.result).toMatchObject({ fileSummary: { totalFiles: 1 } }); - }); - - it("links each repository of the deployment owner to one project of its own", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app", ".config/nvim"); - const app = await realpath(join(scanHome, "Projects", "app")); - const { prisma, spaces } = fakeDatabase(); - - const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const firstStatus = await completed(first.scanId, owner.userId); - expect(firstStatus.status).toBe("complete"); - expect(firstStatus.result).toMatchObject({ - localRepos: [{ name: "app" }], - createdProjects: [{ spaceId: "space-1", name: "app", path: app }], + expect(status).toEqual({ + status: "complete", + projects: [{ id: "project-0", name: "atlas", path: "code/atlas" }], + facts: [{ id: "language:TypeScript", label: "TypeScript" }], }); - expect(createSpaceForMember).toHaveBeenCalledWith(prisma, { - currentSpaceId: owner.spaceId, - userId: owner.userId, - name: "app", + expect(JSON.stringify(status)).not.toContain(home); + expect(() => onboardingScanStatus(scanId, member.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + expect(() => onboardingScanStatus("missing-scan", owner.userId)).toThrow( + expect.objectContaining({ code: "NOT_FOUND" }), + ); + await expect( + applyOnboardingScan(untouchedPrisma, member, home, { + scanId, + projectIds: ["project-0"], + factIds: [], + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("checks ownership, the folder and every chosen project again before writing", async () => { + const home = await tempDir(); + const outside = await tempDir(); + await project(home, "code/atlas", ["index.ts"]); + await project(home, "code/garden", ["main.py"]); + await project(outside, "elsewhere", ["main.rs"]); + const { scanId } = await startOnboardingScan(owner, home); + await completed(scanId, owner.userId); + const choice = { scanId, projectIds: ["project-1"], factIds: ["language:Python"] }; + + await expect( + applyOnboardingScan(untouchedPrisma, { ...owner, isDeploymentOwner: false }, home, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect( + applyOnboardingScan(untouchedPrisma, owner, outside, choice), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + // The chosen project became a symlink out of the scan folder after the scan. + await rm(path.join(home, "code/garden"), { recursive: true }); + await symlink(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + await expect(applyOnboardingScan(untouchedPrisma, owner, home, choice)).rejects.toMatchObject({ + code: "BAD_REQUEST", + message: "That folder does not exist or is not readable.", }); - expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]); - // Each new project starts with its own main agent. - expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1"); - - // Scanning again finds the linked project instead of creating a second one. - const again = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const againStatus = await completed(again.scanId, owner.userId); - expect(againStatus.result).toMatchObject({ createdProjects: [] }); - expect(createSpaceForMember).toHaveBeenCalledTimes(1); - }); - - it("joins a running scan instead of linking its repositories twice", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/app"); - const { prisma, spaces } = fakeDatabase(); - - const first = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const second = await startOnboardingScan({ actor: owner, scanHome }, prisma); - expect(second.scanId).toBe(first.scanId); - await completed(first.scanId, owner.userId); - expect(spaces).toHaveLength(1); - }); - - it("stops creating projects at the space limit and still completes", async () => { - allowHostScan(); - const scanHome = await scanHomeWithRepos("Projects/a", "Projects/b", "Projects/c"); - const { prisma } = fakeDatabase({ spaceLimit: 1 }); - - const { scanId } = await startOnboardingScan({ actor: owner, scanHome }, prisma); - const status = await completed(scanId, owner.userId); - expect(status.status).toBe("complete"); - expect(status.result).toMatchObject({ createdProjects: [{ spaceId: "space-1" }] }); - expect(createSpaceForMember).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index b804fe7..2b22819 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -1,840 +1,434 @@ -// Onboarding scan — real filesystem scan, Git analysis, and profile compilation. -// Runs synchronously on the host machine (no sandbox needed). -import { spawn } from "node:child_process"; -import { access, readdir, stat } from "node:fs/promises"; -import { basename, dirname, join } from "node:path"; -import type { Actor } from "@rakazo/contracts"; +// Onboarding discovery. With the deployment owner's consent, it lists the code projects (git +// checkouts) in the configured scan folder and the languages and tools they use. It reads +// directory listings only, never file contents, and never follows a symlink, so nothing outside +// the folder is reached. Results stay with the account that started the scan, and nothing is +// created until the owner picks projects and facts from them. +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { readdir, realpath, stat } from "node:fs/promises"; +import path from "node:path"; +import { ORPCError } from "@orpc/server"; +import type { + Actor, + OnboardingScanApplyInput, + OnboardingScanApplyResult, + OnboardingScanStatus, + UserFactCategory, +} from "@rakazo/contracts"; +import { ONBOARDING_SCAN_MAX_FACTS, ONBOARDING_SCAN_MAX_PROJECTS } from "@rakazo/contracts"; import type { PrismaClient } from "@rakazo/db"; import { createSpaceForMember, ensureMainConversation, + InvalidProjectWorkspacePathError, InvalidSpaceNameError, + InvalidUserFactError, resolveProjectWorkspaceDirectory, SpaceLimitError, + updateUserProfile, wireProjectWorkspace, } from "@rakazo/db"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- +/** Project roots are looked for at most this deep below the scan folder. */ +const PROJECT_DEPTH = 4; +/** Bounds the walk of a large home folder. */ +const MAX_DIRECTORIES = 5_000; +/** Bounds the language count inside one project. */ +const MAX_FILES_PER_PROJECT = 5_000; +const MAX_FILE_DEPTH = 8; +const MAX_LANGUAGES_PER_PROJECT = 3; +const SCAN_TTL_MS = 60 * 60_000; -interface ScanResult { - localRepos: RepoInfo[]; - projectFolders: ProjectFolder[]; - fileSummary: FileSummary; - profileSummary: ProfileSummary; - agentBriefing: string; - createdProjects: CreatedProject[]; - subAgentRoles: SubAgentRole[]; -} +const SKIPPED_DIRECTORIES = new Set([ + "node_modules", + "dist", + "build", + "out", + "target", + "vendor", + "venv", + "__pycache__", + "Library", +]); -interface RepoInfo { - path: string; - name: string; - remote: string | null; - branch: string; - lastCommit: string | null; - languages: Record; - sizeBytes: number; -} +const LANGUAGES = new Map( + Object.entries({ + ts: "TypeScript", + tsx: "TypeScript", + mts: "TypeScript", + cts: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", + py: "Python", + go: "Go", + rs: "Rust", + java: "Java", + kt: "Kotlin", + kts: "Kotlin", + swift: "Swift", + rb: "Ruby", + php: "PHP", + c: "C", + h: "C", + cc: "C++", + cpp: "C++", + cxx: "C++", + hpp: "C++", + cs: "C#", + dart: "Dart", + ex: "Elixir", + exs: "Elixir", + scala: "Scala", + lua: "Lua", + }), +); -interface ProjectFolder { - path: string; - name: string; - type: "git" | "workspace" | "documents" | "media" | "music" | "code"; - fileCount: number; - modifiedAt: string; -} +const COMPOSE_FILES = new Set([ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +]); -interface FileSummary { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -interface ProfileSummary { - interests: string[]; - skills: string[]; - workStyle: string; - communication: string; - languages: string[]; - techStack: string[]; -} - -interface CreatedProject { - spaceId: string; - name: string; - path: string; -} - -interface ScanRecord { +interface FoundProject { + id: string; + name: string; + /** Relative to the scan folder. */ + path: string; + languages: string[]; + tools: string[]; +} + +interface FoundFact { + id: string; + label: string; + category: UserFactCategory; + /** Stored as data for the user's bots, so it stays a plain English statement. */ + value: string; +} + +export interface OnboardingScanFindings { + projects: FoundProject[]; + facts: FoundFact[]; +} + +interface ScanRecord extends OnboardingScanFindings { id: string; - botId: string; ownerUserId: string; - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result: string; - startedAt: Date; - completedAt: Date | null; + /** Real path of the scanned folder. */ + home: string; + status: "scanning" | "complete" | "failed"; + finishedAt: number | null; + applying: boolean; } -// --------------------------------------------------------------------------- -// Registry (in-memory, 1h TTL) -// --------------------------------------------------------------------------- +/** Per API process; a scan only lives as long as the onboarding that started it. */ +const scans = new Map(); -const scanRegistry = new Map(); - -const emptyScanResult = (): ScanResult => ({ - localRepos: [], - projectFolders: [], - fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, - profileSummary: { - interests: [], - skills: [], - workStyle: "structured", - communication: "direct", - languages: [], - techStack: [], - }, - agentBriefing: "Profile ready", - createdProjects: [], - subAgentRoles: [], -}); - -async function runSimulatedScan(record: ScanRecord) { - const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ - { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, - { status: "connecting", progress: 40, message: "Connecting to your services..." }, - { status: "building", progress: 60, message: "Building your profile..." }, - { status: "briefing", progress: 80, message: "Briefing your agent team..." }, - ]; - for (const phase of phases) { - record.status = phase.status; - record.progress = phase.progress; - record.message = phase.message; - await new Promise((resolve) => setTimeout(resolve, 400)); - } - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(emptyScanResult()); - record.completedAt = new Date(); +/** Code-point order, so results do not depend on the server's locale. */ +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; } -type ScanActor = Pick; - -export interface OnboardingScanRequest { - actor: ScanActor; - botId?: string; - /** Home-style directory to discover. Unset keeps the scan off the host filesystem. */ - scanHome?: string; +/** Most frequent first, then by name. */ +function byCount([leftName, left]: [string, number], [rightName, right]: [string, number]): number { + return right - left || compareText(leftName, rightName); } -export async function startOnboardingScan( - { actor, botId, scanHome }: OnboardingScanRequest, - prisma: PrismaClient, -): Promise<{ scanId: string }> { - // A repeated start (a remount, a retry) joins the running scan, so no repository is linked twice. - for (const running of scanRegistry.values()) { - if (running.ownerUserId === actor.userId && !running.completedAt) return { scanId: running.id }; - } - const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; - const record: ScanRecord = { - id: scanId, - botId: botId ?? "", - ownerUserId: actor.userId, - status: "scanning", - progress: 0, - message: "Scanning your computer...", - result: "[]", - startedAt: new Date(), - completedAt: null, - }; - scanRegistry.set(scanId, record); - - // Run async - void (async () => { - try { - // The host is the deployment owner's machine: only their onboarding may discover it, and - // only inside the directory the deployment explicitly configured. - if ( - !scanHome || - !actor.isDeploymentOwner || - process.env.AGENT_RUNTIME === "scripted" || - process.env.SANDBOX_PROVIDER === "fake" - ) { - await runSimulatedScan(record); - return; - } - const result = await executeFullScan(scanHome, actor, prisma); - record.status = "complete"; - record.progress = 100; - record.message = "Scan complete — profile ready"; - record.result = JSON.stringify(result); - record.completedAt = new Date(); - } catch (error) { - record.status = "failed"; - record.message = error instanceof Error ? error.message : "Scan failed"; - record.completedAt = new Date(); - } - })(); - - return { scanId }; -} - -export async function getScanStatus( - scanId: string, - userId: string, -): Promise<{ - status: ScanRecord["status"]; - progress: number; - message: string; - result?: unknown; -}> { - const record = scanRegistry.get(scanId); - if (!record || record.ownerUserId !== userId) throw new Error("Scan not found"); - const result = record.result ? JSON.parse(record.result) : undefined; - return { status: record.status, progress: record.progress, message: record.message, result }; -} - -// --------------------------------------------------------------------------- -// Full scan pipeline -// --------------------------------------------------------------------------- - -async function executeFullScan( - homeDir: string, - actor: ScanActor, - prisma: PrismaClient, -): Promise { - // Phase 1: Local file scan - const [fileSummary, projectFolders] = await scanFiles(homeDir); - - // Phase 2: Git repository discovery - const localRepos = await discoverGitRepos(homeDir); - - // Phase 3: External service analysis (GitHub, etc.) - // Phase 4: Profile compilation from findings - const profileSummary = compileProfile(localRepos, fileSummary, projectFolders); - - // Phase 6: Projects for discovered repositories - const createdProjects = await createProjects(localRepos, actor, prisma); - - // Phase 7: Sub-agent role creation - const subAgentRoles = generateSubAgentRoles(profileSummary, localRepos); - - // Phase 5: Agent briefing (includes sub-agent roles) - const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); - - return { - localRepos, - projectFolders, - fileSummary, - profileSummary, - agentBriefing, - createdProjects, - subAgentRoles, - }; -} - -// --------------------------------------------------------------------------- -// Phase 1: File scan -// --------------------------------------------------------------------------- - -async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]]> { - const summary: FileSummary = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - const folders: ProjectFolder[] = []; - - // Scan common directories - const scanDirs = [ - "Projects", - "Documents", - "Pictures", - "Music", - "Downloads", - "Desktop", - "src", - "code", - "work", - "data", - "repos", - "dev", - "workspace", - ]; - - for (const subDir of scanDirs) { - const dirPath = join(homeDir, subDir); - try { - await access(dirPath); - const stats = await scanDirectory(dirPath, homeDir, subDir); - summary.totalFiles += stats.totalFiles; - summary.totalSizeBytes += stats.totalSizeBytes; - Object.assign(summary.byType, stats.byType); - Object.assign(summary.byExtension, stats.byExtension); - - if (stats.totalFiles > 0) { - folders.push({ - path: dirPath, - name: subDir, - type: classifyFolderType(subDir), - fileCount: stats.totalFiles, - modifiedAt: new Date().toISOString(), - }); - } - } catch { - /* directory doesn't exist, skip */ - } - } - - return [summary, folders]; -} - -interface DirStats { - totalFiles: number; - totalSizeBytes: number; - byType: Record; - byExtension: Record; -} - -async function scanDirectory( - dirPath: string, - _homeDir: string, - _relativePath: string, -): Promise { - const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; - +async function listDirectory(directory: string): Promise { try { - const entries = await readdir(dirPath, { withFileTypes: true }); + return (await readdir(directory, { withFileTypes: true })).sort((left, right) => + compareText(left.name, right.name), + ); + } catch { + return []; + } +} +/** isDirectory() is false for a symlink, so links (and anything they point to) are never walked. */ +function walkable(entry: Dirent): boolean { + return entry.isDirectory() && !entry.name.startsWith(".") && !SKIPPED_DIRECTORIES.has(entry.name); +} + +async function findProjectRoots(home: string): Promise { + const roots: string[] = []; + let visited = 0; + let level = [home]; + // Breadth first, so shallow projects win when a limit is reached. The scan folder itself is + // never a project: a home folder tracked by git still holds the projects below it. + for (let depth = 0; depth <= PROJECT_DEPTH && level.length > 0; depth += 1) { + const next: string[] = []; + for (const directory of level) { + if (roots.length >= ONBOARDING_SCAN_MAX_PROJECTS || visited >= MAX_DIRECTORIES) return roots; + visited += 1; + const entries = await listDirectory(directory); + if (depth > 0 && entries.some((entry) => entry.name === ".git")) { + roots.push(directory); + continue; + } + for (const entry of entries) { + if (walkable(entry)) next.push(path.join(directory, entry.name)); + } + } + level = next; + } + return roots; +} + +async function analyzeProject(root: string): Promise<{ languages: string[]; tools: string[] }> { + const counts = new Map(); + const tools = new Set(); + let files = 0; + const rootEntries = await listDirectory(root); + if (rootEntries.some((entry) => entry.name === ".github" && entry.isDirectory())) { + const github = await listDirectory(path.join(root, ".github")); + if (github.some((entry) => entry.name === "workflows" && entry.isDirectory())) { + tools.add("GitHub Actions"); + } + } + const visit = async (directory: string, depth: number, entries: Dirent[]): Promise => { for (const entry of entries) { - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - // Skip node_modules, .git, etc. - if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") - continue; - const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); - stats.totalFiles += subStats.totalFiles; - stats.totalSizeBytes += subStats.totalSizeBytes; - Object.assign(stats.byType, subStats.byType); - Object.assign(stats.byExtension, subStats.byExtension); - } else if (entry.isFile()) { - try { - const fileStat = await stat(fullPath); - stats.totalFiles++; - stats.totalSizeBytes += fileStat.size; - - // Classify by type - const type = classifyFileType(entry.name); - stats.byType[type] = (stats.byType[type] || 0) + 1; - - // Classify by extension - const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; - stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; - } catch { - /* permission denied */ + if (files >= MAX_FILES_PER_PROJECT) return; + if (entry.isFile()) { + files += 1; + const name = entry.name.toLowerCase(); + const extension = name.includes(".") ? name.slice(name.lastIndexOf(".") + 1) : ""; + const language = LANGUAGES.get(extension); + if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (name === "dockerfile" || name.startsWith("dockerfile.") || COMPOSE_FILES.has(name)) { + tools.add("Docker"); } + if (extension === "tf") tools.add("Terraform"); + } else if (depth < MAX_FILE_DEPTH && walkable(entry)) { + const child = path.join(directory, entry.name); + await visit(child, depth + 1, await listDirectory(child)); } } - } catch { - /* permission denied */ - } - - return stats; + }; + await visit(root, 0, rootEntries); + const total = [...counts.values()].reduce((sum, count) => sum + count, 0); + const languages = [...counts.entries()] + .filter(([, count]) => count >= total * 0.1) + .sort(byCount) + .slice(0, MAX_LANGUAGES_PER_PROJECT) + .map(([language]) => language); + return { languages, tools: [...tools].sort() }; } -function classifyFileType(filename: string): string { - const ext = filename.split(".").pop()?.toLowerCase() || ""; - const codeExts = [ - "ts", - "tsx", - "js", - "jsx", - "py", - "go", - "rs", - "java", - "c", - "cpp", - "h", - "rb", - "php", - "swift", - "kt", - ]; - const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; - const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; - const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; - - if (codeExts.includes(ext)) return "code"; - if (docExts.includes(ext)) return "documents"; - if (imageExts.includes(ext)) return "images"; - if (musicExts.includes(ext)) return "music"; - if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) - return "config"; - return "other"; +/** Labels used by the most projects first, then by name. */ +function rankLabels(lists: string[][]): string[] { + const counts = new Map(); + for (const list of lists) + for (const label of list) counts.set(label, (counts.get(label) ?? 0) + 1); + return [...counts.entries()].sort(byCount).map(([label]) => label); } -function classifyFolderType(name: string): ProjectFolder["type"] { - const lower = name.toLowerCase(); - if (lower.includes("music")) return "music"; - if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) - return "media"; - if (lower.includes("document") || lower.includes("doc")) return "documents"; - if ( - lower.includes("project") || - lower.includes("code") || - lower.includes("src") || - lower.includes("dev") || - lower.includes("workspace") || - lower.includes("repo") - ) - return "code"; - return "workspace"; +/** Lists the projects below a real (symlink-free) folder path, and what they use. */ +export async function discoverOnboardingScan(home: string): Promise { + const roots = await findProjectRoots(home); + const analyzed = await Promise.all( + roots.map(async (root) => ({ root, ...(await analyzeProject(root)) })), + ); + const projects = analyzed + .map(({ root, languages, tools }) => ({ + name: path.basename(root), + path: path.relative(home, root), + languages, + tools, + })) + .sort((left, right) => compareText(left.path, right.path)) + .map((project, index) => ({ id: `project-${index}`, ...project })); + const facts: FoundFact[] = [ + ...rankLabels(projects.map((project) => project.languages)).map((label) => ({ + id: `language:${label}`, + label, + category: "work" as const, + value: `Codes in ${label}`, + })), + ...rankLabels(projects.map((project) => project.tools)).map((label) => ({ + id: `tool:${label}`, + label, + category: "tools" as const, + value: `Uses ${label}`, + })), + ].slice(0, ONBOARDING_SCAN_MAX_FACTS); + return { projects, facts }; } -// --------------------------------------------------------------------------- -// Phase 2: Git repository discovery -// --------------------------------------------------------------------------- - -async function discoverGitRepos(homeDir: string): Promise { - const repos: RepoInfo[] = []; - +/** + * The scan reads the server's filesystem, so only the deployment owner may scan, and only the + * folder the deployment configured. Null hides the step. + */ +export async function onboardingScanFolder( + actor: Pick, + scanHome: string | undefined, +): Promise { + if (!actor.isDeploymentOwner || !scanHome) return null; try { - const gitDirs = await findGitDirectories(homeDir); - - for (const repoDir of gitDirs) { - try { - const repo = await analyzeGitRepo(repoDir, homeDir); - if (repo) repos.push(repo); - } catch { - /* skip broken repos */ - } - } - } catch { - /* git not available */ - } - - return repos; -} - -async function findGitDirectories(dir: string, depth: number = 0): Promise { - if (depth > 4) return []; // Limit search depth - const results: string[] = []; - - try { - const entries = await readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === ".git") { - results.push(dirname(fullPath)); - } else if ( - // Dot folders hold tools and caches (shell and editor plugins), not the person's projects. - !entry.name.startsWith(".") && - entry.name !== "node_modules" && - entry.name !== "dist" - ) { - results.push(...(await findGitDirectories(fullPath, depth + 1))); - } - } - } - } catch { - /* permission denied */ - } - - return results; -} - -async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { - try { - const name = repoDir.split("/").pop() || ""; - - // Get remote URL - const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const remote = remoteResult.stdout.trim() || null; - - // Get current branch - const branchResult = await spawnPromise("git", ["branch", "--show-current"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const branch = branchResult.stdout.trim() || "unknown"; - - // Get last commit - const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { - cwd: repoDir, - }).catch(() => ({ stdout: "" })); - const lastCommit = commitResult.stdout.trim() || null; - - // Get language stats via git ls-files + wc - const languages = await getRepoLanguages(repoDir); - - // Get directory size - const sizeResult = await spawnPromise("du", ["-sb", repoDir]).catch(() => ({ stdout: "0" })); - const sizeBytes = parseInt(sizeResult.stdout.split("\t")[0] || "0", 10); - - return { path: repoDir, name, remote, branch, lastCommit, languages, sizeBytes }; + const home = await realpath(scanHome); + return (await stat(home)).isDirectory() ? home : null; } catch { return null; } } -async function getRepoLanguages(repoDir: string): Promise> { - const langs: Record = {}; - try { - const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ - stdout: "", - })); - const files = stdout.trim().split("\n").filter(Boolean); - for (const file of files) { - const ext = file.split(".").pop()?.toLowerCase() || ""; - const langMap: Record = { - ts: "TypeScript", - tsx: "TypeScript", - js: "JavaScript", - jsx: "JavaScript", - py: "Python", - go: "Go", - rs: "Rust", - java: "Java", - c: "C", - cpp: "C++", - rb: "Ruby", - php: "PHP", - swift: "Swift", - kt: "Kotlin", - }; - const lang = langMap[ext] || ext || "unknown"; - langs[lang] = (langs[lang] || 0) + 1; - } - } catch { - /* no languages */ +function forgetExpiredScans(now: number): void { + for (const [id, scan] of scans) { + if (scan.finishedAt !== null && now - scan.finishedAt > SCAN_TTL_MS) scans.delete(id); } - return langs; } -// --------------------------------------------------------------------------- -// Phase 3: Profile compilation -// --------------------------------------------------------------------------- - -function compileProfile( - repos: RepoInfo[], - fileSummary: FileSummary, - _folders: ProjectFolder[], -): ProfileSummary { - const interests: string[] = []; - const skills: string[] = []; - const techStack: string[] = []; - const languageSet = new Set(); - - // Extract tech stack from repos - for (const repo of repos) { - for (const lang of Object.keys(repo.languages)) { - techStack.push(lang); - languageSet.add(lang); - } - } - - // Extract interests from file types - for (const [type, count] of Object.entries(fileSummary.byType)) { - if (count > 5) { - interests.push(`${type} (${count} files)`); - } - } - - // Derive skills from languages - const langToSkill: Record = { - TypeScript: "TypeScript/JavaScript", - Python: "Python", - Go: "Go", - Rust: "Rust", - Java: "Java", - C: "C/C++", - "C++": "C/C++", - Ruby: "Ruby", - Swift: "Swift/Kotlin", - PHP: "PHP", +export async function startOnboardingScan( + actor: Pick, + scanHome: string | undefined, +): Promise<{ scanId: string }> { + const home = await onboardingScanFolder(actor, scanHome); + if (!home) throw new ORPCError("FORBIDDEN"); + forgetExpiredScans(Date.now()); + const running = [...scans.values()].find( + (scan) => scan.ownerUserId === actor.userId && scan.status === "scanning", + ); + if (running) return { scanId: running.id }; + const scan: ScanRecord = { + id: randomUUID(), + ownerUserId: actor.userId, + home, + status: "scanning", + projects: [], + facts: [], + finishedAt: null, + applying: false, }; - for (const lang of techStack) { - const skill = langToSkill[lang]; - if (skill && !skills.includes(skill)) skills.push(skill); - } + scans.set(scan.id, scan); + void discoverOnboardingScan(home) + .then((findings) => { + scan.projects = findings.projects; + scan.facts = findings.facts; + scan.status = "complete"; + }) + .catch(() => { + scan.status = "failed"; + }) + .finally(() => { + scan.finishedAt = Date.now(); + }); + return { scanId: scan.id }; +} - // Deduplicate - const uniqueInterests = [...new Set(interests)].slice(0, 10); - const uniqueSkills = [...new Set(skills)].slice(0, 15); - const uniqueTech = [...new Set(techStack)].sort(); +/** Another account's scan reads as missing. */ +function ownedScan(scanId: string, userId: string): ScanRecord { + const scan = scans.get(scanId); + if (!scan || scan.ownerUserId !== userId) throw new ORPCError("NOT_FOUND"); + return scan; +} +export function onboardingScanStatus(scanId: string, userId: string): OnboardingScanStatus { + const scan = ownedScan(scanId, userId); + if (scan.status !== "complete") return { status: scan.status }; return { - interests: uniqueInterests, - skills: uniqueSkills, - workStyle: - uniqueTech.length > 3 - ? "multi-language" - : uniqueTech[0] - ? `${uniqueTech[0]}-focused` - : "general", - communication: "structured", - languages: Array.from(languageSet), - techStack: uniqueTech, + status: "complete", + projects: scan.projects.map(({ id, name, path }) => ({ id, name, path })), + facts: scan.facts.map(({ id, label }) => ({ id, label })), }; } -// --------------------------------------------------------------------------- -// Phase 4: Agent briefing -// --------------------------------------------------------------------------- +const MISSING_FOLDER = "That folder does not exist or is not readable."; -function generateBriefing( - repos: RepoInfo[], - profile: ProfileSummary, - fileSummary: FileSummary, - subAgentRoles: SubAgentRole[], -): string { - const lines: string[] = []; - lines.push(`# User Profile Briefing`); - lines.push(``); - lines.push(`## Technical Profile`); - lines.push(`- **Tech Stack:** ${profile.techStack.join(", ") || "N/A"}`); - lines.push(`- **Languages:** ${profile.languages.join(", ") || "N/A"}`); - lines.push(`- **Work Style:** ${profile.workStyle}`); - lines.push(``); - lines.push(`## Local Repositories (${repos.length} found)`); - for (const repo of repos) { - lines.push( - `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, - ); +/** Resolves a found project again, so a folder swapped for a symlink cannot leave the scan folder. */ +async function confinedProjectFolder(home: string, relative: string): Promise { + const folder = await resolveProjectWorkspaceDirectory(path.join(home, relative)); + const inside = path.relative(home, folder); + if (!inside || inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) { + throw new InvalidProjectWorkspacePathError(MISSING_FOLDER); } - lines.push(``); - lines.push(`## File System Summary`); - lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); - lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); - lines.push( - `- **Categories:** ${Object.entries(fileSummary.byType) - .map(([t, c]) => `${t}(${c})`) - .join(", ")}`, - ); - lines.push(``); - lines.push(`## User Interests`); - lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## User Skills`); - lines.push(`- ${profile.skills.slice(0, 5).join("\n- ")}`); - lines.push(``); - lines.push(`## Assigned Specialist Roles`); - for (const role of subAgentRoles) { - lines.push(`### ${role.name}`); - lines.push(`- **Role:** ${role.role}`); - lines.push(`- **Description:** ${role.description}`); - lines.push(`- **Instructions:** ${role.instructions}`); - lines.push(`- **Skills:** ${role.skills.join(", ")}`); - lines.push(""); - } - lines.push(`---`); - lines.push( - `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, - ); - return lines.join("\n"); + return folder; } -// --------------------------------------------------------------------------- -// Phase 5: Projects for discovered repositories -// --------------------------------------------------------------------------- - -/** Give each discovered repository a project linked to its folder, unless one already links it. */ -async function createProjects( - repos: RepoInfo[], - actor: ScanActor, +async function projectSpace( prisma: PrismaClient, -): Promise { - const linked = await prisma.space.findMany({ - where: { workspacePath: { not: null }, memberships: { some: { userId: actor.userId } } }, - select: { workspacePath: true }, - }); - const linkedFolders = new Set(linked.map((space) => space.workspacePath)); - const created: CreatedProject[] = []; - - for (const repo of repos) { - // Compare and link the real folder, so the next scan recognizes the projects of this one. - // Checking it before the project exists means a folder that cannot be linked leaves none. - const folder = await resolveProjectWorkspaceDirectory(repo.path).catch(() => null); - if (!folder || linkedFolders.has(folder)) continue; - let space: { id: string; name: string }; - try { - space = await createSpaceForMember(prisma, { - currentSpaceId: actor.spaceId, - userId: actor.userId, - name: basename(folder), - }); - } catch (error) { - if (error instanceof SpaceLimitError) break; - if (error instanceof InvalidSpaceNameError) continue; - throw error; - } - const workspace = await wireProjectWorkspace(prisma, { - spaceId: space.id, + actor: Pick, + name: string, + folder: string, +): Promise<{ id: string; name: string }> { + // Applying twice, or onboarding again, reuses the user's own space linked to this folder. + const existing = await prisma.space.findFirst({ + where: { workspacePath: folder, - }); - await ensureMainConversation(prisma, actor.userId, space.id); - created.push({ spaceId: space.id, name: space.name, path: workspace.path }); - } - - return created; -} - -// --------------------------------------------------------------------------- -// Phase 6: Sub-agent role creation -// --------------------------------------------------------------------------- - -interface SubAgentRole { - name: string; - role: string; - description: string; - instructions: string; - skills: string[]; -} - -function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] { - const roles: SubAgentRole[] = []; - const hasCode = profile.languages?.length > 0; - const hasDesign = profile.interests?.some( - (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), - ); - const hasData = profile.interests?.some( - (i) => - i.toLowerCase().includes("data") || - i.toLowerCase().includes("analytics") || - i.toLowerCase().includes("database"), - ); - const hasDevOps = profile.interests?.some( - (i) => - i.toLowerCase().includes("infra") || - i.toLowerCase().includes("ops") || - i.toLowerCase().includes("deploy"), - ); - const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); - const hasWriting = profile.interests?.some( - (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), - ); - - if (hasCode) { - const primaryLang = profile.languages?.[0] || "TypeScript"; - roles.push({ - name: "Code Engineer", - role: "software-engineer", - description: `Specializes in ${primaryLang} development and code review`, - instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, - skills: profile.skills.filter( - (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), - ), - }); - } - - if (hasDesign) { - roles.push({ - name: "UI/UX Designer", - role: "ui-designer", - description: "Creates polished interfaces and design systems", - instructions: - "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", - skills: ["design", "UI/UX"], - }); - } - - if (hasData) { - roles.push({ - name: "Data Analyst", - role: "data-analyst", - description: "Analyzes data, creates reports, and extracts insights", - instructions: - "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", - skills: ["data analysis", "statistics"], - }); - } - - if (hasDevOps) { - roles.push({ - name: "DevOps Engineer", - role: "devops-engineer", - description: "Manages infrastructure, deployments, and CI/CD", - instructions: - "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", - skills: ["DevOps", "infrastructure"], - }); - } - - if (hasMusic) { - roles.push({ - name: "Music Producer", - role: "music-producer", - description: "Assists with music production and audio engineering", - instructions: - "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", - skills: ["music production", "audio engineering"], - }); - } - - if (hasWriting) { - roles.push({ - name: "Content Writer", - role: "content-writer", - description: "Creates and edits written content", - instructions: - "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", - skills: ["writing", "content creation"], - }); - } - - // Always include a general assistant - roles.unshift({ - name: "General Assistant", - role: "general-assistant", - description: "Handles general tasks and coordinates other specialists", - instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ - roles - .slice(1) - .map((r) => r.name) - .join(", ") || "none yet" - }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, - skills: ["task management", "coordination"], + deletingAt: null, + memberships: { some: { userId: actor.userId } }, + }, + select: { id: true, name: true }, + orderBy: { createdAt: "asc" }, }); - - return roles; -} - -// --------------------------------------------------------------------------- -// Utility -// --------------------------------------------------------------------------- - -function spawnPromise( - cmd: string, - args: string[], - options?: { cwd?: string }, -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = spawn(cmd, args, { ...options, timeout: 15000 }); - let stdout = ""; - let stderr = ""; - child.stdout?.on("data", (d: Buffer) => { - stdout += d.toString(); - }); - child.stderr?.on("data", (d: Buffer) => { - stderr += d.toString(); - }); - child.on("error", () => reject(new Error("spawn failed"))); - child.on("close", (code) => { - if (code === 0 || code === null) resolve({ stdout, stderr }); - else reject(new Error(`exit ${code}`)); - }); + if (existing) return existing; + const space = await createSpaceForMember(prisma, { + currentSpaceId: actor.spaceId, + userId: actor.userId, + name: name.slice(0, 60), }); + await wireProjectWorkspace(prisma, { spaceId: space.id, workspacePath: folder }); + // Like any new project, it starts with its own main agent. + await ensureMainConversation(prisma, actor.userId, space.id); + return space; } -// Keep old registry cleanup -setInterval(() => { - const cutoff = Date.now() - 3600000; - for (const [id, r] of scanRegistry) { - if (r.completedAt && r.completedAt.getTime() < cutoff) scanRegistry.delete(id); +function toRpcError(error: unknown): unknown { + if ( + error instanceof SpaceLimitError || + error instanceof InvalidSpaceNameError || + error instanceof InvalidProjectWorkspacePathError || + error instanceof InvalidUserFactError + ) { + return new ORPCError("BAD_REQUEST", { message: error.message }); } -}, 60000); + return error; +} + +/** Creates a project space per chosen project and saves the chosen facts to the profile. */ +export async function applyOnboardingScan( + prisma: PrismaClient, + actor: Pick, + scanHome: string | undefined, + input: OnboardingScanApplyInput, +): Promise { + const scan = ownedScan(input.scanId, actor.userId); + if (scan.status !== "complete" || scan.applying) throw new ORPCError("CONFLICT"); + scan.applying = true; + try { + // Ownership or the configured folder can change after the scan; check again before writing. + if ((await onboardingScanFolder(actor, scanHome)) !== scan.home) { + throw new ORPCError("FORBIDDEN"); + } + const projectIds = new Set(input.projectIds); + const factIds = new Set(input.factIds); + const projects = scan.projects.filter((project) => projectIds.has(project.id)); + const facts = scan.facts.filter((fact) => factIds.has(fact.id)); + // Every chosen folder is checked before anything is written. + const chosen = await Promise.all( + projects.map(async (project) => ({ + name: project.name, + folder: await confinedProjectFolder(scan.home, project.path), + })), + ); + const added = + facts.length > 0 + ? await updateUserProfile(prisma, actor.userId, { + addFacts: facts.map(({ category, value }) => ({ + category, + value, + source: "onboarding" as const, + })), + }) + : []; + const spaces: Array<{ id: string; name: string }> = []; + for (const { name, folder } of chosen) + spaces.push(await projectSpace(prisma, actor, name, folder)); + return { spaces, factsAdded: added.filter((fact) => fact.created).length }; + } catch (error) { + throw toRpcError(error); + } finally { + scan.applying = false; + } +} diff --git a/apps/api/src/onboarding.test.ts b/apps/api/src/onboarding.test.ts index 38189ea..4d68859 100644 --- a/apps/api/src/onboarding.test.ts +++ b/apps/api/src/onboarding.test.ts @@ -1,6 +1,7 @@ import type * as db from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; import { chooseFocus, markAppConnected } from "./onboarding.js"; +import { focusCard } from "./onboarding-intake.js"; const posted = vi.hoisted(() => [] as Array<{ blocks: unknown[] }>); vi.mock("@rakazo/db", async (original) => ({ @@ -13,16 +14,16 @@ vi.mock("@rakazo/db", async (original) => ({ })); function fixture(catalog: unknown[]) { posted.length = 0; + // A specialist's focus card, waiting for an answer. + const pending = vi.fn(async () => [{ id: "choice", blocks: [focusCard()] }]); const tx = { $executeRaw: vi.fn(), - message: { - findMany: vi.fn(async () => [{ id: "choice", blocks: [{ kind: "choice", answerId: null }] }]), - update: vi.fn(), - }, + message: { findMany: pending, update: vi.fn() }, }; const deps = { prisma: { bot: { findFirst: vi.fn(async () => ({ id: "bot", thread: { id: "thread" } })) }, + message: { findMany: pending }, $transaction: vi.fn(async (fn: (tx: unknown) => unknown) => fn(tx)), }, events: { notify: vi.fn() }, @@ -39,17 +40,16 @@ function fixture(catalog: unknown[]) { describe("onboarding connection suggestions", () => { it("does not invent authorization cards when no connector has an app catalog", async () => { const { deps, actor } = fixture([]); - await chooseFocus(deps, actor, "bot", "day"); - expect(posted.flatMap((message) => message.blocks)).not.toContainEqual( - expect.objectContaining({ kind: "app_connect" }), - ); - expect(posted.length).toBeGreaterThan(0); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); + // The answered card is all that changes; nothing is said around it. + expect(posted).toEqual([]); + expect(deps.events.notify).toHaveBeenCalled(); }); it("uses the available connector and omits unavailable apps", async () => { const { deps, actor } = fixture([ { connectorId: "pipedream", slug: "slack", name: "Slack", connected: false, logo: null }, ]); - await chooseFocus(deps, actor, "bot", "day"); + await chooseFocus(deps, actor, "bot", { optionId: "day" }); expect( posted .flatMap((message) => message.blocks) @@ -57,6 +57,8 @@ describe("onboarding connection suggestions", () => { ).toEqual([ expect.objectContaining({ connectorId: "pipedream", provider: "slack", name: "Slack" }), ]); + // The cards speak for themselves. + expect(posted).toHaveLength(1); }); }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 71a4759..0d33eed 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -1,67 +1,47 @@ +import { ORPCError } from "@orpc/server"; +import type { JobPublisher } from "@rakazo/adapter-kit"; import type { ConnectorRegistry } from "@rakazo/adapters"; -import type { Actor, MessageBlock } from "@rakazo/contracts"; +import { getCoachRhythm } from "@rakazo/adapters"; +import type { Actor, MessageBlock, OnboardingStep } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { featuredConnectorProvidersMatch } from "@rakazo/core"; import { appendEventInTransaction, createThreadMessageInTransaction, ensureMainConversation, IsolationError, + isPersonalMainBot, type Prisma, type PrismaClient, type ThreadEvents, } from "@rakazo/db"; +import type { ChoiceBlock, FocusOption, IntakeAnswer, IntakeContext } from "./onboarding-intake.js"; +import { + closingNote, + FOCUS_OPTIONS, + focusCard, + intakeContext, + intakeStep, + nextIntakeStep, + readIntakeAnswer, + 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 = { prisma: PrismaClient; events: ThreadEvents; connectors: ConnectorRegistry; + jobs: JobPublisher; }; -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,102 @@ 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 }, +/** + * Serializes card writes on a thread. NO KEY UPDATE still lets an answer's effects insert rows + * that reference the thread from another connection (the coach routines do). + */ +async function lockThread(tx: Prisma.TransactionClient, threadId: string): Promise { + await tx.$executeRaw`SELECT id FROM threads WHERE id = ${threadId} FOR NO KEY 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, however long ago it came. */ +async function findPendingCard(client: PrismaClient | Prisma.TransactionClient, threadId: string) { + const messages = await client.message.findMany({ + where: { threadId, role: "bot", blocks: { array_contains: [{ kind: "choice" }] } }, + select: { id: true, blocks: true }, + orderBy: { seq: "desc" }, + take: 20, + }); + 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 +168,32 @@ 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. + // The intake runs with the personal main agent; a project's main bot starts like any other. + const main = await isPersonalMainBot(deps.prisma, bot); + // 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 +201,128 @@ 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; + // Fetched before the transaction: the connector catalog can take seconds. + const followUp = focus ? await focusFollowUp(deps, actor, bot.id, focus) : []; - 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, jobs: deps.jobs, 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(deps, 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( + deps: OnboardingDeps, + 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)]); + } + // The rhythm step saved its answer outside this transaction, so the routines are readable. + const rhythm = await getCoachRhythm(deps.prisma, userId); + await tx.userOnboarding.update({ where: { userId }, data: { step: null, completedAt: now } }); + return postInTransaction(tx, target, [closingNote(rhythm, context)]); +} + +/** The apps worth connecting for a focus, as cards; they need no words around them. */ +async function focusFollowUp( + deps: OnboardingDeps, + actor: Actor, + botId: string, + option: FocusOption, +): Promise { const providers = deps.connectors.managedProviders(); const catalog = ( await Promise.all( @@ -290,14 +333,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() || @@ -316,27 +359,7 @@ export async function chooseFocus( }, ]; }); - if (!cards.length) { - await post(deps, target, [{ kind: "text", text: "What would you like to work on first?" }]); - return; - } - 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.`, - }, - ]); + return cards.length > 0 ? [cards] : []; } export async function markAppConnected( @@ -376,7 +399,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 75a3882..79ad40f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -11,6 +11,7 @@ import type { } from "@rakazo/adapter-kit"; import { computerControlExpireJobKey, + MemoryDocumentTooLargeError, messagingDeliverJob, routineJobKey, routineWakeupJob, @@ -27,6 +28,7 @@ import type { MemoryProviderResolver, PiOAuthLogins, RemoteConnectorDependencies, + VapidKeys, } from "@rakazo/adapters"; import { acquireComputerExecutionLease, @@ -43,7 +45,6 @@ import { computerSupportsUpdate, computerUpdateView, createVoiceProvider, - deletePushToken, deploymentAutoReviewDefault, destroyBot, displayBotWorkspacePath, @@ -52,6 +53,7 @@ import { filterTeamWorkspaceEntries, hasActiveComputerControl, isAutoReviewCheckerConfigured, + isBlockedHostname, isComputerScreenUnavailable, isProjectWorkspacePath, isSandboxGoneError, @@ -74,7 +76,6 @@ import { resolveAutoReviewChecker, resolveBotWorkspacePath, sanitizeComposioError, - savePushToken, scheduleComputerControlExpiry, scheduleComputerSleep, screenLeaseIdForRun, @@ -99,6 +100,7 @@ import type { } from "@rakazo/contracts"; import { appContract, + CoachRoutineKeySchema, IntegrationProviderIdSchema, OPENAI_COMPATIBLE_PROVIDER_ID, } from "@rakazo/contracts"; @@ -131,6 +133,7 @@ import { findModelCredential, findSpaceModelTier, formatMessagingLinkCode, + getNotificationSettings, InvalidProjectWorkspacePathError, InvalidSpaceNameError, IsolationError, @@ -144,6 +147,8 @@ import { parseComputerMode, projectWorkspaceDto, releaseSpaceDeletionClaim, + removePushSubscription, + removePushSubscriptionsOfKind, renewSpaceDeletionClaim, requireMembership, resolveProjectWorkspaceDirectory, @@ -152,8 +157,10 @@ import { SpaceLimitError, SpaceNotEmptyError, SpaceNotFoundError, + savePushSubscription, selectSpaceModelPreference, selectSpaceVoicePreference, + updateNotificationSettings, wireProjectWorkspace, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; @@ -162,11 +169,13 @@ import { createAgentSkillsService } from "./agent-skills.js"; import { aiConsentStatus, allowAiConsent } from "./ai-consent.js"; import { createOwnedArtifact, getOwnedArtifact, getSpaceArtifact } from "./artifacts.js"; import { botProfileLabelsChanged, commitBotUpdate } from "./bot-update.js"; +import { createCoachService } from "./coach.js"; import { executionBlocksUserTakeover, resolveBusyBotName, toComputerStatus, } from "./computer-status.js"; +import { recordConnectedApp } from "./connected-app-facts.js"; import { listInbox } from "./inbox.js"; import { searchIntegrationCatalog } from "./integration-catalog.js"; import { @@ -190,7 +199,12 @@ import { promptFocus, startOnboarding, } from "./onboarding.js"; -import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js"; +import { + applyOnboardingScan, + onboardingScanFolder, + onboardingScanStatus, + startOnboardingScan, +} from "./onboarding-scan.js"; import { listSpaceRuns } from "./runs.js"; import { addScreenProxyCapability } from "./screen-proxy.js"; import { querySpaceSearch } from "./search.js"; @@ -295,6 +309,11 @@ async function reconcilePendingConnections( : []), ]; if (updates.length > 0) await prisma.$transaction(updates); + const connected = new Set(sync.connectIds); + for (const row of rows) { + if (row.status !== "pending" || !connected.has(row.id)) continue; + await recordConnectedApp(prisma, { userId: owner.userId, connectorId, provider: row.provider }); + } } /** Serialize begin/revoke for one user+provider so slug-wide remote deletes cannot race a new connect. */ @@ -467,6 +486,8 @@ export interface RouterDeps { secrets: EncryptedSecretStore; oauthLogins: PiOAuthLogins; integrationSettings?: IntegrationProviderSettings; + /** The deployment's web push key pair; without it browsers cannot subscribe. */ + vapid?: Pick; composio?: ComposioProvider; mcpOAuth?: McpOAuthBroker; connectors: ConnectorRegistry; @@ -534,9 +555,18 @@ function mapSpaceLifecycleError(error: unknown): unknown { } export function createRouter(deps: RouterDeps) { - const os = implement(appContract).$context<{ actor: Actor | null; signal?: AbortSignal }>(); + const os = implement(appContract).$context<{ + actor: Actor | null; + signal?: AbortSignal; + userAgent?: string; + }>(); const repos = createRepos(deps.prisma); - const onboardingDeps = { prisma: deps.prisma, events: deps.events, connectors: deps.connectors }; + const onboardingDeps = { + prisma: deps.prisma, + events: deps.events, + connectors: deps.connectors, + jobs: deps.jobs, + }; const mcpOAuth = deps.mcpOAuth ?? new McpOAuthBroker(deps.prisma, deps.secrets); const groupRepos = createGroupRepos(deps.prisma); const projectGoalRepos = createProjectGoalRepos(deps.prisma); @@ -549,6 +579,7 @@ export function createRouter(deps: RouterDeps) { dataDir: deps.dataDir, }); const agentSkills = createAgentSkillsService(deps.prisma); + const coach = createCoachService(deps); const authed = os.use(async ({ context, next }) => { if (!context.actor) throw new ORPCError("UNAUTHORIZED"); @@ -588,12 +619,57 @@ export function createRouter(deps: RouterDeps) { profile: { get: authed.profile.get.handler(({ context }) => getProfile(deps.prisma, context.actor)), update: authed.profile.update.handler(({ context, input }) => - updateProfile(deps.prisma, context.actor, input), + updateProfile(deps, context.actor, input), ), forget: authed.profile.forget.handler(({ context, input }) => - forgetProfile(deps.prisma, context.actor, input), + forgetProfile(deps, context.actor, input), ), }, + coach: { + today: authed.coach.today.handler(({ context }) => coach.today(context.actor)), + goals: { + list: authed.coach.goals.list.handler(({ context, input }) => + coach.listGoals(context.actor, input), + ), + create: authed.coach.goals.create.handler(({ context, input }) => + coach.createGoal(context.actor, input), + ), + update: authed.coach.goals.update.handler(({ context, input }) => + coach.updateGoal(context.actor, input), + ), + remove: authed.coach.goals.remove.handler(({ context, input }) => + coach.removeGoal(context.actor, input), + ), + }, + habits: { + list: authed.coach.habits.list.handler(({ context, input }) => + coach.listHabits(context.actor, input), + ), + create: authed.coach.habits.create.handler(({ context, input }) => + coach.createHabit(context.actor, input), + ), + update: authed.coach.habits.update.handler(({ context, input }) => + coach.updateHabit(context.actor, input), + ), + remove: authed.coach.habits.remove.handler(({ context, input }) => + coach.removeHabit(context.actor, input), + ), + }, + checkIns: { + log: authed.coach.checkIns.log.handler(({ context, input }) => + coach.logCheckIn(context.actor, input), + ), + list: authed.coach.checkIns.list.handler(({ context, input }) => + coach.listCheckIns(context.actor, input), + ), + }, + rhythm: { + get: authed.coach.rhythm.get.handler(({ context }) => coach.getRhythm(context.actor)), + set: authed.coach.rhythm.set.handler(({ context, input }) => + coach.setRhythm(context.actor, input), + ), + }, + }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), @@ -2504,21 +2580,28 @@ export function createRouter(deps: RouterDeps) { }, }); if (!doc) throw new IsolationError(); - const updated = await deps.memory.commit( - { - scope: doc.scope as "bot" | "user", - botId: doc.botId ?? undefined, - path: doc.path, - content: input.content, - }, - { - operationId: "mem", - traceId: "mem", - spaceId: context.actor.spaceId, - userId: context.actor.userId, - signal: new AbortController().signal, - }, - ); + const updated = await deps.memory + .commit( + { + scope: doc.scope as "bot" | "user", + botId: doc.botId ?? undefined, + path: doc.path, + content: input.content, + }, + { + operationId: "mem", + traceId: "mem", + spaceId: context.actor.spaceId, + userId: context.actor.userId, + signal: new AbortController().signal, + }, + ) + .catch((error: unknown) => { + if (error instanceof MemoryDocumentTooLargeError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + }); return { id: updated.id, scope: doc.scope as "bot" | "user", @@ -3494,7 +3577,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 }) => { @@ -3511,16 +3594,18 @@ export function createRouter(deps: RouterDeps) { ); return { ok: true as const }; }), - scan: authed.onboarding.scan.handler(async ({ context, input }) => { - const { scanId } = await startOnboardingScan( - { actor: context.actor, botId: input.botId, scanHome: deps.env.onboardingScanHome }, - deps.prisma, - ); - return { scanId }; - }), - scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => { - return await getScanStatus(input.scanId, context.actor.userId); - }), + scanTarget: authed.onboarding.scanTarget.handler(async ({ context }) => ({ + folder: await onboardingScanFolder(context.actor, deps.env.onboardingScanHome), + })), + scan: authed.onboarding.scan.handler(({ context }) => + startOnboardingScan(context.actor, deps.env.onboardingScanHome), + ), + scanStatus: authed.onboarding.scanStatus.handler(async ({ context, input }) => + onboardingScanStatus(input.scanId, context.actor.userId), + ), + applyScan: authed.onboarding.applyScan.handler(({ context, input }) => + applyOnboardingScan(deps.prisma, context.actor, deps.env.onboardingScanHome, input), + ), }, integrationSetup: { get: authed.integrationSetup.get.handler(async ({ context }) => { @@ -3769,6 +3854,13 @@ export function createRouter(deps: RouterDeps) { } throw new IsolationError(); } + if (!auth.authorizationUrl) { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: input.connectorId, + provider: input.provider, + }); + } return { connectionId: row.id, authorizationUrl: auth.authorizationUrl }; } catch (error) { if (error instanceof IsolationError) throw error; @@ -4074,6 +4166,13 @@ export function createRouter(deps: RouterDeps) { }, { timeout: 60_000 }, ); + if (row.status === "connected") { + await recordConnectedApp(deps.prisma, { + userId: context.actor.userId, + connectorId: row.connectorId, + provider: row.provider, + }); + } } return { id: row.id, @@ -4828,13 +4927,53 @@ export function createRouter(deps: RouterDeps) { }, notifications: { registerPush: authed.notifications.registerPush.handler(async ({ context, input }) => { - await savePushToken(deps.dataDir, context.actor.userId, input.token); + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "expo", + endpoint: input.token, + userAgent: context.userAgent, + }); return { ok: true as const }; }), + // The mobile app signs out without naming its token, so all of its tokens go. unregisterPush: authed.notifications.unregisterPush.handler(async ({ context }) => { - await deletePushToken(deps.dataDir, context.actor.userId); + await removePushSubscriptionsOfKind(deps.prisma, context.actor.userId, "expo"); return { ok: true as const }; }), + settings: { + get: authed.notifications.settings.get.handler(({ context }) => + getNotificationSettings(deps.prisma, context.actor.userId), + ), + update: authed.notifications.settings.update.handler(({ context, input }) => + updateNotificationSettings(deps.prisma, context.actor.userId, input), + ), + }, + webPush: { + config: authed.notifications.webPush.config.handler(async () => { + const publicKey = (await deps.vapid?.publicKey()) ?? null; + return { available: publicKey !== null, publicKey }; + }), + subscribe: authed.notifications.webPush.subscribe.handler(async ({ context, input }) => { + if (isBlockedHostname(new URL(input.endpoint).hostname.replace(/^\[|\]$/g, ""))) { + throw new ORPCError("BAD_REQUEST", { + message: "Endpoint must be a public push service", + }); + } + await savePushSubscription(deps.prisma, context.actor.userId, { + kind: "webpush", + endpoint: input.endpoint, + p256dh: input.keys.p256dh, + auth: input.keys.auth, + userAgent: context.userAgent, + }); + return { ok: true as const }; + }), + unsubscribe: authed.notifications.webPush.unsubscribe.handler( + async ({ context, input }) => { + await removePushSubscription(deps.prisma, context.actor.userId, input.endpoint); + return { ok: true as const }; + }, + ), + }, }, search: { query: authed.search.query.handler(async ({ context, input }) => ({ @@ -5137,6 +5276,7 @@ function spaceGroupDto(group: SpaceGroup): SpaceGroup { sectionId: group.sectionId, members: group.members, preview: group.preview, + ...(group.previewBlock ? { previewBlock: group.previewBlock } : {}), unread: group.unread, updatedAt: group.updatedAt, }; @@ -5520,6 +5660,7 @@ function mapRoutine(row: { lastRunAt: Date | null; nextRunAt: Date | null; createdAt: Date; + coachKey: string | null; }) { return { id: row.id, @@ -5536,6 +5677,7 @@ function mapRoutine(row: { lastRunAt: row.lastRunAt?.toISOString() ?? null, nextRunAt: row.nextRunAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), + coachKey: CoachRoutineKeySchema.safeParse(row.coachKey).data ?? null, }; } diff --git a/apps/api/src/user-profile.ts b/apps/api/src/user-profile.ts index 4307f1a..5192c06 100644 --- a/apps/api/src/user-profile.ts +++ b/apps/api/src/user-profile.ts @@ -1,4 +1,6 @@ import { ORPCError } from "@orpc/server"; +import type { CoachRhythmDeps } from "@rakazo/adapters"; +import { syncCoachTimeZone } from "@rakazo/adapters"; import type { Actor, ForgetUserFacts, @@ -29,32 +31,32 @@ export function getProfile(prisma: PrismaClient, actor: Actor): Promise { try { - await updateUserProfile(prisma, actor.userId, input); + await updateUserProfile(deps.prisma, actor.userId, input); } catch (error) { throw toRpcError(error); } - return getUserProfileState(prisma, actor.userId); + if (input.timeZone !== undefined) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } /** Unknown ids are ignored, so a caller cannot probe another user's facts. */ export async function forgetProfile( - prisma: PrismaClient, + deps: CoachRhythmDeps, actor: Actor, input: ForgetUserFacts, ): Promise { - await forgetUserFacts( - prisma, - actor.userId, - input.factIds - ? { factIds: input.factIds } - : input.category - ? { category: input.category } - : { all: true }, - ); - return getUserProfileState(prisma, actor.userId); + const target = input.factIds + ? { factIds: input.factIds } + : input.category + ? { category: input.category } + : { all: true as const }; + await forgetUserFacts(deps.prisma, actor.userId, target); + // Forgetting everything clears the time zone too. + if ("all" in target) await syncCoachTimeZone(deps, actor.userId); + return getUserProfileState(deps.prisma, actor.userId); } diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index ddc8e93..53ae1e8 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -1857,6 +1857,32 @@ describe("mobile thread event reduction", () => { ).toBe("Slack · Alex: Hello from the group"); }); + it("reads a choice card as its question and answer", () => { + const card = { + kind: "choice" as const, + step: "goal" as const, + question: "What’s one thing you want to get done in the next few weeks?", + textInput: { placeholder: "Your goal" }, + options: [{ id: "skip", letter: "", label: "Skip" }], + }; + expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question); + expect( + blockText( + mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]), + ), + ).toBe(`${card.question}\nRun a 10K`); + expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe( + `${card.question}\nSkip`, + ); + const note = { + kind: "intake_note" as const, + note: "done" as const, + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }; + expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text); + }); + it("deduplicates durable messages and replaces matching transient subagent state", () => { const initial = snapshot([ mobileMessage("message-1", [{ kind: "text", text: "old" }]), diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index e34b7e4..1ae02e8 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -876,6 +876,12 @@ export function blockText(message: MobileMessage) { if (block.kind === "child_bot") { return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`; } + // Choice cards are answered on the web; here they read as the question and its answer. + if (block.kind === "choice") { + const answer = + block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label; + return answer ? `${block.question}\n${answer}` : block.question; + } if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`; if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`; if (block.kind === "file") { diff --git a/apps/web/e2e/chat-contrast.spec.ts b/apps/web/e2e/chat-contrast.spec.ts index 2ea5310..acadca2 100644 --- a/apps/web/e2e/chat-contrast.spec.ts +++ b/apps/web/e2e/chat-contrast.spec.ts @@ -255,7 +255,7 @@ test("shell surfaces stay readable in light and dark themes", async ({ page }, t await sidebar.getByRole("button", { name: "Today", exact: true }).click(); const heute = page.getByTestId("heute-brief"); - await expect(heute.getByTestId("heute-mission-control-link")).toBeVisible(); + await expect(heute).toBeVisible(); await inEachTheme(page, async (theme) => { await captureScreenshot(page, testInfo, `shell-contrast-${theme}-heute`); await expectReadable(heute, `${theme}: the Heute brief`, { inside: true }); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 84d7794..edc646d 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -88,8 +88,9 @@ async function visible(locator: Locator, timeout: number) { } /** - * Walks first-run onboarding, which lands in the main conversation. Unless `mainConversation` - * is set, it then opens the main bot's own thread, where most specs start. + * Walks the first-run wizard, which lands in the main conversation with the main bot's first + * onboarding question pending (the composer works regardless). Unless `mainConversation` is + * set, it then opens the main bot's own thread, where most specs start. */ export async function completeOnboarding( page: Page, @@ -101,13 +102,12 @@ export async function completeOnboarding( if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations|scan|bot)$/, + /^(emoji|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } const design = page.getByRole("heading", { name: /Design your Oo/i }); - const preferences = page.getByRole("heading", { name: /Tell us about yourself/i }); const language = page.getByRole("heading", { name: /Choose your language/i }); const voice = page.getByRole("heading", { name: /Pick a voice/i }); const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); @@ -119,8 +119,6 @@ export async function completeOnboarding( await expect(entry.first()).toBeVisible({ timeout: 20_000 }); if (await design.isVisible()) { - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(preferences).toBeVisible(); await page.getByRole("button", { name: "Continue", exact: true }).click(); await expect(language).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); @@ -133,8 +131,15 @@ export async function completeOnboarding( await page.getByRole("button", { name: "Skip", exact: true }).click(); } - // Scan step auto-advances to the main bot, then onboarding lands in the main conversation. - await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + // The scan step, when shown, either asks first (skip it) or advances by itself. Onboarding + // then lands in the main conversation. + const skipScan = page + .locator('[data-onboarding-step="scan"]') + .getByRole("button", { name: "Skip", exact: true }); + await expect(async () => { + if (await skipScan.isVisible()) await skipScan.click(); + expect(page.url()).toMatch(/\/app\/g\/[^/]+$/); + }).toPass({ timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); await waitForChatComposer(page); @@ -242,6 +247,7 @@ export async function openUserSettings( | "memory" | "profile" | "voice" + | "notifications" | "usage" | "computer" | "updates", diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 6347490..293dcc9 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,20 +1,136 @@ +import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; -import { captureScreenshot, completeOnboarding, signup } from "./helpers"; +import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers"; + +/** The nav button reads "Today" or "Today, N need you". */ +function openHeute(page: Page) { + return page.getByRole("button", { name: /^Today(,|$)/ }).click(); +} + +function shiftDate(date: string, days: number) { + return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10); +} test("Heute shows native brief instead of Mission Control iframe", async ({ page }, testInfo) => { const stamp = Date.now(); await signup(page, `heute-${stamp}@rakazo.test`, "password12", "Heute"); await completeOnboarding(page); - await page.getByRole("button", { name: "Today", exact: true }).click(); + await openHeute(page); await expect(page).toHaveURL(/\/app\/[^/]+$/); const brief = page.getByTestId("heute-brief"); await expect(brief).toBeVisible(); await expect(page.locator('iframe[src="http://127.0.0.1:3088/"]')).toHaveCount(0); await expect(page.getByText("evOo Mission Control")).toHaveCount(0); + await expect(page.getByTestId("heute-mission-control-link")).toHaveCount(0); await expect(page.getByTestId("heute-status")).toBeVisible(); - await expect(page.getByTestId("heute-mission-control-link")).toBeVisible(); + // Nothing tracked yet: no coach sections. + await expect(page.getByTestId("heute-coach-brief")).toHaveCount(0); + await expect(page.getByTestId("heute-habits")).toHaveCount(0); await captureScreenshot(page, testInfo, "heute-native-brief"); }); + +test("Heute shows the coach's brief, habits and goals, checks habits off and sets check-ins", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `heute-coach-${stamp}@rakazo.test`, "password12", "Heute Coach"); + await completeOnboarding(page); + + // The main bot writes today's brief through its tool. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("write today's brief: Two calm hours on the beta, then a walk."); + await page.keyboard.press("Enter"); + await expect(page.getByText("today's brief is ready.").first()).toBeVisible({ timeout: 30_000 }); + + const { date } = await rpc(page, "coach/today", {}); + const goal = await rpc(page, "coach/goals/create", { + title: "Ship the beta", + targetDate: shiftDate(date, 30), + }); + const habit = await rpc(page, "coach/habits/create", { title: "Walk 20 minutes" }); + for (const day of [1, 2]) { + await rpc(page, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + date: shiftDate(date, -day), + done: true, + }); + } + + await openHeute(page); + await expect(page.getByTestId("heute-coach-brief")).toHaveText( + "Two calm hours on the beta, then a walk.", + ); + await expect(page.getByTestId(`heute-goal-${goal.id}`)).toContainText("Ship the beta"); + const habitRow = page.getByTestId(`heute-habit-${habit.id}`); + await expect(habitRow).toContainText("Walk 20 minutes"); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("2 days"); + + const checkbox = habitRow.getByRole("checkbox"); + await expect(checkbox).not.toBeChecked(); + const logged = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/checkIns/log") && response.ok(), + ); + await checkbox.click(); + await logged; + await expect(checkbox).toBeChecked(); + await expect(page.getByTestId(`heute-habit-streak-${habit.id}`)).toHaveText("3 days"); + await captureScreenshot(page, testInfo, "heute-coach"); + + await page.reload(); + await openHeute(page); + await expect(page.getByTestId(`heute-habit-${habit.id}`).getByRole("checkbox")).toBeChecked(); + + // Check-in times live behind one compact control. + await page.getByTestId("heute-rhythm").click(); + const panel = page.getByTestId("heute-rhythm-panel"); + await expect(panel).toBeVisible(); + const morning = panel.getByTestId("heute-rhythm-morning"); + await expect(morning.getByRole("switch")).not.toBeChecked(); + const enabled = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await morning.getByRole("switch").click(); + await enabled; + const time = morning.locator('input[type="time"]'); + await expect(time).toHaveValue("08:00"); + const moved = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await time.fill("07:30"); + await moved; + await expect(time).toHaveValue("07:30"); + const weekly = panel.getByTestId("heute-rhythm-weekly"); + const weeklyOn = page.waitForResponse( + (response) => response.url().includes("/rpc/coach/rhythm/set") && response.ok(), + ); + await weekly.getByRole("switch").click(); + await weeklyOn; + await expect(weekly.getByRole("combobox")).toHaveValue("0"); + await captureScreenshot(page, testInfo, "heute-rhythm"); + + expect(await rpc(page, "coach/rhythm/get", {})).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + const main = await rpc<{ id: string }>(page, "bots/ensureMain", {}); + const routines = await rpc(page, "routines/list", { botId: main.id }); + expect(routines.map(({ name, coachKey }) => ({ name, coachKey }))).toEqual( + expect.arrayContaining([ + { name: "Morning brief", coachKey: "morning" }, + { name: "Weekly review", coachKey: "weekly" }, + ]), + ); + expect(routines).toHaveLength(2); + + // The main bot's routine list names the check-ins by their key, in the UI language. + await openMainBotThread(page); + await page.getByTitle("Agent computer").click(); + await expect(page.getByText("Morning brief", { exact: true })).toBeVisible(); + await expect(page.getByText("Weekly review", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/e2e/inbox.spec.ts b/apps/web/e2e/inbox.spec.ts index b31ea48..683942c 100644 --- a/apps/web/e2e/inbox.spec.ts +++ b/apps/web/e2e/inbox.spec.ts @@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); }); +test("answers a question from another space in the main conversation", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross"); + await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 }); + const helper = await createBot(page, "Helper"); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", { + name: "Scout", + title: "", + description: "", + notifyOnFinish: true, + computerMode: "team", + }); + await rpcInSpace(page, side.id, "threads/send", { + botId: scout.id, + text: "ask me which city to use", + }); + const runInSide = async () => + ( + await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", { + botId: scout.id, + }) + ).run ?? null; + await expect + .poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 }) + .toBe("waiting_input"); + + await openMainConversation(page, helper.id); + const inbox = page.getByRole("region", { name: "Needs you" }); + const ask = inbox.locator("[data-kind='ask']"); + await expect(ask).toContainText("Scout", { timeout: 30_000 }); + await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await captureScreenshot(page, testInfo, "inbox-cross-space"); + + await ask.getByRole("button", { name: "Edit first" }).click(); + await ask.getByRole("textbox", { name: "Answer" }).fill("Paris"); + await ask.getByRole("button", { name: "Send answer" }).click(); + await expect(ask).toHaveCount(0); + await expect.poll(runInSide, { timeout: 60_000 }).toBeNull(); + + // The reply arrives as unread from the other space and opens there. + const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" }); + await expect(reply).toContainText("Paris", { timeout: 45_000 }); + await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project"); + await reply.getByRole("button").click(); + await page.waitForURL(new RegExp(`/app/${scout.id}$`)); + await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 }); + expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id); +}); + +async function rpcInSpace( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + const response = await page.request.post(`/rpc/${procedure}`, { + data: { json: body }, + headers: { "x-rakazo-space-id": spaceId }, + }); + const parsed = (await response.json()) as { json?: T; error?: { message?: string } }; + if (!response.ok() || parsed.error) { + throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`); + } + return parsed.json as T; +} + async function createBot(page: Page, name: string) { return rpc<{ id: string }>(page, "bots/create", { name, diff --git a/apps/web/e2e/knowledge-panel.spec.ts b/apps/web/e2e/knowledge-panel.spec.ts index a504c9d..3ab2d19 100644 --- a/apps/web/e2e/knowledge-panel.spec.ts +++ b/apps/web/e2e/knowledge-panel.spec.ts @@ -114,6 +114,36 @@ test("memory and skills are readable and editable in the app", async ({ page }, await expect(botDocEditor).toHaveValue(new RegExp(botMarker)); await botMemory.getByRole("button", { name: "Cancel", exact: true }).click(); + // Remembering appends a dated note and keeps the edit above. + await rpc(page, "threads/send", { + botId: activeBotId(page), + text: `remember that the tester ${stamp} prefers tea`, + }); + await expect + .poll( + async () => + ( + await rpc(page, "memory/list", { + botId: activeBotId(page), + scope: "bot", + }) + ).find((doc) => doc.path === "MEMORY.md")?.content, + { timeout: 60_000 }, + ) + .toMatch( + new RegExp( + `^# Chief\\n\\n${botMarker}\\n\\n- \\d{4}-\\d{2}-\\d{2}: .*tester ${stamp} prefers tea\\n$`, + ), + ); + await expect + .poll( + async () => + (await rpc<{ run?: unknown }>(page, "threads/get", { botId: activeBotId(page) })).run ?? + null, + { timeout: 60_000 }, + ) + .toBeNull(); + // Skills: create one through the editor, reopen it, edit, then delete it. // Builtin catalog is currently empty; user skills still cover create/edit/delete. await knowledge.getByRole("tab", { name: "Skills", exact: true }).click(); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 4667825..ab6d797 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -4,7 +4,8 @@ import { captureScreenshot, completeOnboarding, signup, waitForChatComposer } fr /** Submit from the Send control so an in-flight prior send cannot swallow Enter. */ async function sendComposerMessage(page: Page, composer: Locator, text: string) { await composer.fill(text); - const send = page.getByRole("button", { name: "Send", exact: true }); + // An open onboarding card in the transcript has its own Send. + const send = page.getByTestId("composer-bar").getByRole("button", { name: "Send", exact: true }); await expect(send).toBeEnabled(); await send.click(); await expect(composer).toHaveValue(""); @@ -72,14 +73,25 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const transcript = page.getByTestId("transcript"); - // Empty start: no Fresh-start greeting. Answer the focus card so a plain - // bot text bubble exists for hover layout checks. - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ + // Empty start: no Fresh-start greeting. Answer the focus card, then ask something long: a bot + // row's rail sits at the row's end, so the measured reply must wrap and fill its row. + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); - await expect(botText).toBeVisible({ timeout: 20_000 }); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible({ + timeout: 20_000, + }); + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage( + page, + composer, + `hover-bot-${stamp} ${"please look into this ".repeat(7)}`, + ); + const botText = page.getByText(/i handled: hover-bot-/); + await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 }); const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); await expect(botRow).toBeVisible(); await expectRailAtRest(page, botRow); @@ -121,7 +133,6 @@ test("message hover shows beside-bubble actions; reply links to parent", async ( const parentText = `hover-parent-${stamp}`; const replyText = `hover-reply-${stamp}`; - const composer = page.getByRole("combobox", { name: /^Message/ }); await expect(composer).toBeVisible(); await sendComposerMessage(page, composer, parentText); @@ -387,12 +398,19 @@ test.describe("touch message actions", () => { expect( await page.evaluate(() => matchMedia("(hover: hover) and (pointer: fine)").matches), ).toBe(false); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible({ + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible({ timeout: 20_000, }); await page.getByTestId("focus-choice-day").click(); - const botText = page.getByText(/Got it\./); - await expect(botText).toBeVisible({ timeout: 20_000 }); + // Onboarding writes no text of its own, so ask for a plain reply to act on. + const composer = page.getByRole("combobox", { name: /^Message/ }); + await sendComposerMessage(page, composer, `touch-bot-${Date.now()}`); + const botText = page.getByText(/i handled: touch-bot-/); + await expect(page.getByTestId("transcript").getByText(/i handled: touch-bot-/)).toBeVisible({ + timeout: 20_000, + }); const row = page .getByTestId("transcript") .locator("[data-message-id]") diff --git a/apps/web/e2e/new-bot-ux.spec.ts b/apps/web/e2e/new-bot-ux.spec.ts index 68bc135..35645fb 100644 --- a/apps/web/e2e/new-bot-ux.spec.ts +++ b/apps/web/e2e/new-bot-ux.spec.ts @@ -42,7 +42,9 @@ test("create opens form, then empty chat; picker lists bots; sidebar collapses", await page.waitForURL(/\/app\/[^/]+$/); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await expect( page.getByTestId("transcript").getByText("Start the conversation — New Bot is on stage above."), ).toBeVisible(); @@ -117,20 +119,30 @@ test("later bot waits before showing the focus card; sending cancels it", async await signup(page, `focus-delay-${stamp}@rakazo.test`, "password12", "Focus Delay"); await completeOnboarding(page, undefined, { mainConversation: true }); // The main bot shows the focus card immediately in the main conversation. - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await page.clock.install(); await createBotFromPicker(page); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await page.clock.fastForward(9_000); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); await page.clock.fastForward(1_500); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await createBotFromPicker(page, { name: "Later Bot" }); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); const composer = page.getByPlaceholder(/Message/); await composer.fill("I'll set this up myself"); // Send must finish before the delay is advanced: cancel runs after a successful @@ -144,7 +156,9 @@ test("later bot waits before showing the focus card; sending cancels it", async page.getByTestId("transcript").getByText("I'll set this up myself").first(), ).toBeVisible(); await page.clock.fastForward(12_000); - await expect(page.getByText("What do you want me on first?", { exact: true })).toHaveCount(0); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toHaveCount(0); }); test("plus picker can create a Private computer bot", async ({ page }, testInfo) => { diff --git a/apps/web/e2e/notification-settings.spec.ts b/apps/web/e2e/notification-settings.spec.ts new file mode 100644 index 0000000..b21a36c --- /dev/null +++ b/apps/web/e2e/notification-settings.spec.ts @@ -0,0 +1,130 @@ +/** + * E2E: Settings → Notifications turns push on for this browser (the Push API is stubbed: headless + * Chromium has no push service), sets quiet hours and switches nudges off; all of it survives a + * reload. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +const ENDPOINT = "https://push.example.test/e2e-device"; + +/** + * A fake Notification permission and service worker registration, kept in sessionStorage so they + * survive a reload. Headless Chromium reports notifications as denied even when granted. + */ +async function stubPushApi(page: Page, endpoint: string) { + await page.addInitScript((pushEndpoint) => { + const permissionKey = "e2e:notification-permission"; + const notification = Object.defineProperties(function Notification() {}, { + permission: { get: () => sessionStorage.getItem(permissionKey) ?? "default" }, + requestPermission: { + value: async () => { + sessionStorage.setItem(permissionKey, "granted"); + return "granted"; + }, + }, + }); + Object.defineProperty(window, "Notification", { + configurable: true, + writable: true, + value: notification, + }); + const storageKey = "e2e:push-subscription"; + const current = () => { + const stored = sessionStorage.getItem(storageKey); + if (!stored) return null; + const saved = JSON.parse(stored) as { serverKey: number[] }; + return { + endpoint: pushEndpoint, + options: { applicationServerKey: new Uint8Array(saved.serverKey).buffer }, + toJSON: () => ({ + endpoint: pushEndpoint, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }), + unsubscribe: async () => { + sessionStorage.removeItem(storageKey); + return true; + }, + }; + }; + const registration = { + scope: `${location.origin}/`, + pushManager: { + getSubscription: async () => current(), + subscribe: async (options: { applicationServerKey: Uint8Array }) => { + sessionStorage.setItem( + storageKey, + JSON.stringify({ serverKey: [...new Uint8Array(options.applicationServerKey)] }), + ); + return current(); + }, + }, + }; + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: { + register: async () => registration, + getRegistration: async () => registration, + ready: Promise.resolve(registration), + }, + }); + if (!("PushManager" in window)) { + Object.defineProperty(window, "PushManager", { configurable: true, value: class {} }); + } + }, endpoint); +} + +test("notification settings: push on this device, quiet hours and nudges", async ({ + page, +}, testInfo) => { + await stubPushApi(page, ENDPOINT); + await signup(page, `notifications-${Date.now()}@rakazo.test`, "password12", "Push Tester"); + await completeOnboarding(page); + + let settings = await openUserSettings(page, "notifications"); + const panel = settings.getByTestId("notification-settings"); + const push = panel.getByTestId("notification-push-switch"); + await expect(push).not.toBeChecked(); + + const subscribed = page.waitForRequest("**/rpc/notifications/webPush/subscribe"); + await push.click(); + expect((await subscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(push).toBeChecked(); + + const quiet = panel.getByTestId("notification-quiet-hours-switch"); + await expect(quiet).not.toBeChecked(); + await quiet.click(); + await expect(quiet).toBeChecked(); + const start = panel.getByTestId("notification-quiet-hours-start"); + await expect(start).toHaveValue("22:00"); + await expect(panel.getByTestId("notification-quiet-hours-end")).toHaveValue("07:00"); + const saved = page.waitForResponse( + (response) => + response.url().includes("/rpc/notifications/settings/update") && + response.request().postData()?.includes("23:30") === true, + ); + await start.fill("23:30"); + await saved; + + const nudges = panel.getByTestId("notification-nudges-switch"); + await expect(nudges).toBeChecked(); + await nudges.click(); + await expect(nudges).not.toBeChecked(); + await expect + .poll(() => rpc<{ nudges: boolean }>(page, "notifications/settings/get", null)) + .toMatchObject({ nudges: false, quietHours: { start: "23:30", end: "07:00" } }); + + await captureScreenshot(page, testInfo, "notification-settings"); + + await page.reload(); + settings = await openUserSettings(page, "notifications"); + await expect(settings.getByTestId("notification-push-switch")).toBeChecked(); + await expect(settings.getByTestId("notification-quiet-hours-start")).toHaveValue("23:30"); + await expect(settings.getByTestId("notification-nudges-switch")).not.toBeChecked(); + + const unsubscribed = page.waitForRequest("**/rpc/notifications/webPush/unsubscribe"); + await settings.getByTestId("notification-push-switch").click(); + expect((await unsubscribed).postDataJSON()).toMatchObject({ json: { endpoint: ENDPOINT } }); + await expect(settings.getByTestId("notification-push-switch")).not.toBeChecked(); +}); diff --git a/apps/web/e2e/onboarding-conversation.spec.ts b/apps/web/e2e/onboarding-conversation.spec.ts index 57bd3bb..7809caa 100644 --- a/apps/web/e2e/onboarding-conversation.spec.ts +++ b/apps/web/e2e/onboarding-conversation.spec.ts @@ -16,11 +16,16 @@ test("focus choice suggests apps and preserves a completed connection", async ({ await expect( page.getByText("Hey Robin. Fresh start on my side, so I’ll keep this short."), ).toHaveCount(0); - await expect(page.getByText("What do you want me on first?", { exact: true })).toBeVisible(); + await expect( + page.getByTestId("transcript").getByText("What do you want me on first?", { exact: true }), + ).toBeVisible(); await expect(page.getByTestId("focus-choice-day")).toBeVisible(); await expect(page.getByTestId("focus-choice-day")).toContainText("Day-to-day work"); await expect(page.getByTestId("focus-choice-research")).toContainText("Research & writing"); await expect(page.getByTestId("focus-choice-crew")).toContainText("Specialist crew"); + // Onboarding questions offer a quiet way out instead of a dismiss button. + await expect(page.getByTestId("focus-choice-skip")).toBeVisible(); + await expect(page.getByRole("button", { name: "Dismiss" })).toHaveCount(0); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "01-focus-choice"); await captureScreenshot(page, testInfo, "choice-card-onboarding"); @@ -33,6 +38,8 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ).toBeVisible(); expect((await rpc<{ name: string }>(page, "bots/ensureMain", {})).name).toBe("Chief"); await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + // The app cards speak for themselves; onboarding adds no text around them. + await expect(page.getByText(/^Got it/)).toHaveCount(0); await expect(page.getByText("Slack", { exact: true })).toBeVisible(); await expect(page.getByText("Gmail", { exact: true })).toBeVisible(); const connectionCards = page.getByRole("group", { name: / connection$/ }); @@ -45,10 +52,10 @@ test("focus choice suggests apps and preserves a completed connection", async ({ ); expect(cardBoxes[1].top - cardBoxes[0].bottom).toBeGreaterThanOrEqual(8); expect(cardBoxes[2].top - cardBoxes[1].bottom).toBeGreaterThanOrEqual(8); - await page - .getByTestId("transcript") - .getByText("Hit those three and I’ll start pulling the picture.") - .scrollIntoViewIfNeeded(); + // The next onboarding question follows the suggestions. + const goal = page.locator('[data-testid="choice-card"][data-step="goal"]'); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + await slackCard(page).scrollIntoViewIfNeeded(); await page.mouse.move(1, 1); await captureScreenshot(page, testInfo, "02-app-suggestions"); const authorizeButton = slackCard(page).getByRole("button", { name: "Authorize" }); diff --git a/apps/web/e2e/onboarding-discovery.spec.ts b/apps/web/e2e/onboarding-discovery.spec.ts new file mode 100644 index 0000000..d953085 --- /dev/null +++ b/apps/web/e2e/onboarding-discovery.spec.ts @@ -0,0 +1,77 @@ +/** + * E2E: the deployment owner's onboarding asks before scanning the fixture home folder the + * harness configures, then adds only the projects and facts the user picks. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import type { SpaceNavigation, UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, rpc, signup } from "./helpers"; + +const api = process.env.API_URL ?? "http://127.0.0.1:3110"; + +/** Advances whichever wizard step is showing until the scan step is reached. */ +async function walkToScanStep(page: Page) { + const surface = page.locator('[data-rakazo-surface="onboarding"]'); + await expect(async () => { + if ((await surface.getAttribute("data-onboarding-step")) === "scan") return; + const skip = page.getByRole("button", { name: /^Skip/ }).first(); + const next = (await skip.isVisible()) + ? skip + : page.getByRole("button", { name: "Continue", exact: true }); + await next.click({ timeout: 2_000 }); + await expect(surface).toHaveAttribute("data-onboarding-step", "scan", { timeout: 1_000 }); + }).toPass({ timeout: 60_000 }); +} + +test("asks before looking for projects and adds only what the user picks", async ({ + page, +}, testInfo) => { + const email = `discovery-${Date.now()}@rakazo.test`; + await signup(page, email, "password12", "Discovery"); + await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); + // The harness starts without a deployment owner; this spec takes the seat for its run. + const owner = await page.request.post(`${api}/__e2e/deployment-owner`, { data: { email } }); + expect(owner.ok()).toBe(true); + const { previousOwnerUserId } = (await owner.json()) as { previousOwnerUserId: string | null }; + try { + await page.goto("/onboarding", { waitUntil: "domcontentloaded" }); + await walkToScanStep(page); + + await expect(page.getByRole("heading", { name: "Look for projects here?" })).toBeVisible(); + await expect(page.getByText(/rakazo-e2e-scan-home-/)).toBeVisible(); + await captureScreenshot(page, testInfo, "onboarding-discovery-consent"); + await page.getByRole("button", { name: "Look", exact: true }).click(); + + const projects = page.getByRole("group", { name: "Add as projects" }); + await expect(projects.getByRole("checkbox")).toHaveCount(2); + const atlas = projects.getByRole("checkbox", { name: /atlas/ }); + await expect(atlas).not.toBeChecked(); + await expect(projects.getByRole("checkbox", { name: /garden/ })).not.toBeChecked(); + await atlas.check(); + const facts = page.getByRole("group", { name: "Add to About you" }); + await expect(facts.getByRole("checkbox", { name: "TypeScript" })).toBeChecked(); + await facts.getByRole("checkbox", { name: "Python" }).uncheck(); + await captureScreenshot(page, testInfo, "onboarding-discovery-results"); + await page.getByRole("button", { name: "Add", exact: true }).click(); + await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 }); + + const navigation = await rpc(page, "spaces/list", {}); + const names = navigation.spaces.map((space) => space.name); + expect(names).toContain("atlas"); + expect(names).not.toContain("garden"); + expect(navigation.spaces.find((space) => space.name === "atlas")?.workspace?.path).toMatch( + /code\/atlas$/, + ); + const values = (await rpc(page, "profile/get", {})).facts.map( + (fact) => fact.value, + ); + expect(values).toEqual( + expect.arrayContaining(["Codes in TypeScript", "Uses Docker", "Uses GitHub Actions"]), + ); + expect(values).not.toContain("Codes in Python"); + } finally { + await page.request.post(`${api}/__e2e/deployment-owner`, { + data: { userId: previousOwnerUserId }, + }); + } +}); diff --git a/apps/web/e2e/onboarding-emoji-first.spec.ts b/apps/web/e2e/onboarding-emoji-first.spec.ts index 0a49936..51e1bf8 100644 --- a/apps/web/e2e/onboarding-emoji-first.spec.ts +++ b/apps/web/e2e/onboarding-emoji-first.spec.ts @@ -17,12 +17,11 @@ test.beforeEach(async ({ page }) => { }); /** - * E2E test for the new emoji-first onboarding flow: + * E2E test for the emoji-first onboarding flow: * 1. Sign up → lands on onboarding * 2. Design Oo: pick shape + color → live preview - * 3. Tell us about yourself: name, timezone, hobbies, work style, communication - * 4. Choose language - * 5. Skip integrations → bot created → land in chat + * 3. Choose language, skip the voice + * 4. Skip integrations → bot created → land in chat, where the main bot asks the rest */ test("emoji-first onboarding: shape + color selection with live preview", async ({ @@ -50,50 +49,13 @@ test("emoji-first onboarding: shape + color selection with live preview", async if (testInfo) await captureScreenshot(page, testInfo, "02-shape-color-preview"); - // Click Continue → goes to preferences step + // Click Continue → goes to the language step await page .getByRole("button", { name: /Continue/i }) .first() .click(); - // Step 2: Tell us about yourself - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - - if (testInfo) await captureScreenshot(page, testInfo, "03-user-preferences"); - - // Fill in name - await page.getByPlaceholder(/Your name/i).fill("Alice"); - - // Fill in timezone - await page.getByLabel(/Timezone/i).fill("Europe/Vienna"); - - // Select a hobby - const hobbyButtons = page.locator("button[aria-pressed]"); - if (await hobbyButtons.count()) { - await hobbyButtons.first().click(); // toggle first hobby - } - - // Select work style - const workStyleButtons = page.locator("button[aria-pressed]:visible"); - if ((await workStyleButtons.count()) >= 2) { - await workStyleButtons.nth(0).click(); - } - - // Select communication style - const commButtons = page.locator("button[aria-pressed]:visible"); - if ((await commButtons.count()) >= 2) { - await commButtons.nth(0).click(); - } - - if (testInfo) await captureScreenshot(page, testInfo, "04-preferences-filled"); - - // Click Continue → goes to language step - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - - // Step 3: Language selection + // Step 2: Language selection await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); if (testInfo) await captureScreenshot(page, testInfo, "05-language-step"); @@ -135,13 +97,6 @@ test("onboarding: skip language defaults to English", async ({ page }) => { .first() .click(); - // Preferences step — just continue - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await page - .getByRole("button", { name: /Continue/i }) - .first() - .click(); - // Language step — click Skip, then skip voice. await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: /Skip/i }).click(); diff --git a/apps/web/e2e/onboarding-intake.spec.ts b/apps/web/e2e/onboarding-intake.spec.ts new file mode 100644 index 0000000..7e223cf --- /dev/null +++ b/apps/web/e2e/onboarding-intake.spec.ts @@ -0,0 +1,91 @@ +/** + * E2E: after the wizard the main bot gets to know the user in the main conversation, one card + * after each answer, without ever blocking the composer. + */ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +function card(page: Page, step: string) { + return page.locator(`[data-testid="choice-card"][data-step="${step}"]`); +} + +test("the main bot asks focus, goal, check-ins and quiet hours, then says what is next", async ({ + page, +}, testInfo) => { + await signup(page, `intake-${Date.now()}@rakazo.test`, "password12", "Intake Tester"); + await completeOnboarding(page, undefined, { mainConversation: true }); + + await page.getByTestId("focus-choice-day").click(); + const goal = card(page, "goal"); + await expect(goal).toContainText("What’s one thing you want to get done in the next few weeks?"); + + // Typing instead of answering goes to the main bot, and the card stays answerable. + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.fill("Quick question first"); + await composer.press("Enter"); + await expect(page.getByTestId("transcript")).toContainText(/on it|handled/i, { + timeout: 60_000, + }); + const goalInput = goal.getByRole("textbox", { + name: "What’s one thing you want to get done in the next few weeks?", + }); + await goalInput.fill("Run a 10K"); + await goal.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-goal"); + await goalInput.press("Enter"); + await expect(goal.getByTestId("choice-answer")).toHaveText("Run a 10K"); + + const rhythm = card(page, "rhythm"); + await expect(rhythm).toContainText("When should I check in with you?"); + // The sidebar previews the main conversation as the web words it: the pending question. + const mainEntry = page.locator("aside").first().locator("[data-main-conversation]"); + await expect(mainEntry).toContainText("When should I check in with you?"); + await rhythm.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-rhythm"); + await rhythm.getByTestId("rhythm-choice-morning").click(); + await expect(rhythm.getByTestId("choice-answer")).toContainText("Every morning"); + + const quiet = card(page, "quiet_hours"); + await expect(quiet).toContainText("When should I stay quiet?"); + await quiet.getByTestId("quiet_hours-choice-22-07").click(); + + // Only a browser that can still turn push on is asked about notifications. + // The web words the closing note itself, from the time of the first check-in. + const closing = page + .getByTestId("transcript") + .getByText(/^All set\. I’ll check in (today|tomorrow) at 8:00\sAM\.$/); + const notifications = card(page, "notifications"); + await expect(notifications.or(closing).first()).toBeVisible(); + if (await notifications.isVisible()) { + await notifications.getByTestId("notifications-choice-later").click(); + } + await expect(closing).toBeVisible(); + await expect(mainEntry).toContainText(/All set\. I’ll check in (today|tomorrow) at 8:00\sAM\./); + await expect( + page.locator('[data-testid="choice-card"]:not(:has([data-testid="choice-answer"]))'), + ).toHaveCount(0); + await closing.scrollIntoViewIfNeeded(); + await page.mouse.move(1, 1); + await captureScreenshot(page, testInfo, "onboarding-intake-done"); + + const settings = await rpc<{ quietHours: { start: string; end: string } | null }>( + page, + "notifications/settings/get", + null, + ); + expect(settings.quietHours).toEqual({ start: "22:00", end: "07:00" }); + const profile = await rpc<{ facts: Array<{ value: string; source: string }> }>( + page, + "profile/get", + null, + ); + expect(profile.facts).toContainEqual( + expect.objectContaining({ + value: "Wants help with day-to-day work first", + source: "onboarding", + }), + ); +}); diff --git a/apps/web/e2e/onboarding-model-required.spec.ts b/apps/web/e2e/onboarding-model-required.spec.ts index bfd722e..224608d 100644 --- a/apps/web/e2e/onboarding-model-required.spec.ts +++ b/apps/web/e2e/onboarding-model-required.spec.ts @@ -37,7 +37,7 @@ test("onboarding shows the look picker when a model is still required", async ({ await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ @@ -56,7 +56,7 @@ for (const unavailable of ["empty", "failed"] as const) { await page.waitForURL(/\/onboarding/, { timeout: 20_000 }); await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|language|voice|integrations)$/, { timeout: 30_000 }, ); await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ diff --git a/apps/web/e2e/onboarding-oo-swipe.spec.ts b/apps/web/e2e/onboarding-oo-swipe.spec.ts index 8e175f2..0789ace 100644 --- a/apps/web/e2e/onboarding-oo-swipe.spec.ts +++ b/apps/web/e2e/onboarding-oo-swipe.spec.ts @@ -77,5 +77,5 @@ test("mobile onboarding: category pick and swipe look previews", async ({ page } .getByRole("button", { name: /Continue/i }) .first() .click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); + await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); }); diff --git a/apps/web/e2e/user-profile-edit.spec.ts b/apps/web/e2e/user-profile-edit.spec.ts new file mode 100644 index 0000000..612ab13 --- /dev/null +++ b/apps/web/e2e/user-profile-edit.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E: Settings → About you edits a fact in place: its wording, category and the spaces it + * shows in. + */ +import { expect, test } from "@playwright/test"; +import type { UserProfileState } from "@rakazo/contracts"; +import { captureScreenshot, completeOnboarding, openUserSettings, rpc, signup } from "./helpers"; + +test("a fact's wording, category and spaces are edited in place", async ({ page }, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-edit-${stamp}@rakazo.test`, "password12", "Edit Tester"); + await completeOnboarding(page); + const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" }); + await rpc(page, "profile/update", { + addFacts: [{ category: "preferences", value: "Prefers tea" }], + }); + + const settings = await openUserSettings(page, "profile"); + const profile = settings.getByTestId("user-profile-settings"); + const tea = profile.getByTestId("user-fact").filter({ hasText: "Prefers tea" }); + await tea.getByRole("button", { name: "Edit" }).click(); + + const editor = profile.getByTestId("user-fact-editor"); + const wording = editor.getByRole("textbox", { name: "Fact" }); + await expect(wording).toBeFocused(); + await wording.fill("Prefers green tea"); + await editor.getByRole("combobox", { name: "Category" }).selectOption("habits"); + await editor.getByRole("button", { name: "All spaces" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Side project" }).click(); + await expect(page.getByRole("menuitemcheckbox", { name: "All spaces" })).not.toBeChecked(); + await page.keyboard.press("Escape"); + await expect(editor.getByRole("button", { name: "Side project" })).toBeVisible(); + await captureScreenshot(page, testInfo, "user-profile-edit"); + + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + const edited = profile + .getByTestId("user-facts-habits") + .getByTestId("user-fact") + .filter({ hasText: "Prefers green tea" }); + await expect(edited).toContainText("Side project"); + await expect(tea).toHaveCount(0); + const teaFact = async () => + (await rpc(page, "profile/get", null)).facts.find((fact) => + fact.value.includes("tea"), + ); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + // Escape leaves the fact as it was; showing it everywhere again clears the limit. + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("textbox", { name: "Fact" }).fill("Discarded"); + await page.keyboard.press("Escape"); + await expect(editor).toHaveCount(0); + await expect(edited).toContainText("Prefers green tea"); + await expect(settings).toBeVisible(); + + await edited.getByRole("button", { name: "Edit" }).click(); + await editor.getByRole("button", { name: "Side project" }).click(); + await page.getByRole("menuitemcheckbox", { name: "All spaces" }).click(); + await page.keyboard.press("Escape"); + await editor.getByRole("button", { name: "Save", exact: true }).click(); + await expect(editor).toHaveCount(0); + await expect(edited).not.toContainText("Side project"); + expect(await teaFact()).toMatchObject({ + value: "Prefers green tea", + allSpaces: true, + spaceIds: [], + }); +}); diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts index 9b57445..da47b22 100644 --- a/apps/web/e2e/user-profile.spec.ts +++ b/apps/web/e2e/user-profile.spec.ts @@ -1,6 +1,7 @@ /** - * E2E: onboarding answers land in the user profile, and Settings → About you shows every - * fact with its origin, confirms a pending one, and forgets one or all of them. + * E2E: onboarding fills the user profile without asking (time zone, locale and language from + * the browser) and records the focus answer as a fact; Settings → About you shows every fact + * with its origin, confirms a pending one, and forgets one or all of them. */ import { expect, test } from "@playwright/test"; import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; @@ -8,7 +9,7 @@ import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } test.use({ timezoneId: "Europe/Vienna" }); test.beforeEach(async ({ page }) => { - // Walk the full emoji-first flow, including the preferences step. + // Walk the full emoji-first flow. await page.route("**/rpc/integrationSetup/get", (route) => route.fulfill({ json: { @@ -34,27 +35,23 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async }); await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Tell us about yourself/i })).toBeVisible(); - await expect(page.getByLabel("Timezone")).toHaveValue("Europe/Vienna"); - await page.getByPlaceholder("Your name").fill("Pat Example"); - await page.getByRole("button", { name: "Music", exact: true }).click(); - await page.getByRole("button", { name: "Deep-dive focused" }).click(); - await page.getByRole("button", { name: "Direct & concise" }).click(); - await page.getByRole("button", { name: "Continue", exact: true }).click(); - await expect(page.getByRole("heading", { name: /Choose your language/i })).toBeVisible(); await page.getByRole("button", { name: "English", exact: true }).click(); await expect(page.getByRole("heading", { name: /Pick a voice/i })).toBeVisible(); await page.getByRole("button", { name: "Skip", exact: true }).click(); - await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); + await page.waitForURL(/\/app\/g\/[^/]+/, { timeout: 30_000 }); await waitForChatComposer(page); + // The main bot asks the rest in the main conversation; the focus answer becomes a fact. + await page.getByTestId("focus-choice-day").click(); + await expect(page.locator('[data-testid="choice-card"][data-step="goal"]')).toBeVisible(); // The answers no longer overwrite a bot's instructions. const bots = await rpc>(page, "bots/list", null); expect(bots.length).toBeGreaterThan(0); for (const bot of bots) expect(bot.instructions).not.toContain("User Context"); - expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Pat Example"); + // The account name is used as is; onboarding no longer asks for it again. + expect((await rpc<{ name: string }>(page, "me", null)).name).toBe("Profile Tester"); // Sensitive facts from anywhere but the user wait for confirmation. await rpc(page, "profile/update", { @@ -67,14 +64,13 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(profile.getByTestId("profile-field-timeZone")).toContainText("Europe/Vienna"); await expect(profile.getByTestId("profile-field-language")).toContainText("English"); await expect(profile.getByTestId("profile-field-locale")).toContainText("United States"); - await expect(profile.getByTestId("profile-field-communicationStyle")).toContainText( - "Direct and concise", - ); - const music = profile.getByTestId("user-fact").filter({ hasText: "Interested in music" }); - await expect(music).toContainText("Onboarding"); - await expect(profile.getByTestId("user-facts-habits")).toContainText( - "Prefers focused deep dives", + const focus = profile + .getByTestId("user-fact") + .filter({ hasText: "Wants help with day-to-day work first" }); + await expect(focus).toContainText("Onboarding"); + await expect(profile.getByTestId("user-facts-goals")).toContainText( + "Wants help with day-to-day work first", ); const allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); @@ -84,8 +80,8 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async await expect(allergy).toHaveAttribute("data-status", "active"); await expect(allergy.getByRole("button", { name: "Confirm" })).toHaveCount(0); - await music.getByRole("button", { name: "Delete" }).click(); - await expect(music).toHaveCount(0); + await focus.getByRole("button", { name: "Delete" }).click(); + await expect(focus).toHaveCount(0); await captureScreenshot(page, testInfo, "user-profile-settings"); const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( page, @@ -93,7 +89,9 @@ test("onboarding feeds the profile; facts can be confirmed and forgotten", async null, ); expect(afterDelete.facts).toEqual( - expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), + expect.not.arrayContaining([ + expect.objectContaining({ value: "Wants help with day-to-day work first" }), + ]), ); expect(afterDelete.facts).toContainEqual( expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), diff --git a/apps/web/index.html b/apps/web/index.html index 3d2749a..a3c98da 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -9,6 +9,7 @@ + evOo diff --git a/apps/web/public/site.webmanifest b/apps/web/public/site.webmanifest index 90dd32d..c57083f 100644 --- a/apps/web/public/site.webmanifest +++ b/apps/web/public/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "EvOo", - "short_name": "EvOo", + "name": "evOo", + "short_name": "evOo", "description": "Deine Oos — immer da, immer wach.", "start_url": "/", "display": "standalone", diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js new file mode 100644 index 0000000..374172c --- /dev/null +++ b/apps/web/public/sw.js @@ -0,0 +1,61 @@ +// Shows the server's web push notifications and opens the conversation they belong to. +// No fetch handler: the app always loads from the network. + +self.addEventListener("install", () => { + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil(self.clients.claim()); +}); + +function appPath(value) { + return typeof value === "string" && value.startsWith("/") && !value.startsWith("//") + ? value + : "/app"; +} + +self.addEventListener("push", (event) => { + event.waitUntil( + (async () => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = {}; + } + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + // The app in front of the user already shows what happened. + if (windows.some((client) => client.focused && client.visibilityState === "visible")) return; + await self.registration.showNotification( + typeof data.title === "string" && data.title ? data.title : "evOo", + { + body: typeof data.body === "string" ? data.body : "", + // Same tag as the in-app notification, so one replaces the other. + tag: typeof data.tag === "string" && data.tag ? data.tag : undefined, + icon: "/icon-192.png", + data: { url: appPath(data.url) }, + }, + ); + })(), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil( + (async () => { + const target = new URL(appPath(event.notification.data?.url), self.location.origin).href; + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + const open = + windows.find((client) => client.url === target) ?? + windows.find((client) => new URL(client.url).origin === self.location.origin); + if (!open) { + await self.clients.openWindow(target); + return; + } + const focused = await open.focus(); + if (focused.url !== target) await focused.navigate(target).catch(() => undefined); + })(), + ); +}); diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index 0d6175d..c208258 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -79,6 +79,22 @@ const unread: InboxItem = { text: "Draft ready for review", createdAt, }; +const side = { id: "space-side", name: "Side project" }; +const askInOtherSpace: InboxItem = { + kind: "ask", + bot: writer, + target: { botId: writer.id }, + space: side, + runId: "run-side", + messageId: "msg-side", + ask: { kind: "ask", text: "Which title fits?", status: "pending" }, + createdAt, +}; +const groupAskInOtherSpace: InboxItem = { + ...approvalInGroup, + space: side, + messageId: "msg-side-group", +}; const idleBots = [ { id: scout.id, status: "idle", unread: false, preview: "" }, @@ -89,10 +105,18 @@ let container: HTMLDivElement; let root: Root; const onOpenBot = vi.fn(); const onOpenGroup = vi.fn(); +const onOpenInSpace = vi.fn(); function render(bots = idleBots) { return act(async () => - root.render(), + root.render( + , + ), ); } @@ -111,6 +135,7 @@ beforeEach(() => { api.markRead.mockReset().mockResolvedValue({ ok: true }); onOpenBot.mockReset(); onOpenGroup.mockReset(); + onOpenInSpace.mockReset(); container = document.createElement("div"); document.body.append(container); root = createRoot(container); @@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn itemOf("approval").querySelector("[data-answer]")?.click(), ); - expect(api.answer).toHaveBeenCalledWith({ - groupId: "group-ops", - runId: "run-approval", - messageId: "msg-approval", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" }, + { context: {} }, + ); expect(api.markRead).not.toHaveBeenCalled(); expect(api.list).toHaveBeenCalledTimes(2); expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1); @@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }); + expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} }); expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan( api.answer.mock.invocationCallOrder[0]!, ); @@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => { await act(async () => itemOf("ask").querySelector("[data-answer]")?.click()); - expect(api.answer).toHaveBeenCalledWith({ - botId: scout.id, - runId: "run-ask", - messageId: "msg-ask", - answer: "Paris", - }); + expect(api.answer).toHaveBeenCalledWith( + { botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" }, + { context: {} }, + ); expect(container.querySelector("[data-testid='main-inbox']")).toBeNull(); }); @@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]); }); +it("names another space's items and answers and opens them in that space", async () => { + api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] }); + api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] }); + api.answer.mockResolvedValue({ ok: true }); + await render(); + + const items = container.querySelectorAll("[data-testid='main-inbox-item']"); + expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe( + "· Side project", + ); + expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull(); + + await act(async () => items[0]?.querySelector("[data-answer]")?.click()); + expect(api.markRead).toHaveBeenCalledWith( + { botId: writer.id }, + { context: { spaceId: side.id } }, + ); + expect(api.answer).toHaveBeenCalledWith( + { botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" }, + { context: { spaceId: side.id } }, + ); + + const [own, group] = container.querySelectorAll("[data-testid='main-inbox-item']"); + await act(async () => group?.querySelector("button")?.click()); + await act(async () => own?.querySelector("button")?.click()); + expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]); + expect(onOpenBot.mock.calls).toEqual([[scout.id]]); + expect(onOpenGroup).not.toHaveBeenCalled(); +}); + it("refreshes when a bot's state changes, not on every render", async () => { api.list.mockResolvedValue({ items: [] }); await render(); diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 5f3007b..8ea65bd 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +/** Thread procedures for an item run in the item's own space. */ +function itemContext(item: InboxItem) { + return { context: item.space ? { spaceId: item.space.id } : {} }; +} + /** - * What the user's bots need from them outside the main conversation, with each bot as the - * sender: asks and approvals are answered in place, takeovers and unread threads open the - * bot's thread. + * What the user's bots in all of their spaces need from them outside the main conversation, with + * each bot as the sender: asks and approvals are answered in place, takeovers and unread threads + * open the bot's thread. */ export function MainInbox({ bots, onOpenBot, onOpenGroup, + onOpenInSpace, }: { /** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */ bots: readonly Pick[]; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; + /** Opens an app path, such as a thread, in another of the user's spaces. */ + onOpenInSpace: (spaceId: string, path: string) => void; }) { const { t } = useLingui(); const [items, setItems] = useState([]); @@ -59,11 +67,16 @@ export function MainInbox({ }, [refresh]); const open = useCallback( - (target: InboxThreadTarget) => { - if ("botId" in target) onOpenBot(target.botId); + ({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => { + if (space) { + onOpenInSpace( + space.id, + "botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`, + ); + } else if ("botId" in target) onOpenBot(target.botId); else onOpenGroup(target.groupId); }, - [onOpenBot, onOpenGroup], + [onOpenBot, onOpenGroup, onOpenInSpace], ); const answer = useCallback( @@ -72,14 +85,12 @@ export function MainInbox({ // Answering reads the ask, so the bot's thread should not come back as unread with the // same question. Mark it first: the reply to this answer marks it unread again. if ("botId" in item.target) { - await rpc.threads.markRead(item.target).catch(() => undefined); + await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined); } - await rpc.threads.answer({ - ...item.target, - runId: item.runId, - messageId: item.messageId, - answer: text, - }); + await rpc.threads.answer( + { ...item.target, runId: item.runId, messageId: item.messageId, answer: text }, + itemContext(item), + ); const key = inboxItemKey(item); setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key)); } finally { @@ -105,10 +116,10 @@ export function MainInbox({
open(item.target)} + onClick={() => open(item)} className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent" > - + {item.text ? ( {bot.name} + {space ? ( + + + {space.name} + + ) : null} ); } diff --git a/apps/web/src/components/NotificationSettingsPanel.tsx b/apps/web/src/components/NotificationSettingsPanel.tsx new file mode 100644 index 0000000..de71979 --- /dev/null +++ b/apps/web/src/components/NotificationSettingsPanel.tsx @@ -0,0 +1,197 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; +import { Input, Switch } from "@rakazo/ui-web"; +import type { ReactNode } from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { rpc } from "../lib/rpc"; +import { disableWebPush, enableWebPush, webPushEnabled, webPushSupported } from "../lib/web-push"; + +const DEFAULT_QUIET_HOURS = { start: "22:00", end: "07:00" }; +const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/; + +type PushState = "loading" | "on" | "off" | "blocked"; + +function SettingRow({ + label, + testId, + checked, + disabled, + onCheckedChange, + children, +}: { + label: ReactNode; + testId: string; + checked: boolean; + disabled?: boolean; + onCheckedChange: (checked: boolean) => void; + children?: ReactNode; +}) { + const labelId = useId(); + return ( +
  • +
    + + {label} + + +
    + {children} +
  • + ); +} + +/** Push on this browser, quiet hours and nudges. Quiet hours and nudges apply to every device. */ +export function NotificationSettingsPanel() { + const { t } = useLingui(); + const pushSupported = webPushSupported(); + const [settings, setSettings] = useState(null); + const [push, setPush] = useState("loading"); + const [pushBusy, setPushBusy] = useState(false); + const [error, setError] = useState(null); + const [quietDraft, setQuietDraft] = useState(DEFAULT_QUIET_HOURS); + const saves = useRef(Promise.resolve()); + + useEffect(() => { + let cancelled = false; + rpc.notifications.settings + .get() + .then((next) => { + if (cancelled) return; + setSettings(next); + if (next.quietHours) setQuietDraft(next.quietHours); + }) + .catch(() => { + if (!cancelled) setError(t`Couldn't load`); + }); + return () => { + cancelled = true; + }; + }, [t]); + + useEffect(() => { + if (!pushSupported) return; + let cancelled = false; + void webPushEnabled().then((enabled) => { + if (cancelled) return; + setPush(enabled ? "on" : Notification.permission === "denied" ? "blocked" : "off"); + }); + return () => { + cancelled = true; + }; + }, [pushSupported]); + + /** Saves in order, so the last change the user made is the one that sticks. */ + function save(patch: NotificationSettingsPatch) { + setError(null); + setSettings((current) => (current ? { ...current, ...patch } : current)); + saves.current = saves.current.then(async () => { + try { + setSettings(await rpc.notifications.settings.update(patch)); + } catch { + setError(t`Couldn't save`); + setSettings(await rpc.notifications.settings.get().catch(() => null)); + } + }); + } + + async function changePush(next: boolean) { + setPushBusy(true); + setError(null); + try { + if (!next) { + await disableWebPush(); + setPush("off"); + return; + } + const result = await enableWebPush(); + if (result === "enabled") setPush("on"); + else if (result === "denied") setPush("blocked"); + else setError(t`Couldn't save`); + } catch { + setError(t`Couldn't save`); + } finally { + setPushBusy(false); + } + } + + function changeQuietTime(field: "start" | "end", value: string) { + const next = { ...quietDraft, [field]: value }; + setQuietDraft(next); + if (CLOCK_TIME.test(next.start) && CLOCK_TIME.test(next.end) && next.start !== next.end) { + save({ quietHours: next }); + } + } + + return ( +
    + {error ? ( +

    + {error} +

    + ) : null} + {settings ? ( +
    +
      + {pushSupported ? ( + Push on this device} + testId="notification-push-switch" + checked={push === "on"} + disabled={push === "loading" || push === "blocked" || pushBusy} + onCheckedChange={(next) => void changePush(next)} + > + {push === "blocked" ? ( +

      + Blocked in your browser settings +

      + ) : null} +
      + ) : null} + Quiet hours} + testId="notification-quiet-hours-switch" + checked={settings.quietHours !== null} + onCheckedChange={(next) => save({ quietHours: next ? quietDraft : null })} + > + {settings.quietHours ? ( +
      + changeQuietTime("start", event.target.value)} + className="w-36" + /> + + changeQuietTime("end", event.target.value)} + className="w-36" + /> +
      + ) : null} +
      + Nudges from your Oo} + testId="notification-nudges-switch" + checked={settings.nudges} + onCheckedChange={(next) => save({ nudges: next })} + /> +
    +
    + ) : null} +
    + ); +} diff --git a/apps/web/src/components/UserFactEditor.tsx b/apps/web/src/components/UserFactEditor.tsx new file mode 100644 index 0000000..17306e1 --- /dev/null +++ b/apps/web/src/components/UserFactEditor.tsx @@ -0,0 +1,129 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { UserFact, UserFactCategory } from "@rakazo/contracts"; +import { USER_FACT_CATEGORIES, USER_FACT_VALUE_MAX_LENGTH } from "@rakazo/contracts"; +import { + Button, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, + Input, + NativeSelect, + NativeSelectOption, +} from "@rakazo/ui-web"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; +import type { UserFactDraft } from "../lib/user-profile-view"; +import { factSpaceNames, toggleFactSpace, userFactDraft } from "../lib/user-profile-view"; + +/** Edits one fact in place: its wording, category and, with more than one space, where it shows. */ +export function UserFactEditor({ + fact, + spaces, + categoryLabels, + busy, + onSave, + onCancel, +}: { + fact: UserFact; + /** The user's spaces; the visibility control appears with more than one. */ + spaces: ReadonlyArray<{ id: string; name: string }>; + categoryLabels: Record; + busy: boolean; + onSave: (draft: UserFactDraft) => void; + onCancel: () => void; +}) { + const { t } = useLingui(); + const [draft, setDraft] = useState(() => userFactDraft(fact)); + const allSpaces = t`All spaces`; + const showSpaces = spaces.length > 1 || draft.spaceIds !== null; + + return ( +
  • +
    { + event.preventDefault(); + if (draft.value.trim()) onSave(draft); + }} + onKeyDown={(event) => { + // The spaces menu renders in a portal; its Escape only closes the menu. + if (event.key === "Escape" && event.currentTarget.contains(event.target as Node)) { + event.stopPropagation(); + onCancel(); + } + }} + > + setDraft({ ...draft, value: event.target.value })} + /> +
    + + setDraft({ ...draft, category: event.target.value as UserFactCategory }) + } + > + {USER_FACT_CATEGORIES.map((category) => ( + + {categoryLabels[category]} + + ))} + + {showSpaces ? ( + + } + > + + {factSpaceNames(draft.spaceIds, spaces).join(", ") || allSpaces} + + + + setDraft({ ...draft, spaceIds: null })} + > + {allSpaces} + + + {spaces.map((space) => ( + + setDraft({ + ...draft, + spaceIds: toggleFactSpace(draft.spaceIds, space.id, checked), + }) + } + > + + {space.name} + + + ))} + + + ) : null} +
    + + +
    +
    +
    +
  • + ); +} diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx index 6a8e2e3..3811a9a 100644 --- a/apps/web/src/components/UserProfileSettings.tsx +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -10,7 +10,7 @@ import { AlertDialogTitle, Button, } from "@rakazo/ui-web"; -import { XIcon } from "lucide-react"; +import { PencilIcon, XIcon } from "lucide-react"; import type { ReactNode } from "react"; import { useEffect, useId, useState } from "react"; import { getActiveUiLocale } from "../lib/i18n"; @@ -20,9 +20,12 @@ import { CLEARED_PROFILE_FIELD, displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + userFactEdit, } from "../lib/user-profile-view"; +import { UserFactEditor } from "./UserFactEditor"; function Row({ value, @@ -51,14 +54,30 @@ function Row({ ); } -/** Everything the user's bots know about the user, with consent and forget controls. */ +/** Everything the user's bots know about the user, with edit, consent and forget controls. */ export function UserProfileSettings() { const { t } = useLingui(); const [state, setState] = useState(null); + const [spaces, setSpaces] = useState>([]); + const [editingId, setEditingId] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [forgetOpen, setForgetOpen] = useState(false); + useEffect(() => { + let cancelled = false; + // Only names a fact's spaces; without them the profile still works. + rpc.spaces + .list() + .then((navigation) => { + if (!cancelled) setSpaces(navigation.spaces.map(({ id, name }) => ({ id, name }))); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + useEffect(() => { let cancelled = false; rpc.profile @@ -74,14 +93,16 @@ export function UserProfileSettings() { }; }, [t]); - async function apply(change: () => Promise) { - if (busy) return; + async function apply(change: () => Promise): Promise { + if (busy) return false; setBusy(true); setError(null); try { setState(await change()); + return true; } catch (err) { setError(err instanceof Error ? err.message : t`Couldn't save`); + return false; } finally { setBusy(false); } @@ -178,36 +199,75 @@ export function UserProfileSettings() { {categoryLabels[group.category]}
      - {group.facts.map((fact) => ( - ( - <> - {fact.status === "pending" ? ( + {group.facts.map((fact) => + fact.id === editingId ? ( + setEditingId(null)} + onSave={(draft) => { + const edit = userFactEdit(fact, draft); + if (!edit) { + setEditingId(null); + return; + } + void apply(() => rpc.profile.update({ editFacts: [edit] })).then((saved) => { + if (saved) setEditingId(null); + }); + }} + /> + ) : ( + ( + <> + {fact.status === "pending" ? ( + + ) : null} - ) : null} - {deleteButton( - describedBy, - () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), - )} - - )} - /> - ))} + {deleteButton( + describedBy, + () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), + )} + + )} + /> + ), + )}
    ))} diff --git a/apps/web/src/components/heute/CoachRhythmControl.tsx b/apps/web/src/components/heute/CoachRhythmControl.tsx new file mode 100644 index 0000000..102c085 --- /dev/null +++ b/apps/web/src/components/heute/CoachRhythmControl.tsx @@ -0,0 +1,214 @@ +import { i18n } from "@lingui/core"; +import { useLingui } from "@lingui/react/macro"; +import type { CoachRhythm } from "@rakazo/contracts"; +import { + Button, + Input, + NativeSelect, + NativeSelectOption, + Popover, + PopoverContent, + PopoverTrigger, + Spinner, + Switch, +} from "@rakazo/ui-web"; +import { CalendarClock } from "lucide-react"; +import type { ReactNode } from "react"; +import { useCallback, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; + +/** Times a check-in starts at when it is switched on. */ +const DEFAULTS = { morning: "08:00", evening: "20:00", weekly: { day: 0, time: "18:00" } }; +const WEEKDAYS = [0, 1, 2, 3, 4, 5, 6]; + +function weekdayName(day: number): string { + // 2026-10-04 was a Sunday, so day 0 maps to Sunday. + return new Date(Date.UTC(2026, 9, 4 + day)).toLocaleDateString(i18n.locale || "en", { + weekday: "long", + timeZone: "UTC", + }); +} + +/** When the main bot checks in: loaded when opened, saved on every change. */ +export function CoachRhythmControl() { + const { t } = useLingui(); + const [rhythm, setRhythm] = useState(null); + const latestRequest = useRef(0); + + const load = useCallback(async () => { + const request = ++latestRequest.current; + try { + const loaded = await rpc.coach.rhythm.get(); + if (request === latestRequest.current) setRhythm(loaded); + } catch { + // Opening the control again retries. + } + }, []); + + const save = useCallback( + async (next: CoachRhythm) => { + setRhythm(next); + const request = ++latestRequest.current; + try { + const saved = await rpc.coach.rhythm.set(next); + if (request === latestRequest.current) setRhythm(saved); + } catch { + if (request === latestRequest.current) void load(); + } + }, + [load], + ); + + return ( + { + if (open) void load(); + }} + > + } + > + + + {rhythm ? ( + <> + void save({ ...rhythm, morning: on ? DEFAULTS.morning : null })} + > + {(labelId) => + rhythm.morning ? ( + void save({ ...rhythm, morning })} + /> + ) : null + } + + void save({ ...rhythm, evening: on ? DEFAULTS.evening : null })} + > + {(labelId) => + rhythm.evening ? ( + void save({ ...rhythm, evening })} + /> + ) : null + } + + void save({ ...rhythm, weekly: on ? DEFAULTS.weekly : null })} + > + {(labelId) => { + const weekly = rhythm.weekly; + if (!weekly) return null; + return ( + <> + + void save({ + ...rhythm, + weekly: { ...weekly, day: Number(event.target.value) }, + }) + } + > + {WEEKDAYS.map((day) => ( + + {weekdayName(day)} + + ))} + + void save({ ...rhythm, weekly: { ...weekly, time } })} + /> + + ); + }} + + + ) : ( +
    + +
    + )} +
    +
    + ); +} + +function RhythmRow({ + label, + testId, + on, + onToggle, + children, +}: { + label: string; + testId: string; + on: boolean; + onToggle: (on: boolean) => void; + children: (labelId: string) => ReactNode; +}) { + const labelId = useId(); + return ( +
    + onToggle(checked)} + /> + + {label} + +
    {children(labelId)}
    +
    + ); +} + +function TimeInput({ + labelId, + value, + onChange, +}: { + labelId: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + { + if (event.target.value) onChange(event.target.value.slice(0, 5)); + }} + className="h-control-sm w-32 px-2 tabular-nums" + /> + ); +} diff --git a/apps/web/src/components/heute/HeuteCoach.tsx b/apps/web/src/components/heute/HeuteCoach.tsx new file mode 100644 index 0000000..c88a3c9 --- /dev/null +++ b/apps/web/src/components/heute/HeuteCoach.tsx @@ -0,0 +1,105 @@ +import { i18n } from "@lingui/core"; +import { Plural, Trans } from "@lingui/react/macro"; +import type { CoachToday, HabitToday } from "@rakazo/contracts"; +import { Checkbox, cn } from "@rakazo/ui-web"; +import { Flame } from "lucide-react"; +import { useId } from "react"; +import { formatLocalDate } from "../../lib/heute-brief"; + +/** Shown once a habit has been kept at least twice in a row. */ +const STREAK_MIN = 2; + +/** Today's brief, habits to check off and active goals; each part only when it has content. */ +export function HeuteCoach({ + today, + onHabitDone, +}: { + today: CoachToday; + onHabitDone: (habit: HabitToday, done: boolean) => void; +}) { + return ( + <> + {today.brief ? ( +
    +

    {today.brief.text}

    +
    + ) : null} + + {today.habits.length > 0 ? ( +
    +

    + Habits +

    +
      + {today.habits.map((habit) => ( +
    • + onHabitDone(habit, done)} /> +
    • + ))} +
    +
    + ) : null} + + {today.goals.length > 0 ? ( +
    +

    + Goals +

    +
      + {today.goals.map((goal) => ( +
    • + {goal.title} + {goal.targetDate ? ( + + {formatLocalDate(goal.targetDate, i18n.locale || "en", { + day: "numeric", + month: "short", + })} + + ) : null} +
    • + ))} +
    +
    + ) : null} + + ); +} + +function HabitRow({ habit, onDone }: { habit: HabitToday; onDone: (done: boolean) => void }) { + const id = useId(); + return ( + + ); +} diff --git a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx b/apps/web/src/components/onboarding/OnboardingScanProgress.tsx deleted file mode 100644 index 3b40753..0000000 --- a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { useEffect, useState } from "react"; -import { rpc } from "../../lib/rpc"; - -interface ScanStatus { - status: "scanning" | "connecting" | "building" | "briefing" | "complete" | "failed"; - progress: number; - message: string; - result?: unknown; -} - -const PHASES = [ - { - key: "scanning" as const, - label: "Scanning your computer...", - sublabel: "Files, folders, git repositories", - }, - { - key: "connecting" as const, - label: "Connecting to your services...", - sublabel: "GitHub, email, calendar", - }, - { - key: "building" as const, - label: "Building your profile...", - sublabel: "Interests, skills, preferences", - }, - { - key: "briefing" as const, - label: "Setting up your agent team...", - sublabel: "Project folders, role assignments", - }, -] as const; - -export function OnboardingScanProgress({ - onComplete, - botId, -}: { - onComplete: () => void; - botId?: string; -}) { - const [status, setStatus] = useState(null); - const [error, setError] = useState(null); - const [, setScanId] = useState(null); - - useEffect(() => { - let cancelled = false; - let pollingInterval: ReturnType | null = null; - - const run = async () => { - try { - const scanResult = await rpc.onboarding.scan(botId ? { botId } : {}); - if (cancelled) return; - setScanId(scanResult.scanId); - - // Poll for status - const poll = async () => { - try { - const s = await rpc.onboarding.scanStatus({ scanId: scanResult.scanId }); - if (cancelled) return; - setStatus(s); - if (s.status === "complete") { - onComplete(); - } else if (s.status === "failed") { - setError(s.message); - } - } catch { - // poll error — keep trying - } - }; - - // First poll immediately, then every 2s - await poll(); - pollingInterval = setInterval(poll, 2000); - } catch (err) { - if (!cancelled) setError(err instanceof Error ? err.message : "Scan failed"); - } - }; - - run(); - return () => { - cancelled = true; - if (pollingInterval) clearInterval(pollingInterval); - }; - }, [botId, onComplete]); - - if (error) { - return ( -
    -

    {error}

    - -
    - ); - } - - if (!status) { - return ( -
    -
    -

    Preparing your profile...

    -
    - ); - } - - const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status); - const isComplete = status.status === "complete"; - - return ( -
    -

    - {isComplete ? "All set!" : "Getting to know you..."} -

    -

    - {isComplete - ? "Your profile and agent team are ready." - : "We're scanning your computer and services to personalize your experience."} -

    - -
    - {PHASES.map((phase, idx) => { - const isActive = idx === currentPhaseIndex && !isComplete; - const isDone = idx < currentPhaseIndex || isComplete; - return ( -
    -
    - {isDone ? ( - - ✓ - - ) : isActive ? ( - - ● - - ) : ( - - ○ - - )} -
    -
    -

    - {phase.label} -

    -

    {phase.sublabel}

    -
    - {isActive && ( -
    -
    -
    -
    -
    - )} -
    - ); - })} -
    - - {isComplete && ( -
    - -
    - )} -
    - ); -} diff --git a/apps/web/src/components/onboarding/OnboardingScanStep.tsx b/apps/web/src/components/onboarding/OnboardingScanStep.tsx new file mode 100644 index 0000000..1b700c8 --- /dev/null +++ b/apps/web/src/components/onboarding/OnboardingScanStep.tsx @@ -0,0 +1,250 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { OnboardingScanFact, OnboardingScanProject } from "@rakazo/contracts"; +import { Button, Checkbox } from "@rakazo/ui-web"; +import { useEffect, useId, useRef, useState } from "react"; +import { rpc } from "../../lib/rpc"; +import { LoadingState } from "../ai/primitives"; + +const POLL_MS = 1_000; + +interface Found { + projects: OnboardingScanProject[]; + facts: OnboardingScanFact[]; +} + +function toggled(ids: string[], id: string, on: boolean): string[] { + return on ? [...ids, id] : ids.filter((entry) => entry !== id); +} + +/** + * Asks before looking at the server's scan folder, then lets the user choose which found projects + * become project spaces and which languages and tools go to About you. Nothing is created without + * a choice. Skips itself when this account cannot scan. + */ +export function OnboardingScanStep({ onDone }: { onDone: () => void }) { + const { t } = useLingui(); + const ids = useId(); + const done = useRef(onDone); + done.current = onDone; + const [folder, setFolder] = useState(null); + const [looking, setLooking] = useState(false); + const [scanId, setScanId] = useState(null); + const [found, setFound] = useState(null); + const [failed, setFailed] = useState(false); + const [projectIds, setProjectIds] = useState([]); + const [factIds, setFactIds] = useState([]); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + rpc.onboarding + .scanTarget() + .then((target) => { + if (cancelled) return; + if (target.folder) setFolder(target.folder); + else done.current(); + }) + .catch(() => { + if (!cancelled) done.current(); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (!scanId) return; + let cancelled = false; + let timer: ReturnType | undefined; + const poll = () => { + rpc.onboarding + .scanStatus({ scanId }) + .then((status) => { + if (cancelled) return; + if (status.status === "scanning") { + timer = setTimeout(poll, POLL_MS); + } else if (status.status === "failed") { + setFailed(true); + } else { + setFound({ projects: status.projects, facts: status.facts }); + setFactIds(status.facts.map((fact) => fact.id)); + } + }) + .catch(() => { + if (!cancelled) setFailed(true); + }); + }; + poll(); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [scanId]); + + async function look() { + setLooking(true); + try { + setScanId((await rpc.onboarding.scan()).scanId); + } catch { + setFailed(true); + } + } + + async function add() { + if (!scanId) return; + setSaving(true); + setError(null); + try { + await rpc.onboarding.applyScan({ scanId, projectIds, factIds }); + done.current(); + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)); + setSaving(false); + } + } + + const continueButton = ( +
    + +
    + ); + + if (failed) { + return ( +
    +

    + Could not look in this folder. +

    + {continueButton} +
    + ); + } + + if (found && found.projects.length === 0) { + return ( +
    +

    + No projects found +

    + {continueButton} +
    + ); + } + + if (found) { + return ( +
    +
    + +

    + Add as projects +

    +
    +
    + {found.projects.map((project) => ( + + ))} +
    +
    + + {found.facts.length > 0 ? ( +
    + + Add to About you + +
    + {found.facts.map((fact) => ( + + ))} +
    +
    + ) : null} + + {error ?

    {error}

    : null} + +
    + + +
    +
    + ); + } + + if (looking) { + return ( +
    + +
    + ); + } + + if (!folder) return null; + + return ( +
    +

    + Look for projects here? +

    +

    + {folder} +

    +

    + Only file and folder names are read. +

    +
    + + +
    +
    + ); +} diff --git a/apps/web/src/lib/heute-brief.test.ts b/apps/web/src/lib/heute-brief.test.ts index d023919..18e85e9 100644 --- a/apps/web/src/lib/heute-brief.test.ts +++ b/apps/web/src/lib/heute-brief.test.ts @@ -1,6 +1,13 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { describe, expect, it } from "vitest"; -import { buildHeuteBrief, HEUTE_NEEDS_YOU_CAP, isToday } from "./heute-brief"; +import { + buildHeuteBrief, + coachRefreshSignal, + formatLocalDate, + HEUTE_NEEDS_YOU_CAP, + isToday, + withHabitDone, +} from "./heute-brief"; const now = new Date("2026-09-22T12:00:00.000Z"); @@ -142,3 +149,59 @@ describe("buildHeuteBrief", () => { expect(brief.actionCount).toBe(5); }); }); + +function habit(overrides: Partial = {}): HabitToday { + return { + id: "habit-1", + title: "Walk", + cadence: "daily", + goalId: null, + active: true, + source: "user", + createdByBotId: null, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + doneToday: false, + streak: 3, + ...overrides, + }; +} + +describe("coach on the Heute page", () => { + it("reloads the coach when a run finishes, not while runs are only working", () => { + const working = [run({ runId: "run-1", status: "running" })]; + expect(coachRefreshSignal(working)).toBe(""); + expect(coachRefreshSignal([...working, run({ runId: "run-2", status: "completed" })])).toBe( + "run-2", + ); + }); + + it("checks a habit off before the server confirms, moving the streak by one", () => { + const today: CoachToday = { + date: "2026-10-03", + timeZone: "Europe/Vienna", + brief: null, + goals: [], + habits: [habit(), habit({ id: "habit-2", doneToday: true, streak: 1 })], + checkIn: null, + }; + const checked = withHabitDone(today, "habit-1", true); + expect(checked.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: true, streak: 1 }, + ]); + expect(withHabitDone(checked, "habit-2", false).habits[1]).toMatchObject({ + doneToday: false, + streak: 0, + }); + // Repeating the same state changes nothing. + expect(withHabitDone(checked, "habit-1", true)).toEqual(checked); + }); + + it("labels the user's calendar date without shifting it into the browser's zone", () => { + expect(formatLocalDate("2026-10-03", "en-US", { weekday: "long" })).toBe("Saturday"); + expect(formatLocalDate("2026-11-01", "en-US", { month: "short", day: "numeric" })).toBe( + "Nov 1", + ); + }); +}); diff --git a/apps/web/src/lib/heute-brief.ts b/apps/web/src/lib/heute-brief.ts index dfebb61..a102370 100644 --- a/apps/web/src/lib/heute-brief.ts +++ b/apps/web/src/lib/heute-brief.ts @@ -1,4 +1,4 @@ -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, RunActivityRow } from "@rakazo/contracts"; import { isActionableRun, isDoingStatus, isFailedRoutine } from "./agent-activity"; export type JetztRunItem = { @@ -137,3 +137,38 @@ export function buildHeuteBrief(params: { weiter: [], }; } + +/** + * Finished runs are when the coach learns something (a check-in, today's brief), so a change in + * them is the cue to reload the coach's view of the day. + */ +export function coachRefreshSignal(recentRuns: RunActivityRow[]): string { + return recentRuns + .filter((run) => run.status === "completed") + .map((run) => run.runId) + .join(","); +} + +/** + * A habit checked or unchecked before the server confirms. The current period only counts + * toward the streak once it is done, so the streak moves by one. + */ +export function withHabitDone(today: CoachToday, habitId: string, done: boolean): CoachToday { + return { + ...today, + habits: today.habits.map((habit) => + habit.id !== habitId || habit.doneToday === done + ? habit + : { ...habit, doneToday: done, streak: Math.max(0, habit.streak + (done ? 1 : -1)) }, + ), + }; +} + +/** A calendar date ("YYYY-MM-DD") as a label, independent of the browser's time zone. */ +export function formatLocalDate( + date: string, + locale: string, + options: Intl.DateTimeFormatOptions, +): string { + return new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...options, timeZone: "UTC" }); +} diff --git a/apps/web/src/lib/onboarding-copy.test.ts b/apps/web/src/lib/onboarding-copy.test.ts new file mode 100644 index 0000000..b408585 --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.test.ts @@ -0,0 +1,203 @@ +import type { MessageBlock, ThreadMessage } from "@rakazo/contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "./i18n.js"; +import { + intakeNoteText, + localizedChoice, + localizedPreview, + withIntakeNoteText, +} from "./onboarding-copy.js"; + +// The macros compile at build time; here the English source is the id, placeholders numbered. +vi.mock("@lingui/core/macro", () => { + const descriptor = (parts: TemplateStringsArray, values: unknown[]) => { + const message = parts.reduce((out, part, index) => `${out}{${index - 1}}${part}`); + return { id: message, message, values: { ...values } }; + }; + return { + t: (parts: TemplateStringsArray, ...values: unknown[]) => i18n._(descriptor(parts, values)), + msg: (parts: TemplateStringsArray, ...values: unknown[]) => descriptor(parts, values), + }; +}); + +type Choice = Extract; +type Note = Extract; + +// Intl may separate the time from AM/PM with a narrow no-break space. +const plain = (text: string) => text.replace(/\s/g, " "); + +beforeEach(() => { + i18n.load("en", {}); + i18n.activate("en"); +}); + +describe("localizedChoice", () => { + it("says a known card from its step and option ids, whatever the server wrote", () => { + const card: Choice = { + kind: "choice", + step: "rhythm", + question: "server question", + options: [ + { id: "morning", letter: "A", label: "server morning" }, + { id: "fortnightly", letter: "B", label: "Every other week" }, + { id: "none", letter: "", label: "server none" }, + ], + }; + expect(localizedChoice(card)).toEqual({ + ...card, + question: "When should I check in with you?", + options: [ + { id: "morning", letter: "A", label: "Every morning" }, + // An option the web does not know keeps the server text. + { id: "fortnightly", letter: "B", label: "Every other week" }, + { id: "none", letter: "", label: "Not now" }, + ], + }); + }); + + it("words the goal placeholder and keeps the server's quiet-hour ranges", () => { + const goal = localizedChoice({ + kind: "choice", + step: "goal", + question: "?", + textInput: { placeholder: "server placeholder" }, + options: [{ id: "skip", letter: "", label: "server skip" }], + }); + expect(goal.textInput).toEqual({ placeholder: "Your goal" }); + expect(goal.options[0]?.label).toBe("Skip"); + const quiet = localizedChoice({ + kind: "choice", + step: "quiet_hours", + question: "?", + options: [{ id: "22-07", letter: "A", label: "22:00 – 7:00" }], + }); + expect(quiet.options[0]?.label).toBe("22:00 – 7:00"); + }); + + it("recognizes another bot's focus card and leaves other cards alone", () => { + const focus: Choice = { + kind: "choice", + question: "server question", + options: [ + { id: "day", letter: "A", label: "x" }, + { id: "crew", letter: "D", label: "y" }, + ], + }; + expect(localizedChoice(focus).question).toBe("What do you want me on first?"); + expect(localizedChoice(focus).options.map((option) => option.label)).toEqual([ + "Day-to-day work", + "Specialist crew", + ]); + const other: Choice = { + kind: "choice", + question: "Pick one", + options: [{ id: "a", letter: "A", label: "Apples" }], + }; + expect(localizedChoice(other)).toBe(other); + }); +}); + +describe("intakeNoteText", () => { + const now = new Date(2026, 9, 7, 10, 0); + const note = (nextCheckInAt: Date | null): Note => ({ + kind: "intake_note", + note: "done", + nextCheckInAt: nextCheckInAt?.toISOString() ?? null, + text: "server text", + }); + + it("names the first check-in relative to today, in the user's clock", () => { + expect(plain(intakeNoteText(note(new Date(2026, 9, 7, 18, 0)), now))).toBe( + "All set. I’ll check in today at 6:00 PM.", + ); + expect(plain(intakeNoteText(note(new Date(2026, 9, 8, 8, 0)), now))).toBe( + "All set. I’ll check in tomorrow at 8:00 AM.", + ); + expect(plain(intakeNoteText(note(new Date(2026, 9, 11, 18, 0)), now))).toBe( + "All set. I’ll check in on Sunday at 6:00 PM.", + ); + }); + + it("has a line for no check-in and falls back to the server text when unreadable", () => { + expect(intakeNoteText(note(null), now)).toBe("All set. I’m here whenever you need me."); + expect(intakeNoteText({ ...note(null), nextCheckInAt: "soon" }, now)).toBe("server text"); + }); +}); + +describe("withIntakeNoteText", () => { + const message = (blocks: MessageBlock[]) => + ({ id: "m", role: "bot", blocks, createdAt: "2026-10-07T08:00:00.000Z" }) as ThreadMessage; + + it("turns notes into text and leaves other messages untouched", () => { + const plainMessage = message([{ kind: "text", text: "hi" }]); + expect(withIntakeNoteText(plainMessage)).toBe(plainMessage); + const withNote = message([ + { kind: "intake_note", note: "done", nextCheckInAt: null, text: "server text" }, + ]); + expect(withIntakeNoteText(withNote).blocks).toEqual([ + { kind: "text", text: "All set. I’m here whenever you need me." }, + ]); + }); +}); + +describe("localizedPreview", () => { + const now = new Date(2026, 9, 7, 10, 0); + const rhythm: Choice = { + kind: "choice", + step: "rhythm", + question: "server question", + options: [ + { id: "morning", letter: "A", label: "server morning" }, + { id: "none", letter: "", label: "server none" }, + ], + }; + const goal: Choice = { + kind: "choice", + step: "goal", + question: "server goal", + textInput: { placeholder: "server placeholder" }, + options: [{ id: "skip", letter: "", label: "server skip" }], + }; + const done: Note = { + kind: "intake_note", + note: "done", + nextCheckInAt: new Date(2026, 9, 8, 8, 0).toISOString(), + text: "server text", + }; + const preview = (previewBlock?: Choice | Note) => localizedPreview({ preview: "", previewBlock }); + + beforeEach(() => { + vi.useFakeTimers({ now }); + return () => vi.useRealTimers(); + }); + + it("keeps the server preview when onboarding did not write the latest message", () => { + expect(localizedPreview({ preview: "See you at 8" })).toBe("See you at 8"); + }); + + it("reads like the conversation: the question, the answer once given, the closing note", () => { + expect(preview(rhythm)).toBe("When should I check in with you?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Every morning"); + expect(preview({ ...goal, answerId: "_text", answerText: "Run a 10K" })).toBe("Run a 10K"); + expect(plain(preview(done))).toBe("All set. I’ll check in tomorrow at 8:00 AM."); + // A dismissed card is gone from the conversation, so the preview is too. + expect(preview({ ...rhythm, answerId: "_dismissed" })).toBe(""); + }); + + it("speaks the user's language", () => { + i18n.load("de", { + "When should I check in with you?": "Wann soll ich mich bei dir melden?", + "Every morning": "Jeden Morgen", + "All set. I’ll check in tomorrow at {0}.": "Alles klar. Ich melde mich morgen um {0}.", + "All set. I’m here whenever you need me.": + "Alles klar. Ich bin da, wann immer du mich brauchst.", + }); + i18n.activate("de"); + expect(preview(rhythm)).toBe("Wann soll ich mich bei dir melden?"); + expect(preview({ ...rhythm, answerId: "morning" })).toBe("Jeden Morgen"); + expect(preview(done)).toBe("Alles klar. Ich melde mich morgen um 8:00."); + expect(preview({ ...done, nextCheckInAt: null })).toBe( + "Alles klar. Ich bin da, wann immer du mich brauchst.", + ); + }); +}); diff --git a/apps/web/src/lib/onboarding-copy.ts b/apps/web/src/lib/onboarding-copy.ts new file mode 100644 index 0000000..839110f --- /dev/null +++ b/apps/web/src/lib/onboarding-copy.ts @@ -0,0 +1,130 @@ +import type { MessageDescriptor } from "@lingui/core"; +import { msg, t } from "@lingui/core/macro"; +import type { Group, MessageBlock, OnboardingStep, ThreadMessage } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; +import { i18n } from "./i18n"; + +/** + * The onboarding cards in the user's language. The server stores each card with its step and + * option ids plus English text; the web says what it knows from the ids and keeps the server + * text for anything new (mobile and agent history always use the server text). + */ + +type ChoiceBlock = Extract; +type ChoiceOption = ChoiceBlock["options"][number]; +type IntakeNote = Extract; + +const QUESTIONS: Record = { + focus: msg`What do you want me on first?`, + goal: msg`What’s one thing you want to get done in the next few weeks?`, + rhythm: msg`When should I check in with you?`, + quiet_hours: msg`When should I stay quiet?`, + notifications: msg`Can I send you notifications?`, +}; + +const SKIP = msg`Skip`; +const NOT_NOW = msg`Not now`; + +// Quiet-hour ranges are left out: the server already writes them in the user's locale. +const OPTIONS: Record> = { + focus: { + day: msg`Day-to-day work`, + inbox: msg`Inbox & email`, + research: msg`Research & writing`, + crew: msg`Specialist crew`, + skip: SKIP, + }, + goal: { skip: SKIP }, + rhythm: { + morning: msg`Every morning`, + morning_evening: msg`Morning and evening`, + weekly: msg`Once a week`, + none: NOT_NOW, + }, + quiet_hours: { skip: SKIP }, + notifications: { on: msg`Turn on`, later: NOT_NOW }, +}; + +const PLACEHOLDERS: Partial> = { + goal: msg`Your goal`, +}; + +/** Another bot's focus card carries no step; its option ids give it away. */ +function cardStep(card: ChoiceBlock): OnboardingStep | undefined { + if (card.step) return card.step; + const focus = OPTIONS.focus; + return card.options.length > 0 && card.options.every((option) => focus[option.id]) + ? "focus" + : undefined; +} + +/** The card's question, options and placeholder in the user's language. */ +export function localizedChoice(card: ChoiceBlock): ChoiceBlock { + const step = cardStep(card); + if (!step) return card; + const options = OPTIONS[step]; + const placeholder = PLACEHOLDERS[step]; + return { + ...card, + question: i18n._(QUESTIONS[step]), + options: card.options.map((option) => { + const label = options[option.id]; + return label ? { ...option, label: i18n._(label) } : option; + }), + ...(card.textInput && placeholder ? { textInput: { placeholder: i18n._(placeholder) } } : {}), + }; +} + +/** What the user answered: the option they picked or their own words. */ +export function chosenOption(card: ChoiceBlock): ChoiceOption | undefined { + return card.answerText + ? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: card.answerText } + : card.options.find((option) => option.id === card.answerId); +} + +function sameDay(left: Date, right: Date): boolean { + return left.toDateString() === right.toDateString(); +} + +/** The closing note in the user's language, with the first check-in in their time zone. */ +export function intakeNoteText(note: IntakeNote, now = new Date()): string { + if (note.note !== "done") return note.text; + if (!note.nextCheckInAt) return t`All set. I’m here whenever you need me.`; + const at = new Date(note.nextCheckInAt); + if (!Number.isFinite(at.getTime())) return note.text; + const time = new Intl.DateTimeFormat(i18n.locale || "en", { + hour: "numeric", + minute: "2-digit", + }).format(at); + const tomorrow = new Date(now); + tomorrow.setDate(now.getDate() + 1); + if (sameDay(at, now)) return t`All set. I’ll check in today at ${time}.`; + if (sameDay(at, tomorrow)) return t`All set. I’ll check in tomorrow at ${time}.`; + const weekday = new Intl.DateTimeFormat(i18n.locale || "en", { weekday: "long" }).format(at); + return t`All set. I’ll check in on ${weekday} at ${time}.`; +} + +/** The message with onboarding notes as plain text, so bubbles, copy and speech read the same. */ +export function withIntakeNoteText(message: ThreadMessage): ThreadMessage { + if (!message.blocks.some((block) => block.kind === "intake_note")) return message; + return { + ...message, + blocks: message.blocks.map((block) => + block.kind === "intake_note" ? { kind: "text", text: intakeNoteText(block) } : block, + ), + }; +} + +/** + * A conversation's preview in the user's language. An onboarding card reads as its question, or + * as the answer once given; the closing note as the note. A dismissed card shows nothing in the + * conversation, so it keeps the server preview. + */ +export function localizedPreview(chat: Pick): string { + const block = chat.previewBlock; + if (!block) return chat.preview; + if (block.kind === "intake_note") return intakeNoteText(block); + const card = localizedChoice(block); + if (!card.answerId) return card.question; + return chosenOption(card)?.label ?? chat.preview; +} 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/routine-name.test.ts b/apps/web/src/lib/routine-name.test.ts new file mode 100644 index 0000000..b7a4d1f --- /dev/null +++ b/apps/web/src/lib/routine-name.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from "vitest"; +import { routineName, routineSaveName } from "./routine-name.js"; + +const labels = vi.hoisted(() => ({ locale: "en" })); +vi.mock("@lingui/core/macro", () => ({ + // Stands in for a German catalog when the test switches the locale. + t: (parts: TemplateStringsArray) => + labels.locale === "de" && parts.join("") === "Evening check-in" + ? "Abend-Check-in" + : parts.join(""), +})); + +describe("check-in routine names", () => { + it("shows a check-in under its own label until the user renames it", () => { + labels.locale = "de"; + expect(routineName({ name: "Evening check-in", coachKey: "evening" })).toBe("Abend-Check-in"); + expect(routineName({ name: "Wind down", coachKey: "evening" })).toBe("Wind down"); + expect(routineName({ name: "Evening check-in", coachKey: null })).toBe("Evening check-in"); + }); + + it("saves the stored name when the shown one was left as it is", () => { + labels.locale = "de"; + const routine = { name: "Evening check-in", coachKey: "evening" as const }; + expect(routineSaveName(routine, "Abend-Check-in")).toBe("Evening check-in"); + expect(routineSaveName(routine, "Wind down")).toBe("Wind down"); + expect(routineSaveName(null, "New")).toBe("New"); + }); +}); diff --git a/apps/web/src/lib/routine-name.ts b/apps/web/src/lib/routine-name.ts new file mode 100644 index 0000000..434b1c9 --- /dev/null +++ b/apps/web/src/lib/routine-name.ts @@ -0,0 +1,27 @@ +import { t } from "@lingui/core/macro"; +import type { CoachRoutineKey, Routine } from "@rakazo/contracts"; +import { COACH_ROUTINE_NAMES } from "@rakazo/contracts"; + +const COACH_ROUTINE_LABELS: Record string> = { + morning: () => t`Morning brief`, + evening: () => t`Evening check-in`, + weekly: () => t`Weekly review`, +}; + +/** + * A check-in routine is stored under its English name; the user sees it in their language + * until they rename it. + */ +export function routineName(routine: Pick): string { + return routine.coachKey && routine.name === COACH_ROUTINE_NAMES[routine.coachKey] + ? COACH_ROUTINE_LABELS[routine.coachKey]() + : routine.name; +} + +/** The name to save: a check-in name the user left as shown keeps its stored form. */ +export function routineSaveName( + routine: Pick | null, + draftName: string, +): string { + return routine && draftName === routineName(routine) ? routine.name : draftName; +} diff --git a/apps/web/src/lib/user-profile-view.test.ts b/apps/web/src/lib/user-profile-view.test.ts index 0843590..0f774c6 100644 --- a/apps/web/src/lib/user-profile-view.test.ts +++ b/apps/web/src/lib/user-profile-view.test.ts @@ -3,8 +3,12 @@ import { describe, expect, it } from "vitest"; import { displayLanguageTag, displayRegionTag, + factSpaceNames, filledProfileFields, groupUserFacts, + toggleFactSpace, + userFactDraft, + userFactEdit, } from "./user-profile-view"; function fact(overrides: Partial & Pick): UserFact { @@ -74,3 +78,43 @@ describe("user profile view", () => { expect(displayRegionTag("not a tag", "en")).toBe("not a tag"); }); }); + +describe("user fact editing", () => { + const tea = fact({ id: "tea", value: "Prefers tea", category: "preferences" }); + const limited = fact({ id: "lin", allSpaces: false, spaceIds: ["space-b", "space-a"] }); + + it("sends only what changed, or nothing", () => { + expect(userFactEdit(tea, userFactDraft(tea))).toBeNull(); + expect(userFactEdit(tea, { ...userFactDraft(tea), value: " Prefers tea " })).toBeNull(); + expect( + userFactEdit(limited, { ...userFactDraft(limited), spaceIds: ["space-a", "space-b"] }), + ).toBeNull(); + expect( + userFactEdit(tea, { value: " Prefers green tea ", category: "habits", spaceIds: null }), + ).toEqual({ id: "tea", value: "Prefers green tea", category: "habits" }); + expect(userFactEdit(tea, { ...userFactDraft(tea), spaceIds: ["space-a"] })).toEqual({ + id: "tea", + spaceIds: ["space-a"], + }); + expect(userFactEdit(limited, { ...userFactDraft(limited), spaceIds: null })).toEqual({ + id: "lin", + spaceIds: null, + }); + }); + + it("limits a fact to checked spaces and shows it everywhere when none is left", () => { + expect(toggleFactSpace(null, "space-a", true)).toEqual(["space-a"]); + expect(toggleFactSpace(["space-a"], "space-b", true)).toEqual(["space-a", "space-b"]); + expect(toggleFactSpace(["space-a", "space-b"], "space-a", false)).toEqual(["space-b"]); + expect(toggleFactSpace(["space-a"], "space-a", false)).toBeNull(); + }); + + it("names the spaces of a limited fact", () => { + const spaces = [ + { id: "space-a", name: "Home" }, + { id: "space-b", name: "Side project" }, + ]; + expect(factSpaceNames(["space-b", "gone"], spaces)).toEqual(["Side project"]); + expect(factSpaceNames(null, spaces)).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/user-profile-view.ts b/apps/web/src/lib/user-profile-view.ts index 97877dc..623e9be 100644 --- a/apps/web/src/lib/user-profile-view.ts +++ b/apps/web/src/lib/user-profile-view.ts @@ -1,6 +1,15 @@ -import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; +import type { UpdateUserProfile, UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; +/** What the fact editor holds: null spaces means every space. */ +export interface UserFactDraft { + value: string; + category: UserFactCategory; + spaceIds: string[] | null; +} + +type UserFactEdit = NonNullable[number]; + export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary"; const PROFILE_FIELDS: ProfileField[] = [ @@ -75,3 +84,49 @@ export function displayRegionTag(tag: string, uiLocale: string): string { return tag; } } + +export function userFactDraft(fact: UserFact): UserFactDraft { + return { + value: fact.value, + category: fact.category, + spaceIds: fact.allSpaces ? null : fact.spaceIds, + }; +} + +/** The `editFacts` entry for what changed in the draft, or null when nothing did. */ +export function userFactEdit(fact: UserFact, draft: UserFactDraft): UserFactEdit | null { + const edit: UserFactEdit = { id: fact.id }; + const value = draft.value.trim(); + if (value !== fact.value) edit.value = value; + if (draft.category !== fact.category) edit.category = draft.category; + const before = userFactDraft(fact).spaceIds; + const sameSpaces = + before === null || draft.spaceIds === null + ? before === draft.spaceIds + : before.length === draft.spaceIds.length && + draft.spaceIds.every((spaceId) => before.includes(spaceId)); + if (!sameSpaces) edit.spaceIds = draft.spaceIds; + return Object.keys(edit).length > 1 ? edit : null; +} + +/** Checks or unchecks one space; unchecking the last one shows the fact everywhere again. */ +export function toggleFactSpace( + spaceIds: string[] | null, + spaceId: string, + checked: boolean, +): string[] | null { + const current = spaceIds ?? []; + const next = checked + ? [...current.filter((id) => id !== spaceId), spaceId] + : current.filter((id) => id !== spaceId); + return next.length > 0 ? next : null; +} + +/** Names of the spaces a limited fact shows in, in the order of `spaces`; unknown ids are skipped. */ +export function factSpaceNames( + spaceIds: string[] | null, + spaces: ReadonlyArray<{ id: string; name: string }>, +): string[] { + if (!spaceIds) return []; + return spaces.filter((space) => spaceIds.includes(space.id)).map((space) => space.name); +} diff --git a/apps/web/src/lib/web-push.ts b/apps/web/src/lib/web-push.ts new file mode 100644 index 0000000..2dd999d --- /dev/null +++ b/apps/web/src/lib/web-push.ts @@ -0,0 +1,102 @@ +import { desktopBridge } from "./desktop"; +import { rpc } from "./rpc"; + +const SERVICE_WORKER_URL = "/sw.js"; +const SERVICE_WORKER_SCOPE = "/"; + +/** + * Web push needs the Push API, a service worker and a secure context (https or localhost). The + * desktop app keeps its in-app notifications instead. + */ +export function webPushSupported(): boolean { + return ( + typeof window !== "undefined" && + window.isSecureContext && + "serviceWorker" in navigator && + "PushManager" in window && + "Notification" in window && + !desktopBridge() + ); +} + +/** Registers the push service worker where web push works; safe to call on every start. */ +export async function registerPushServiceWorker(): Promise { + if (!webPushSupported()) return; + await navigator.serviceWorker + .register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }) + .catch(() => undefined); +} + +async function currentSubscription(): Promise { + const registration = await navigator.serviceWorker.getRegistration(SERVICE_WORKER_SCOPE); + return (await registration?.pushManager.getSubscription()) ?? null; +} + +/** True when this browser has granted notifications and holds a push subscription. */ +export async function webPushEnabled(): Promise { + if (!webPushSupported() || Notification.permission !== "granted") return false; + return Boolean(await currentSubscription().catch(() => null)); +} + +function base64UrlToBytes(value: string): Uint8Array { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const binary = atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "=")); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; +} + +function sameKey(current: ArrayBuffer | null, expected: Uint8Array): boolean { + if (!current || current.byteLength !== expected.byteLength) return false; + const bytes = new Uint8Array(current); + return bytes.every((byte, index) => byte === expected[index]); +} + +/** + * Turns on push for this browser and registers it with the server. Call it straight from a click: + * browsers only show the permission prompt during a user gesture. Throws when the server cannot + * be reached. + */ +export async function enableWebPush(): Promise<"enabled" | "denied" | "unsupported"> { + if (!webPushSupported()) return "unsupported"; + const permission = + Notification.permission === "default" + ? await Notification.requestPermission() + : Notification.permission; + if (permission !== "granted") return "denied"; + const config = await rpc.notifications.webPush.config(); + if (!config.available || !config.publicKey) return "unsupported"; + const applicationServerKey = base64UrlToBytes(config.publicKey); + let subscription: PushSubscription; + try { + await navigator.serviceWorker.register(SERVICE_WORKER_URL, { scope: SERVICE_WORKER_SCOPE }); + const registration = await navigator.serviceWorker.ready; + const existing = await registration.pushManager.getSubscription(); + // A subscription made for other server keys cannot receive this server's pushes. + if (existing && !sameKey(existing.options.applicationServerKey, applicationServerKey)) { + await existing.unsubscribe(); + } + subscription = + existing && sameKey(existing.options.applicationServerKey, applicationServerKey) + ? existing + : await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey }); + } catch { + // Browsers without a push service (or with it switched off) reject the subscription. + return "unsupported"; + } + const keys = subscription.toJSON().keys ?? {}; + await rpc.notifications.webPush.subscribe({ + endpoint: subscription.endpoint, + keys: { p256dh: keys.p256dh ?? "", auth: keys.auth ?? "" }, + }); + return "enabled"; +} + +/** Stops push for this browser, on the server first so no push arrives after it returns. */ +export async function disableWebPush(): Promise { + if (!webPushSupported()) return; + const subscription = await currentSubscription(); + if (!subscription) return; + await rpc.notifications.webPush.unsubscribe({ endpoint: subscription.endpoint }); + await subscription.unsubscribe().catch(() => undefined); +} diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index 0c9ed59..234a25f 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -21,6 +21,11 @@ msgstr " (ungelesen)" msgid "(optional)" msgstr "(optional)" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "{0, plural, one {# Tag} other {# Tage}}" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# Modell} other {# Modelle}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "{0, plural, one {# weiteres wartet} other {# weitere warten}}" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "{0, plural, one {# Woche} other {# Wochen}}" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "1:1-Unterhaltung" msgid "1:1 conversation with {title}" msgstr "1:1-Unterhaltung mit {title}" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "Ein paar kurze Vorlieben, damit dein Oo besser zu dir passt." - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "Über Gruppen" @@ -350,6 +356,7 @@ msgstr "Aktives Modell" msgid "Activity" msgstr "Aktivität" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Gib eine HTTPS-Server-URL ein." msgid "Add another" msgstr "Weiteres hinzufügen" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "Als Projekte hinzufügen" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor hinzufügen" @@ -428,6 +439,10 @@ msgstr "Server hinzufügen" msgid "Add server URL" msgstr "Server-URL hinzufügen" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "Zu „Über dich“ hinzufügen" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Zur Routine hinzufügen" @@ -494,6 +509,26 @@ msgstr "Alle Daten lokal gespeichert — keine externen Server" msgid "All quiet" msgstr "Alles ruhig" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "Alles klar. Ich melde mich am {weekday} um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "Alles klar. Ich melde mich heute um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "Alles klar. Ich melde mich morgen um {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "Alles klar. Ich bin da, wann immer du mich brauchst." + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "Alle Spaces" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Autorisieren" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autorisiere diesen Server, damit Agents seine Tools nutzen können. Ein Pop-up öffnet sich." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "Automatisch erkannt oder manuell eingeben" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Automatische Absender" @@ -740,10 +771,6 @@ msgstr "Zurück zur Anmeldung" msgid "Back to timeline" msgstr "Zurück zur Zeitleiste" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "Ausgewogen" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Basis-URL" @@ -753,6 +780,10 @@ msgstr "Basis-URL" msgid "Bearer token" msgstr "Bearer-Token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "In deinen Browser-Einstellungen blockiert" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Live-Desktop wird gestartet…" @@ -821,6 +852,10 @@ msgstr "Ruhige Hinweistöne für erledigte Arbeit, Sprachphasen und Übergaben. msgid "Calm helper" msgstr "Ruhiger Helfer" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "Darf ich dir Benachrichtigungen schicken?" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "Kann sprechen" @@ -833,6 +868,7 @@ msgstr "Server nicht erreichbar." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,9 +903,9 @@ msgstr "Abgebrochen" msgid "Cancelled" msgstr "Abgebrochen" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" -msgstr "Locker & freundlich" +#: src/components/UserFactEditor.tsx +msgid "Category" +msgstr "Kategorie" #: src/pages/AccountSettingsOverlay.tsx msgid "Change password" @@ -925,6 +961,10 @@ msgstr "Melde dich." msgid "Check your email" msgstr "Prüfe deine E-Mails" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "Check-ins" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "Prüfe Abhängigkeiten…" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Befehl" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "Kommunikationsstil" @@ -1295,8 +1334,8 @@ msgstr "Kontextlimit" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Weiter" @@ -1522,6 +1561,10 @@ msgstr "Update-Status konnte nicht geladen werden" msgid "Could not load voice settings" msgstr "Stimmeinstellungen konnten nicht geladen werden" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "In diesem Ordner konnte nicht gesucht werden." + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Lokale Einstellungen konnten nicht geöffnet werden. Starte den lokalen Server, erstelle sein Besitzerkonto und versuche es dann erneut." @@ -1674,6 +1717,7 @@ msgstr "Standardbereich für Erinnerungen konnte nicht aktualisiert werden" msgid "Could not update voice" msgstr "Stimme konnte nicht aktualisiert werden" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "Konnte nicht geladen werden" @@ -1682,6 +1726,7 @@ msgstr "Konnte nicht geladen werden" msgid "Couldn't load messaging settings" msgstr "Messaging-Einstellungen konnten nicht geladen werden" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "Konnte nicht gespeichert werden" @@ -1823,6 +1868,10 @@ msgstr "Datenbank" msgid "day" msgstr "Tag" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "Tagesgeschäft" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "Tage" @@ -1831,10 +1880,6 @@ msgstr "Tage" msgid "Decline" msgstr "Ablehnen" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "Fokussiert in die Tiefe" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Standard" @@ -1935,18 +1980,10 @@ msgstr "Desktop-App" msgid "Desktop update" msgstr "Desktop-Update" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "Ausführlich & erklärend" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "Entwicklertools" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "Direkt & knapp" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Direktes MCP" @@ -2066,6 +2103,10 @@ msgstr "z. B. Hivenet Agent, Presales, Zeiterfassungs-Bot" msgid "Earlier message" msgstr "Frühere Nachricht" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "Bearbeiten" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Zuerst bearbeiten" @@ -2115,6 +2156,14 @@ msgstr "Gib einen absoluten Ordnerpfad auf dem Server ein. Git-Remotes werden au msgid "Enter this code at <0>{0}" msgstr "Gib diesen Code unter <0>{0} ein" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "Abends" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "Abend-Check-in" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Jeden Montag" msgid "Every month" msgstr "Jeden Monat" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "Jeden Morgen" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "Externe Unterhaltung" msgid "Extra high" msgstr "Sehr hoch" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "Fakt" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "fehlgeschlagen" @@ -2256,10 +2313,6 @@ msgstr "Erste {shownRows} Zeilen" msgid "Flag unexpected actions" msgstr "Unerwartete Aktionen markieren" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "Flexibel & spontan" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "Schriften lokal geladen" @@ -2324,6 +2377,7 @@ msgstr "Git-Ereignis" msgid "Goal" msgstr "Ziel" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "Updates bündeln" msgid "Groups" msgstr "Gruppen" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "Gewohnheiten" @@ -2434,10 +2489,6 @@ msgstr "Passwort verbergen" msgid "High" msgstr "Hoch" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "Hobbys & Interessen" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "Hobby" @@ -2451,10 +2502,6 @@ msgstr "Stunde" msgid "hours" msgstr "Stunden" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "Wie arbeitest du am liebsten?" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Wie oft" @@ -2463,10 +2510,6 @@ msgstr "Wie oft" msgid "How should your Oo sound when reading replies aloud?" msgstr "Wie soll dein Oo klingen, wenn es Antworten vorliest?" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "Wie soll dein Oo mit dir sprechen?" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "So wird geprüft" @@ -2507,6 +2550,10 @@ msgstr "In meinem Fachgebiet — ich gehe gern ins Detail oder setze einfach um, msgid "In progress" msgstr "In Arbeit" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "Posteingang & E-Mail" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Standard übernehmen" @@ -2730,6 +2777,10 @@ msgstr "Einstellungen des lokalen Servers" msgid "Log out" msgstr "Abmelden" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "Suchen" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "Look {0} von {1}" msgid "Look families" msgstr "Look-Familien" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "Hier nach Projekten suchen?" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "Look-Varianten" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "Suche läuft" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "Minute" msgid "minutes" msgstr "Minuten" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "Mission Control" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "Weitere Computeraktionen" msgid "More conversations ({hiddenSessionCount})" msgstr "Mehr Unterhaltungen ({hiddenSessionCount})" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "Morgens" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "Morgens und abends" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "Morgenbriefing" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Verschiebe sie in deine geteilten Erinnerungen." @@ -2975,7 +3042,6 @@ msgstr "In Ordner verschieben" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "Noch keine Nachrichten mit {peerBotName}." msgid "No model catalog is available." msgstr "Kein Modellkatalog verfügbar." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "Keine Projekte gefunden" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "Noch keine Projekte." @@ -3211,6 +3281,7 @@ msgstr "Nicht verbunden" msgid "Not in the plugin catalog" msgstr "Nicht im Plugin-Katalog" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Jetzt nicht" @@ -3223,14 +3294,11 @@ msgstr "Nichts läuft mehr" msgid "Nothing remembered yet" msgstr "Noch nichts gemerkt" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "Nichts Bestimmtes" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "Noch nichts" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Benachrichtigungen" @@ -3244,6 +3312,10 @@ msgstr "Jetzt" msgid "Now using {0}." msgstr "Jetzt wird {0} verwendet." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "Anstöße von deinem Oo" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth-Autorisierung wurde abgebrochen." @@ -3277,6 +3349,10 @@ msgstr "am 1. um {0}" msgid "Onboarding" msgstr "Onboarding" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "Einmal pro Woche" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "Noch ein Blick, bevor ich antworte…" @@ -3285,6 +3361,10 @@ msgstr "Noch ein Blick, bevor ich antworte…" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Nur leere Spaces können gelöscht werden. Lösche zuerst die Bots und Gruppen darin." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "Gelesen werden nur Datei- und Ordnernamen." + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Anbieter" msgid "Pull request" msgstr "Pull Request" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "Push auf diesem Gerät" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "In Warteschlange" msgid "Queued…" msgstr "Wartet…" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "Schnell & pragmatisch" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "Bisher ein ruhiger Tag" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "Ruhezeiten" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "Ende der Ruhezeiten" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "Beginn der Ruhezeiten" + #: src/pages/Shell.tsx msgid "Quote" msgstr "Zitieren" @@ -3773,6 +3865,10 @@ msgstr "Antwort an {replyName}: „{replyQuote}“" msgid "Reporting from home…" msgstr "Melde mich von zu Hause…" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "Recherche & Schreiben" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "Recherche, Schreiben" @@ -3912,6 +4008,7 @@ msgstr "Sandbox" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Suche…" msgid "Select {0}" msgstr "{0} auswählen" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "Wähle alles Zutreffende aus" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "Selbst gehostet (better-auth)" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Senden" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Skills" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Überspringen" @@ -4269,6 +4365,10 @@ msgstr "Vorlesen an oder aus" msgid "Speaking with intent…" msgstr "Spreche mit Bedacht…" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "Spezialisten-Team" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "Standard" @@ -4362,10 +4462,6 @@ msgstr "Stresstest fürs Portfolio…" msgid "Stronger together — {0} specialists" msgstr "Stärker zusammen — {0} Spezialisten" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "Strukturiert & geplant" - #: src/pages/Shell.tsx msgid "subagent" msgstr "Subagent" @@ -4451,10 +4547,6 @@ msgstr "Teams nicht aktiviert" msgid "Tell me what matters. I'll move." msgstr "Sag mir, was zählt. Ich lege los." -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "Erzähl uns von dir" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Test" @@ -4545,10 +4637,6 @@ msgstr "Uhrzeit" msgid "Time zone" msgstr "Zeitzone" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "Zeitzone" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Titel" @@ -4603,6 +4691,10 @@ msgstr "Triage läuft…" msgid "Try again" msgstr "Erneut versuchen" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "Einschalten" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Gib deine Antwort ein" @@ -4796,6 +4888,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Wochentage" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "Wöchentlich" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "Wochenrückblick" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Was soll mit seinen Erinnerungen geschehen?" @@ -4804,6 +4904,10 @@ msgstr "Was soll mit seinen Erinnerungen geschehen?" msgid "What do you think — react or answer in a sentence." msgstr "Was meinst du — reagiere oder antworte in einem Satz." +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "Wobei soll ich dir zuerst helfen?" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "Was passt zu dir?" @@ -4844,6 +4948,10 @@ msgstr "Was zurückgegeben werden soll" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "Was beschäftigt dich? Ich leite es weiter — und sage dir, wenn ich anderer Meinung bin." +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "Was möchtest du in den nächsten Wochen erledigt bekommen?" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "Was ist heute privat dran?" @@ -4856,6 +4964,14 @@ msgstr "Um welche Aufgabe geht es?" msgid "When a webhook fires" msgstr "Wenn ein Webhook auslöst" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "Wann soll ich mich bei dir melden?" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "Wann soll ich still sein?" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Ausführungszeit" @@ -4881,10 +4997,6 @@ msgstr "Arbeit" msgid "Work channel open — go ahead." msgstr "Arbeitskanal offen — leg los." -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "Arbeitsstil" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "Arbeitet" @@ -4941,8 +5053,11 @@ msgstr "Deine Unterhaltungen und Einstellungen verlassen nie deinen Rechner" msgid "Your email address" msgstr "Deine E-Mail-Adresse" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "Dein Ziel" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Dein Name" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index a075553..b030f40 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -21,6 +21,11 @@ msgstr " (unread)" msgid "(optional)" msgstr "(optional)" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "{0, plural, one {# day} other {# days}}" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# models}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "{0, plural, one {# more waiting} other {# more waiting}}" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "{0, plural, one {# week} other {# weeks}}" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "1:1 conversation" msgid "1:1 conversation with {title}" msgstr "1:1 conversation with {title}" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "A few quick preferences so your Oo can be better tailored to you." - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "About groups" @@ -350,6 +356,7 @@ msgstr "Active model" msgid "Activity" msgstr "Activity" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Add an HTTPS server URL." msgid "Add another" msgstr "Add another" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "Add as projects" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Add Executor" @@ -428,6 +439,10 @@ msgstr "Add server" msgid "Add server URL" msgstr "Add server URL" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "Add to About you" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Add to routine" @@ -494,6 +509,26 @@ msgstr "All data stored locally — no external servers" msgid "All quiet" msgstr "All quiet" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "All set. I’ll check in on {weekday} at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "All set. I’ll check in today at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "All set. I’ll check in tomorrow at {time}." + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "All set. I’m here whenever you need me." + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "All spaces" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Authorize" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Authorize this server so agents can use its tools. A popup opens." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "Auto-detected or enter manually" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Automated senders" @@ -740,10 +771,6 @@ msgstr "Back to sign in" msgid "Back to timeline" msgstr "Back to timeline" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "Balanced" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Base URL" @@ -753,6 +780,10 @@ msgstr "Base URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "Blocked in your browser settings" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Booting live desktop…" @@ -821,6 +852,10 @@ msgstr "Calm audio cues for finished work, voice phases, and handoffs. Respects msgid "Calm helper" msgstr "Calm helper" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "Can I send you notifications?" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "Can speak" @@ -833,6 +868,7 @@ msgstr "Can't reach the server." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,9 +903,9 @@ msgstr "Cancelled" msgid "Cancelled" msgstr "Cancelled" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" -msgstr "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" +msgstr "Category" #: src/pages/AccountSettingsOverlay.tsx msgid "Change password" @@ -925,6 +961,10 @@ msgstr "Check in." msgid "Check your email" msgstr "Check your email" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "Check-ins" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "Checking dependencies…" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Command" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "Communication style" @@ -1295,8 +1334,8 @@ msgstr "Context limit" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continue" @@ -1522,6 +1561,10 @@ msgstr "Could not load update status" msgid "Could not load voice settings" msgstr "Could not load voice settings" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "Could not look in this folder." + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Could not open local settings. Start the local server and create its owner account, then try again." @@ -1674,6 +1717,7 @@ msgstr "Could not update the default memory scope" msgid "Could not update voice" msgstr "Could not update voice" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "Couldn't load" @@ -1682,6 +1726,7 @@ msgstr "Couldn't load" msgid "Couldn't load messaging settings" msgstr "Couldn't load messaging settings" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "Couldn't save" @@ -1823,6 +1868,10 @@ msgstr "Database" msgid "day" msgstr "day" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "Day-to-day work" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "days" @@ -1831,10 +1880,6 @@ msgstr "days" msgid "Decline" msgstr "Decline" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "Deep-dive focused" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Default" @@ -1935,18 +1980,10 @@ msgstr "Desktop app" msgid "Desktop update" msgstr "Desktop update" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "Detailed & explanatory" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "Developer tools" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "Direct & concise" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Direct MCP" @@ -2066,6 +2103,10 @@ msgstr "e.g. Hivenet Agent, Presales, Timesheets bot" msgid "Earlier message" msgstr "Earlier message" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "Edit" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Edit first" @@ -2115,6 +2156,14 @@ msgstr "Enter an absolute folder path on the server. Git remotes are linked auto msgid "Enter this code at <0>{0}" msgstr "Enter this code at <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "Evening" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "Evening check-in" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Every Monday" msgid "Every month" msgstr "Every month" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "Every morning" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "External conversation" msgid "Extra high" msgstr "Extra high" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "Fact" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "failed" @@ -2256,10 +2313,6 @@ msgstr "First {shownRows} rows" msgid "Flag unexpected actions" msgstr "Flag unexpected actions" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "Flexible & spontaneous" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "Fonts loaded locally" @@ -2324,6 +2377,7 @@ msgstr "Git event" msgid "Goal" msgstr "Goal" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "Group updates" msgid "Groups" msgstr "Groups" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "Habits" @@ -2434,10 +2489,6 @@ msgstr "Hide password" msgid "High" msgstr "High" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "Hobbies & interests" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "Hobby" @@ -2451,10 +2502,6 @@ msgstr "hour" msgid "hours" msgstr "hours" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "How do you like to work?" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "How often" @@ -2463,10 +2510,6 @@ msgstr "How often" msgid "How should your Oo sound when reading replies aloud?" msgstr "How should your Oo sound when reading replies aloud?" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "How should your Oo talk to you?" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "How to check" @@ -2507,6 +2550,10 @@ msgstr "In my lane — happy to geek out or just execute, your call." msgid "In progress" msgstr "In progress" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "Inbox & email" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Inherit default" @@ -2730,6 +2777,10 @@ msgstr "Local Server Settings" msgid "Log out" msgstr "Log out" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "Look" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "Look {0} of {1}" msgid "Look families" msgstr "Look families" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "Look for projects here?" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "Look variants" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "Looking" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "minute" msgid "minutes" msgstr "minutes" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "Mission Control" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "More computer actions" msgid "More conversations ({hiddenSessionCount})" msgstr "More conversations ({hiddenSessionCount})" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "Morning" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "Morning and evening" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "Morning brief" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Move them to your shared memory." @@ -2975,7 +3042,6 @@ msgstr "Move to folder" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "No messages with {peerBotName} yet." msgid "No model catalog is available." msgstr "No model catalog is available." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "No projects found" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "No projects yet." @@ -3211,6 +3281,7 @@ msgstr "Not connected" msgid "Not in the plugin catalog" msgstr "Not in the plugin catalog" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Not now" @@ -3223,14 +3294,11 @@ msgstr "Nothing is still running" msgid "Nothing remembered yet" msgstr "Nothing remembered yet" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "Nothing specific" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "Nothing yet" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notifications" @@ -3244,6 +3312,10 @@ msgstr "Now" msgid "Now using {0}." msgstr "Now using {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "Nudges from your Oo" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth authorization was cancelled." @@ -3277,6 +3349,10 @@ msgstr "on the 1st at {0}" msgid "Onboarding" msgstr "Onboarding" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "Once a week" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "One more look before I answer…" @@ -3285,6 +3361,10 @@ msgstr "One more look before I answer…" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Only empty spaces can be deleted. Delete its bots and groups first." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "Only file and folder names are read." + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Providers" msgid "Pull request" msgstr "Pull request" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "Push on this device" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "Queued" msgid "Queued…" msgstr "Queued…" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "Quick & pragmatic" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "Quiet day so far" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "Quiet hours" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "Quiet hours end" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "Quiet hours start" + #: src/pages/Shell.tsx msgid "Quote" msgstr "Quote" @@ -3773,6 +3865,10 @@ msgstr "Replying to {replyName}: “{replyQuote}”" msgid "Reporting from home…" msgstr "Reporting from home…" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "Research & writing" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "research, writing" @@ -3912,6 +4008,7 @@ msgstr "Sandbox" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Searching…" msgid "Select {0}" msgstr "Select {0}" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "Select all that apply" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "Self-hosted (better-auth)" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Send" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Skills" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Skip" @@ -4269,6 +4365,10 @@ msgstr "Speaking on or off" msgid "Speaking with intent…" msgstr "Speaking with intent…" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "Specialist crew" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "Standard" @@ -4362,10 +4462,6 @@ msgstr "Stress-testing the book…" msgid "Stronger together — {0} specialists" msgstr "Stronger together — {0} specialists" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "Structured & planned" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagent" @@ -4451,10 +4547,6 @@ msgstr "Teams not enabled" msgid "Tell me what matters. I'll move." msgstr "Tell me what matters. I'll move." -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "Tell us about yourself" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Test" @@ -4545,10 +4637,6 @@ msgstr "Time of day" msgid "Time zone" msgstr "Time zone" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "Timezone" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Title" @@ -4603,6 +4691,10 @@ msgstr "Triage in progress…" msgid "Try again" msgstr "Try again" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "Turn on" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Type your answer" @@ -4796,6 +4888,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Weekdays" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "Weekly" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "Weekly review" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "What about its memories?" @@ -4804,6 +4904,10 @@ msgstr "What about its memories?" msgid "What do you think — react or answer in a sentence." msgstr "What do you think — react or answer in a sentence." +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "What do you want me on first?" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "What feels like you?" @@ -4844,6 +4948,10 @@ msgstr "What to return" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "What's on your mind? I'll route it — and tell you when I disagree." +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "What’s one thing you want to get done in the next few weeks?" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "What's personal today?" @@ -4856,6 +4964,14 @@ msgstr "What's the work item?" msgid "When a webhook fires" msgstr "When a webhook fires" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "When should I check in with you?" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "When should I stay quiet?" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "When to run" @@ -4881,10 +4997,6 @@ msgstr "Work" msgid "Work channel open — go ahead." msgstr "Work channel open — go ahead." -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "Work style" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "Working" @@ -4941,8 +5053,11 @@ msgstr "Your conversations and settings never leave your machine" msgid "Your email address" msgstr "Your email address" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "Your goal" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Your name" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 3c6e5b7..4adafbc 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -21,6 +21,11 @@ msgstr " (sin leer)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "Modelo activo" msgid "Activity" msgstr "Actividad" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Agrega una URL de servidor HTTPS." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Agregar Executor" @@ -428,6 +439,10 @@ msgstr "Agregar servidor" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Agregar a la rutina" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Autorizar" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autoriza este servidor para que tus agentes usen sus herramientas. Se abre una ventana emergente." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "Volver a iniciar sesión" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "URL base" @@ -753,6 +780,10 @@ msgstr "URL base" msgid "Bearer token" msgstr "Token Bearer" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Iniciando escritorio en vivo…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "No se puede alcanzar el servidor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "Cancelado" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "Regístrate." msgid "Check your email" msgstr "Revisa tu correo" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Comando" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "Límite de contexto" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continuar" @@ -1522,6 +1561,10 @@ msgstr "No se pudo cargar el estado de actualización" msgid "Could not load voice settings" msgstr "No se pudo cargar la configuración de voz" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "No se pudo actualizar el alcance de memoria predeterminado" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "No se pudo cargar la configuración de mensajería" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "día" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "días" @@ -1831,10 +1880,6 @@ msgstr "días" msgid "Decline" msgstr "Rechazar" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Predeterminado" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "p. ej. Hivenet Agent, Presales, bot de hojas de horas" msgid "Earlier message" msgstr "Mensaje anterior" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Editar primero" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Ingresa este código en <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Cada lunes" msgid "Every month" msgstr "Cada mes" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "Extra alto" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "fallido" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "Marcar acciones inesperadas" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "Evento de Git" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "Ocultar contraseña" msgid "High" msgstr "Alto" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "hora" msgid "hours" msgstr "horas" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Con qué frecuencia" @@ -2463,10 +2510,6 @@ msgstr "Con qué frecuencia" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Cómo comprobar" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Heredar predeterminado" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "Cerrar sesión" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "minuto" msgid "minutes" msgstr "minutos" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "Más acciones de computadora" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Muévelas a tu memoria compartida." @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "Aún no hay mensajes con {peerBotName}." msgid "No model catalog is available." msgstr "No hay un catálogo de modelos disponible." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "No conectado" msgid "Not in the plugin catalog" msgstr "No está en el catálogo de plugins" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Ahora no" @@ -3223,14 +3294,11 @@ msgstr "No queda nada en ejecución" msgid "Nothing remembered yet" msgstr "Aún no hay recuerdos" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notificaciones" @@ -3244,6 +3312,10 @@ msgstr "Ahora" msgid "Now using {0}." msgstr "Ahora usando {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "Se canceló la autorización OAuth." @@ -3277,6 +3349,10 @@ msgstr "el día 1 a las {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Proveedores" msgid "Pull request" msgstr "Solicitud de incorporación" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "En cola" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Buscando…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Enviar" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Habilidades" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Omitir" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagente" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Probar" @@ -4545,10 +4637,6 @@ msgstr "Hora del día" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Título" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "Intentarlo de nuevo" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Escribe tu respuesta" @@ -4796,6 +4888,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "Días laborables" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "¿Qué hay de sus memorias?" @@ -4804,6 +4904,10 @@ msgstr "¿Qué hay de sus memorias?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "Qué devolver" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "Cuando se dispara un webhook" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Cuándo ejecutar" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "Tu dirección de correo" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Tu nombre" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 82d7601..e2f2e54 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -21,6 +21,11 @@ msgstr " (अपठित)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# मॉडल} other {# मॉडल}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "सक्रिय मॉडल" msgid "Activity" msgstr "गतिविधि" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "एक HTTPS सर्वर URL दर्ज करें।" msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor जोड़ें" @@ -428,6 +439,10 @@ msgstr "सर्वर जोड़ें" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "रूटीन में जोड़ें" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "ऑथराइज़ करें" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "एजेंटों को इसके टूल का उपयोग करने देने के लिए इस सर्वर को ऑथराइज़ करें। एक पॉपअप खुलता है।" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "साइन इन पर वापस जाएँ" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "बेस URL" @@ -753,6 +780,10 @@ msgstr "बेस URL" msgid "Bearer token" msgstr "बियरर टोकन" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "लाइव डेस्कटॉप शुरू हो रहा है…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "सर्वर तक नहीं पहुंचा जा सका #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "रद्द किया गया" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "जांच करें।" msgid "Check your email" msgstr "अपना ईमेल देखें" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "कमांड" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "कॉन्टेक्स्ट सीमा" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "जारी रखें" @@ -1522,6 +1561,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "आवाज़ सेटिंग्स लोड नहीं हो सकीं" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "डिफ़ॉल्ट मेमोरी स्कोप अपडे msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "दिन" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "दिन" @@ -1831,10 +1880,6 @@ msgstr "दिन" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "डिफ़ॉल्ट" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "उदा. Hivenet Agent, Presales, टाइमशीट बॉट" msgid "Earlier message" msgstr "पुराना संदेश" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "पहले संपादित करें" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "यह कोड <0>{0} पर दर्ज करें" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "हर सोमवार" msgid "Every month" msgstr "हर महीने" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "अतिरिक्त उच्च" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "असफल" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "" msgid "High" msgstr "उच्च" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "घंटा" msgid "hours" msgstr "घंटे" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "कितनी बार" @@ -2463,10 +2510,6 @@ msgstr "कितनी बार" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "जांच कैसे करें" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "डिफ़ॉल्ट का पालन करें" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "लॉग आउट" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "मिनट" msgid "minutes" msgstr "मिनट" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "उन्हें अपनी साझा मेमोरी में ले जाएं।" @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "{peerBotName} के साथ अभी कोई संदेश न msgid "No model catalog is available." msgstr "कोई मॉडल कैटलॉग उपलब्ध नहीं है।" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "कनेक्टेड नहीं" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "अभी नहीं" @@ -3223,14 +3294,11 @@ msgstr "कुछ भी नहीं चल रहा" msgid "Nothing remembered yet" msgstr "अभी तक कुछ याद नहीं किया गया" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "सूचनाएं" @@ -3244,6 +3312,10 @@ msgstr "अभी" msgid "Now using {0}." msgstr "अब {0} उपयोग में है।" +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth ऑथराइज़ेशन रद्द कर दिया गया।" @@ -3277,6 +3349,10 @@ msgstr "1 तारीख को {0} बजे" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "प्रोवाइडर्स" msgid "Pull request" msgstr "पुल अनुरोध" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "कतार में" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "खोजा जा रहा है…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "भेजें" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "स्किल्स" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "छोड़ें" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "सबएजेंट" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "टेस्ट करें" @@ -4545,10 +4637,6 @@ msgstr "दिन का समय" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "शीर्षक" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "फिर से कोशिश करें" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "अपना उत्तर लिखें" @@ -4796,6 +4888,14 @@ msgstr "" msgid "Weekdays" msgstr "कार्यदिवस" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "इसकी मेमोरी का क्या करें?" @@ -4804,6 +4904,10 @@ msgstr "इसकी मेमोरी का क्या करें?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "क्या लौटाना है" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "कब चलाएं" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "आपका ईमेल पता" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "आपका नाम" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index 42038d7..e7e2a0e 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -21,6 +21,11 @@ msgstr " (읽지 않음)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {모델 #개} other {모델 #개}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "현재 모델" msgid "Activity" msgstr "활동" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "HTTPS 서버 URL을 입력하세요." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor 추가" @@ -428,6 +439,10 @@ msgstr "서버 추가" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "자동 실행에 추가" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "인증하기" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "에이전트가 도구를 사용할 수 있도록 이 서버에 권한을 부여하세요. 팝업이 열립니다." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "로그인으로 돌아가기" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "서버 주소" @@ -753,6 +780,10 @@ msgstr "서버 주소" msgid "Bearer token" msgstr "Bearer 토큰" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "라이브 데스크톱 시작 중…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "서버에 연결할 수 없습니다." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "취소됨" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "확인해 주세요." msgid "Check your email" msgstr "이메일을 확인하세요" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "실행 명령" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "컨텍스트 한도" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "계속" @@ -1522,6 +1561,10 @@ msgstr "업데이트 상태를 불러올 수 없습니다" msgid "Could not load voice settings" msgstr "음성 설정을 불러오지 못했습니다." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "기본 기억 범위를 변경하지 못했습니다." msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "메시징 설정을 불러올 수 없습니다" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "일" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "일" @@ -1831,10 +1880,6 @@ msgstr "일" msgid "Decline" msgstr "거절" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "기본값" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "예: Hivenet Agent, Presales, 근무표 봇" msgid "Earlier message" msgstr "이전 메시지" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "수정 후 보내기" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "<0>{0}에서 이 코드를 입력하세요" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "매주 월요일" msgid "Every month" msgstr "매월" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "매우 높음" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "실패" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "예상하지 못한 작업 표시" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "Git 이벤트" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "비밀번호 숨기기" msgid "High" msgstr "높음" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "시간" msgid "hours" msgstr "시간" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "실행 주기" @@ -2463,10 +2510,6 @@ msgstr "실행 주기" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "완료 확인 방법" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "기본 설정 사용" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "로그아웃" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "분" msgid "minutes" msgstr "분" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "컴퓨터 작업 더보기" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "공용 기억으로 옮깁니다." @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "{peerBotName}와의 메시지가 아직 없습니다." msgid "No model catalog is available." msgstr "사용 가능한 모델 목록이 없습니다." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "연결되지 않음" msgid "Not in the plugin catalog" msgstr "플러그인 카탈로그에 없음" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "나중에" @@ -3223,14 +3294,11 @@ msgstr "실행 중인 작업 없음" msgid "Nothing remembered yet" msgstr "아직 기억한 내용이 없습니다" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "알림" @@ -3244,6 +3312,10 @@ msgstr "지금" msgid "Now using {0}." msgstr "이제 {0} 모델을 사용합니다." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth 인증이 취소되었습니다." @@ -3277,6 +3349,10 @@ msgstr "매월 1일 {0}에" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "서비스" msgid "Pull request" msgstr "풀 리퀘스트" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "대기 중" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "검색 중…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "보내기" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "스킬" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "건너뛰기" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "보조 에이전트" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "시험 실행" @@ -4545,10 +4637,6 @@ msgstr "실행 시각" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "역할" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "다시 시도" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "답변을 입력하세요" @@ -4796,6 +4888,14 @@ msgstr "웹훅" msgid "Weekdays" msgstr "주중" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "이 Bot의 기억은 어떻게 할까요?" @@ -4804,6 +4904,10 @@ msgstr "이 Bot의 기억은 어떻게 할까요?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "완료 후 전달할 결과" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "웹훅이 실행될 때" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "실행 시간" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "이메일 주소" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "이름" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 70b7fac..e3392d4 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -21,6 +21,11 @@ msgstr " (não lido)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# modelo} other {# modelos}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "Modelo ativo" msgid "Activity" msgstr "Atividade" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Adicione um URL do servidor HTTPS." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Adicionar Executor" @@ -428,6 +439,10 @@ msgstr "Adicionar servidor" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Adicionar à rotina" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Autorizar" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Autorize este servidor para que os agentes usem suas ferramentas. Uma janela pop-up será aberta." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "Voltar para o login" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "URL Base" @@ -753,6 +780,10 @@ msgstr "URL Base" msgid "Bearer token" msgstr "Token portador" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Iniciando a área de trabalho ao vivo..." @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "Não é possível acessar o servidor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "Cancelado" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "Acompanhamento" msgid "Check your email" msgstr "Confira seu e-mail" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Comando" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "Limite de contexto" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Continuar" @@ -1522,6 +1561,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "Não foi possível carregar as configurações de voz" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "Não foi possível atualizar o escopo de memória padrão" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "dia" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "dias" @@ -1831,10 +1880,6 @@ msgstr "dias" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Padrão" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "ex.: Hivenet Agent, Presales, bot de planilhas" msgid "Earlier message" msgstr "" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Editar primeiro" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Digite este código em <0>{0}" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Toda segunda-feira" msgid "Every month" msgstr "A cada mês" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "Extra alta" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "falhou" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "" msgid "High" msgstr "Alto" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "hora" msgid "hours" msgstr "horas" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Com que frequência" @@ -2463,10 +2510,6 @@ msgstr "Com que frequência" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Como verificar" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Herdar (padrão)" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "Sair" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "minuto" msgid "minutes" msgstr "minutos" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Mova-os para a sua memória compartilhada." @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "Ainda sem mensagens com {peerBotName}." msgid "No model catalog is available." msgstr "Nenhum catálogo de modelos está disponível." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "Não Conectado" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Agora não" @@ -3223,14 +3294,11 @@ msgstr "Nada ainda está em execução" msgid "Nothing remembered yet" msgstr "Nada memorizado ainda" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Notificações" @@ -3244,6 +3312,10 @@ msgstr "Agora" msgid "Now using {0}." msgstr "Agora usando {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "A autorização OAuth foi cancelada." @@ -3277,6 +3349,10 @@ msgstr "no dia 1º às {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Provedores" msgid "Pull request" msgstr "Solicitação de pull" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "Na fila" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Pesquisando…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Enviar" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Habilidades" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Pular" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "subagente" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Teste" @@ -4545,10 +4637,6 @@ msgstr "Hora do dia" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Título" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "Tentar novamente" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Digite sua resposta" @@ -4796,6 +4888,14 @@ msgstr "" msgid "Weekdays" msgstr "Dias da semana" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "E suas memórias?" @@ -4804,6 +4904,10 @@ msgstr "E suas memórias?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "O que devolver" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Quando executar" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "Seu endereço de e-mail" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "O seu nome" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 8e8c5f1..cd98928 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -21,6 +21,11 @@ msgstr " (непрочитано)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# модель} few {# модели} many {# модел msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "О группах" @@ -350,6 +356,7 @@ msgstr "Активная модель" msgid "Activity" msgstr "Активность" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Добавьте URL-адрес HTTPS-сервера." msgid "Add another" msgstr "Добавить еще" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Добавить Executor" @@ -428,6 +439,10 @@ msgstr "Добавить сервер" msgid "Add server URL" msgstr "Добавьте URL сервера" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Добавить в задачу" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Авторизовать" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "Автоматические отправители" @@ -740,10 +771,6 @@ msgstr "Вернуться ко входу" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Базовый URL" @@ -753,6 +780,10 @@ msgstr "Базовый URL" msgid "Bearer token" msgstr "Bearer-токен" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Загрузка рабочего стола…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "Не удаётся связаться с сервером." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "Отменено" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "Проверить." msgid "Check your email" msgstr "Проверьте свою электронную почту" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Команда" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Продолжить" @@ -1522,6 +1561,10 @@ msgstr "Не удалось загрузить статус обновления msgid "Could not load voice settings" msgstr "Не удалось загрузить голосовые настройки." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "Не удалось открыть локальные настройки. Запустите локальный сервер и создайте учетную запись владельца, затем повторите попытку." @@ -1674,6 +1717,7 @@ msgstr "Не удалось обновить область памяти по у msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "Не удалось загрузить настройки обмена сообщениями." +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "день" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "дни" @@ -1831,10 +1880,6 @@ msgstr "дни" msgid "Decline" msgstr "Отклонить" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "" @@ -1935,18 +1980,10 @@ msgstr "Настольное приложение" msgid "Desktop update" msgstr "Обновление настольного приложения" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "Прямой MCP" @@ -2066,6 +2103,10 @@ msgstr "напр. Hivenet Agent, Presales, бот табелей" msgid "Earlier message" msgstr "Предыдущее сообщение" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Сначала отредактируйте" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Введите этот код по адресу <0>{0}." +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Каждый понедельник" msgid "Every month" msgstr "Каждый месяц" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "Внешний диалог" msgid "Extra high" msgstr "Очень высокий" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "неуспешный" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "Пометить неожиданные действия" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "Git-событие" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "Обновления группы" msgid "Groups" msgstr "Группы" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "Скрыть пароль" msgid "High" msgstr "Высокий" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "час" msgid "hours" msgstr "часы" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Как часто" @@ -2463,10 +2510,6 @@ msgstr "Как часто" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Как проверить" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Наследовать значение по умолчанию" @@ -2730,6 +2777,10 @@ msgstr "Настройки локального сервера" msgid "Log out" msgstr "Выйти" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "минута" msgid "minutes" msgstr "минуты" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "Больше действий с компьютером" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Переместите их в свою общую память." @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "Сообщений с {peerBotName} пока нет." msgid "No model catalog is available." msgstr "Каталога моделей нет." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "Не подключено" msgid "Not in the plugin catalog" msgstr "Нет в каталоге плагинов" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Не сейчас" @@ -3223,14 +3294,11 @@ msgstr "" msgid "Nothing remembered yet" msgstr "Пока ничего не запомнено" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Уведомления" @@ -3244,6 +3312,10 @@ msgstr "Сейчас" msgid "Now using {0}." msgstr "Теперь используется {0}." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "Авторизация OAuth отменена." @@ -3277,6 +3349,10 @@ msgstr "1-го числа в {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "Удалить можно только пустые пространства. Сначала удалите их ботов и группы." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Провайдеры" msgid "Pull request" msgstr "Запрос на слияние" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "В очереди" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Поиск…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Отправить" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Навыки" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Пропустить" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "субагент" @@ -4451,10 +4547,6 @@ msgstr "Teams не настроен" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Тест" @@ -4545,10 +4637,6 @@ msgstr "Время суток" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Заголовок" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Введите ответ" @@ -4796,6 +4888,14 @@ msgstr "Вебхук" msgid "Weekdays" msgstr "Будни" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Что делать с его памятью?" @@ -4804,6 +4904,10 @@ msgstr "Что делать с его памятью?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "Что вернуть" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "Когда срабатывает вебхук" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Когда запускать" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "Ваш адрес электронной почты" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Ваше имя" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index a526fa6..088dc15 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -21,6 +21,11 @@ msgstr " (okunmadı)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# model} other {# model}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "Etkin model" msgid "Activity" msgstr "Etkinlik" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "Bir HTTPS sunucu URL'si gir." msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "Executor ekle" @@ -428,6 +439,10 @@ msgstr "Sunucu ekle" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "Rutine ekle" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "Yetkilendir" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "Agent'ların araçlarını kullanabilmesi için bu sunucuyu yetkilendir. Bir açılır pencere açılır." -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "Oturum açmaya geri dön" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Temel URL" @@ -753,6 +780,10 @@ msgstr "Temel URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "Canlı masaüstü başlatılıyor…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "Sunucuya ulaşılamıyor." #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "İptal edildi" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "Durum bildir." msgid "Check your email" msgstr "E-postanızı kontrol edin" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "Komut" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "Bağlam sınırı" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "Devam" @@ -1522,6 +1561,10 @@ msgstr "" msgid "Could not load voice settings" msgstr "Ses ayarları yüklenemedi" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "Varsayılan hafıza kapsamı güncellenemedi" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "gün" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "gün" @@ -1831,10 +1880,6 @@ msgstr "gün" msgid "Decline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "Varsayılan" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "örn. Hivenet Agent, Presales, puantaj botu" msgid "Earlier message" msgstr "" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "Önce düzenle" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "Bu kodu <0>{0} adresinde gir" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "Her pazartesi" msgid "Every month" msgstr "Her ay" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "Çok yüksek" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "başarısız" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "" msgid "High" msgstr "Yüksek" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "saat" msgid "hours" msgstr "saat" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "Ne sıklıkla" @@ -2463,10 +2510,6 @@ msgstr "Ne sıklıkla" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "Nasıl kontrol edilir" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "Varsayılanı devral" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "Çıkış yap" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "dakika" msgid "minutes" msgstr "dakika" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "Bunları ortak hafızana taşı." @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "{peerBotName} ile henüz mesaj yok." msgid "No model catalog is available." msgstr "Kullanılabilir model kataloğu yok." +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "Bağlı değil" msgid "Not in the plugin catalog" msgstr "" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "Şimdi değil" @@ -3223,14 +3294,11 @@ msgstr "Hâlâ çalışan bir şey yok" msgid "Nothing remembered yet" msgstr "Henüz hatırlanan bir şey yok" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "Bildirimler" @@ -3244,6 +3312,10 @@ msgstr "Şimdi" msgid "Now using {0}." msgstr "Artık {0} kullanılıyor." +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth yetkilendirmesi iptal edildi." @@ -3277,6 +3349,10 @@ msgstr "her ayın 1'inde saat {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "Sağlayıcılar" msgid "Pull request" msgstr "Çekme isteği" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "Kuyrukta" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "Aranıyor…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "Gönder" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "Beceriler" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "Atla" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "alt ajan" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "Dene" @@ -4545,10 +4637,6 @@ msgstr "Günün saati" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "Başlık" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "Tekrar dene" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "Yanıtını yaz" @@ -4796,6 +4888,14 @@ msgstr "" msgid "Weekdays" msgstr "Hafta içi" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "Hafızası ne olsun?" @@ -4804,6 +4904,10 @@ msgstr "Hafızası ne olsun?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "Ne döndürülecek" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "Ne zaman çalışacak" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "E-posta adresin" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "Adın" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index 16fb966..f0276e0 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -21,6 +21,11 @@ msgstr "(未读)" msgid "(optional)" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# day} other {# days}}" +msgstr "" + #. placeholder {0}: group.entries.length #: src/pages/ModelSettingsOverlay.tsx msgid "{0, plural, one {# model} other {# models}}" @@ -31,6 +36,11 @@ msgstr "{0, plural, one {# 个模型} other {# 个模型}}" msgid "{0, plural, one {# more waiting} other {# more waiting}}" msgstr "" +#. placeholder {0}: habit.streak +#: src/components/heute/HeuteCoach.tsx +msgid "{0, plural, one {# week} other {# weeks}}" +msgstr "" + #. placeholder {0}: file.name #: src/pages/Shell.tsx msgid "{0} (max {ATTACHMENT_MAX_COUNT} attachments)" @@ -281,10 +291,6 @@ msgstr "" msgid "1:1 conversation with {title}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "A few quick preferences so your Oo can be better tailored to you." -msgstr "" - #: src/pages/shell/bot-picker.tsx msgid "About groups" msgstr "" @@ -350,6 +356,7 @@ msgstr "当前模型" msgid "Activity" msgstr "动态" +#: src/components/onboarding/OnboardingScanStep.tsx #: src/pages/PluginsOverlay.tsx #: src/pages/ScratchpadSection.tsx msgid "Add" @@ -387,6 +394,10 @@ msgstr "请填写 HTTPS 服务器 URL。" msgid "Add another" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add as projects" +msgstr "" + #: src/pages/PluginsOverlay.tsx msgid "Add Executor" msgstr "添加 Executor" @@ -428,6 +439,10 @@ msgstr "添加服务器" msgid "Add server URL" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Add to About you" +msgstr "" + #: src/components/teach/SkillDraftCard.tsx msgid "Add to routine" msgstr "加入例行任务" @@ -494,6 +509,26 @@ msgstr "" msgid "All quiet" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in on {weekday} at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in today at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’ll check in tomorrow at {time}." +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "All set. I’m here whenever you need me." +msgstr "" + +#: src/components/UserFactEditor.tsx +msgid "All spaces" +msgstr "" + #. placeholder {0}: rule.matchValue #: src/components/ApprovalRulesSettings.tsx msgid "Allow {0} actions without asking" @@ -700,10 +735,6 @@ msgstr "授权" msgid "Authorize this server so agents can use its tools. A popup opens." msgstr "授权此服务器后,Agent 才能使用其工具。将打开弹窗。" -#: src/pages/Onboarding.tsx -msgid "Auto-detected or enter manually" -msgstr "" - #: src/pages/ExternalConversationSettings.tsx msgid "Automated senders" msgstr "" @@ -740,10 +771,6 @@ msgstr "返回登录" msgid "Back to timeline" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Balanced" -msgstr "" - #: src/pages/memory-providers/SupermemorySettingsForm.tsx msgid "Base URL" msgstr "Base URL" @@ -753,6 +780,10 @@ msgstr "Base URL" msgid "Bearer token" msgstr "Bearer token" +#: src/components/NotificationSettingsPanel.tsx +msgid "Blocked in your browser settings" +msgstr "" + #: src/pages/Shell.tsx msgid "Booting live desktop…" msgstr "正在启动实时桌面…" @@ -821,6 +852,10 @@ msgstr "" msgid "Calm helper" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Can I send you notifications?" +msgstr "" + #: src/pages/VoiceSettingsOverlay.tsx msgid "Can speak" msgstr "" @@ -833,6 +868,7 @@ msgstr "无法连接服务器。" #: src/components/ComputerMaintenanceActions.tsx #: src/components/ComputerUpdateProgress.tsx #: src/components/teach/TeachComputerOverlay.tsx +#: src/components/UserFactEditor.tsx #: src/components/UserProfileSettings.tsx #: src/pages/KnowledgeSection.tsx #: src/pages/PluginsOverlay.tsx @@ -867,8 +903,8 @@ msgstr "" msgid "Cancelled" msgstr "已取消" -#: src/pages/Onboarding.tsx -msgid "Casual & friendly" +#: src/components/UserFactEditor.tsx +msgid "Category" msgstr "" #: src/pages/AccountSettingsOverlay.tsx @@ -925,6 +961,10 @@ msgstr "报到。" msgid "Check your email" msgstr "请查看邮箱" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Check-ins" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "Checking dependencies…" msgstr "" @@ -1134,7 +1174,6 @@ msgid "Command" msgstr "命令" #: src/components/UserProfileSettings.tsx -#: src/pages/Onboarding.tsx msgid "Communication style" msgstr "" @@ -1295,8 +1334,8 @@ msgstr "上下文限制" #: src/components/integrations/IntegrationSetup.tsx #: src/components/onboarding/OnboardingOoDesigner.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx -#: src/pages/Onboarding.tsx msgid "Continue" msgstr "继续" @@ -1522,6 +1561,10 @@ msgstr "无法加载更新状态" msgid "Could not load voice settings" msgstr "无法加载语音设置" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Could not look in this folder." +msgstr "" + #: src/pages/LocalSettings.tsx msgid "Could not open local settings. Start the local server and create its owner account, then try again." msgstr "" @@ -1674,6 +1717,7 @@ msgstr "无法更新默认记忆范围" msgid "Could not update voice" msgstr "" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't load" msgstr "" @@ -1682,6 +1726,7 @@ msgstr "" msgid "Couldn't load messaging settings" msgstr "无法加载消息设置" +#: src/components/NotificationSettingsPanel.tsx #: src/components/UserProfileSettings.tsx msgid "Couldn't save" msgstr "" @@ -1823,6 +1868,10 @@ msgstr "" msgid "day" msgstr "天" +#: src/lib/onboarding-copy.ts +msgid "Day-to-day work" +msgstr "" + #: src/pages/RoutineSchedule.tsx msgid "days" msgstr "天" @@ -1831,10 +1880,6 @@ msgstr "天" msgid "Decline" msgstr "拒绝" -#: src/pages/Onboarding.tsx -msgid "Deep-dive focused" -msgstr "" - #: src/pages/ModelSettingsOverlay.tsx msgid "Default" msgstr "默认" @@ -1935,18 +1980,10 @@ msgstr "" msgid "Desktop update" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Detailed & explanatory" -msgstr "" - #: src/components/browser/InAppBrowser.tsx msgid "Developer tools" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Direct & concise" -msgstr "" - #: src/components/integrations/IntegrationSetup.tsx msgid "Direct MCP" msgstr "" @@ -2066,6 +2103,10 @@ msgstr "例如 Hivenet Agent、Presales、工时表机器人" msgid "Earlier message" msgstr "更早的消息" +#: src/components/UserProfileSettings.tsx +msgid "Edit" +msgstr "" + #: src/components/AskCard.tsx msgid "Edit first" msgstr "先编辑" @@ -2115,6 +2156,14 @@ msgstr "" msgid "Enter this code at <0>{0}" msgstr "在 <0>{0} 输入此代码" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Evening check-in" +msgstr "" + #: src/pages/ExternalConversationSettings.tsx #: src/pages/RoutineSchedule.tsx msgid "Every" @@ -2143,6 +2192,10 @@ msgstr "每周一" msgid "Every month" msgstr "每月" +#: src/lib/onboarding-copy.ts +msgid "Every morning" +msgstr "" + #: src/pages/RoutineEditor.tsx #: src/pages/RoutineSchedule.tsx msgid "Every week" @@ -2183,6 +2236,10 @@ msgstr "" msgid "Extra high" msgstr "极高" +#: src/components/UserFactEditor.tsx +msgid "Fact" +msgstr "" + #: src/components/CloudAgentCard.tsx msgid "failed" msgstr "失败" @@ -2256,10 +2313,6 @@ msgstr "" msgid "Flag unexpected actions" msgstr "标记异常操作" -#: src/pages/Onboarding.tsx -msgid "Flexible & spontaneous" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Fonts loaded locally" msgstr "" @@ -2324,6 +2377,7 @@ msgstr "Git 事件" msgid "Goal" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Goals" @@ -2360,6 +2414,7 @@ msgstr "" msgid "Groups" msgstr "" +#: src/components/heute/HeuteCoach.tsx #: src/components/UserProfileSettings.tsx msgid "Habits" msgstr "" @@ -2434,10 +2489,6 @@ msgstr "隐藏密码" msgid "High" msgstr "高" -#: src/pages/Onboarding.tsx -msgid "Hobbies & interests" -msgstr "" - #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "Hobby" msgstr "" @@ -2451,10 +2502,6 @@ msgstr "小时" msgid "hours" msgstr "小时" -#: src/pages/Onboarding.tsx -msgid "How do you like to work?" -msgstr "" - #: src/pages/RoutineSchedule.tsx msgid "How often" msgstr "频率" @@ -2463,10 +2510,6 @@ msgstr "频率" msgid "How should your Oo sound when reading replies aloud?" msgstr "" -#: src/pages/Onboarding.tsx -msgid "How should your Oo talk to you?" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "How to check" msgstr "如何检查" @@ -2507,6 +2550,10 @@ msgstr "" msgid "In progress" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Inbox & email" +msgstr "" + #: src/pages/shell/bot-panel.tsx msgid "Inherit default" msgstr "沿用默认" @@ -2730,6 +2777,10 @@ msgstr "" msgid "Log out" msgstr "退出登录" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look" +msgstr "" + #. placeholder {0}: index + 1 #. placeholder {1}: looks.length #: src/components/onboarding/LookPreviewCarousel.tsx @@ -2740,10 +2791,18 @@ msgstr "" msgid "Look families" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Look for projects here?" +msgstr "" + #: src/components/onboarding/LookPreviewCarousel.tsx msgid "Look variants" msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Looking" +msgstr "" + #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Low" @@ -2904,10 +2963,6 @@ msgstr "分钟" msgid "minutes" msgstr "分钟" -#: src/pages/HeuteBrief.tsx -msgid "Mission Control" -msgstr "" - #: src/pages/memory-providers/BuiltinSettingsForm.tsx #: src/pages/ModelSettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx @@ -2964,6 +3019,18 @@ msgstr "" msgid "More conversations ({hiddenSessionCount})" msgstr "" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Morning" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "Morning and evening" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Morning brief" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "Move them to your shared memory." msgstr "把它们移到共享记忆。" @@ -2975,7 +3042,6 @@ msgstr "" #: src/components/teach/SkillDraftCard.tsx #: src/pages/Auth.tsx #: src/pages/GroupPanel.tsx -#: src/pages/Onboarding.tsx #: src/pages/RoutineEditor.tsx #: src/pages/shell/bot-panel.tsx #: src/pages/shell/dialogs.tsx @@ -3154,6 +3220,10 @@ msgstr "还没有与 {peerBotName} 的消息。" msgid "No model catalog is available." msgstr "没有可用的模型目录。" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "No projects found" +msgstr "" + #: src/pages/Shell.tsx msgid "No projects yet." msgstr "" @@ -3211,6 +3281,7 @@ msgstr "未连接" msgid "Not in the plugin catalog" msgstr "不在插件目录中" +#: src/lib/onboarding-copy.ts #: src/pages/shell/message-cards.tsx msgid "Not now" msgstr "暂不" @@ -3223,14 +3294,11 @@ msgstr "没有仍在运行的任务" msgid "Nothing remembered yet" msgstr "暂无记忆" -#: src/pages/Onboarding.tsx -msgid "Nothing specific" -msgstr "" - #: src/components/UserProfileSettings.tsx msgid "Nothing yet" msgstr "" +#: src/pages/SettingsOverlay.tsx #: src/pages/shell/bot-panel.tsx msgid "Notifications" msgstr "通知" @@ -3244,6 +3312,10 @@ msgstr "现在" msgid "Now using {0}." msgstr "正在使用 {0}。" +#: src/components/NotificationSettingsPanel.tsx +msgid "Nudges from your Oo" +msgstr "" + #: src/pages/McpOAuthCallback.tsx msgid "OAuth authorization was cancelled." msgstr "OAuth 授权已取消。" @@ -3277,6 +3349,10 @@ msgstr "每月 1 日 {0}" msgid "Onboarding" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Once a week" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "One more look before I answer…" msgstr "" @@ -3285,6 +3361,10 @@ msgstr "" msgid "Only empty spaces can be deleted. Delete its bots and groups first." msgstr "" +#: src/components/onboarding/OnboardingScanStep.tsx +msgid "Only file and folder names are read." +msgstr "" + #: src/pages/ScratchpadSection.tsx #: src/pages/Shell.tsx msgid "Open" @@ -3536,6 +3616,10 @@ msgstr "提供方" msgid "Pull request" msgstr "拉取请求" +#: src/components/NotificationSettingsPanel.tsx +msgid "Push on this device" +msgstr "" + #: src/components/ai/DelegatedTaskCard.tsx #: src/pages/ProjectSessionTimeline.tsx msgid "Queued" @@ -3546,14 +3630,22 @@ msgstr "排队中" msgid "Queued…" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Quick & pragmatic" -msgstr "" - #: src/pages/HeuteBrief.tsx msgid "Quiet day so far" msgstr "" +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours end" +msgstr "" + +#: src/components/NotificationSettingsPanel.tsx +msgid "Quiet hours start" +msgstr "" + #: src/pages/Shell.tsx msgid "Quote" msgstr "" @@ -3773,6 +3865,10 @@ msgstr "" msgid "Reporting from home…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Research & writing" +msgstr "" + #: src/pages/shell/bot-capabilities.tsx msgid "research, writing" msgstr "" @@ -3912,6 +4008,7 @@ msgstr "" #: src/components/AskCard.tsx #: src/components/teach/SkillDraftCard.tsx +#: src/components/UserFactEditor.tsx #: src/pages/ExternalConversationSettings.tsx #: src/pages/GroupPanel.tsx #: src/pages/KnowledgeSection.tsx @@ -4012,15 +4109,12 @@ msgstr "正在搜索…" msgid "Select {0}" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Select all that apply" -msgstr "" - #: src/pages/PrivacySettingsOverlay.tsx msgid "Self-hosted (better-auth)" msgstr "" #: src/pages/Shell.tsx +#: src/pages/shell/message-cards.tsx msgid "Send" msgstr "发送" @@ -4184,7 +4278,9 @@ msgid "Skills" msgstr "技能" #: src/components/integrations/IntegrationSetup.tsx +#: src/components/onboarding/OnboardingScanStep.tsx #: src/components/onboarding/OnboardingVoiceStep.tsx +#: src/lib/onboarding-copy.ts #: src/pages/Shell.tsx msgid "Skip" msgstr "跳过" @@ -4269,6 +4365,10 @@ msgstr "" msgid "Speaking with intent…" msgstr "" +#: src/lib/onboarding-copy.ts +msgid "Specialist crew" +msgstr "" + #: src/lib/model-tier-label.ts msgid "Standard" msgstr "" @@ -4362,10 +4462,6 @@ msgstr "" msgid "Stronger together — {0} specialists" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Structured & planned" -msgstr "" - #: src/pages/Shell.tsx msgid "subagent" msgstr "子 Agent" @@ -4451,10 +4547,6 @@ msgstr "" msgid "Tell me what matters. I'll move." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Tell us about yourself" -msgstr "" - #: src/components/teach/SkillDraftCard.tsx msgid "Test" msgstr "测试" @@ -4545,10 +4637,6 @@ msgstr "时间" msgid "Time zone" msgstr "" -#: src/pages/Onboarding.tsx -msgid "Timezone" -msgstr "" - #: src/pages/shell/bot-panel.tsx msgid "Title" msgstr "标题" @@ -4603,6 +4691,10 @@ msgstr "" msgid "Try again" msgstr "重试" +#: src/lib/onboarding-copy.ts +msgid "Turn on" +msgstr "" + #: src/components/AskCard.tsx msgid "Type your answer" msgstr "输入你的回答" @@ -4796,6 +4888,14 @@ msgstr "Webhook" msgid "Weekdays" msgstr "工作日" +#: src/components/heute/CoachRhythmControl.tsx +msgid "Weekly" +msgstr "" + +#: src/lib/routine-name.ts +msgid "Weekly review" +msgstr "" + #: src/pages/shell/dialogs.tsx msgid "What about its memories?" msgstr "它的记忆怎么处理?" @@ -4804,6 +4904,10 @@ msgstr "它的记忆怎么处理?" msgid "What do you think — react or answer in a sentence." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What do you want me on first?" +msgstr "" + #: src/components/onboarding/OnboardingOoDesigner.tsx msgid "What feels like you?" msgstr "" @@ -4844,6 +4948,10 @@ msgstr "返回什么" msgid "What's on your mind? I'll route it — and tell you when I disagree." msgstr "" +#: src/lib/onboarding-copy.ts +msgid "What’s one thing you want to get done in the next few weeks?" +msgstr "" + #: src/lib/voice-persona-copy.ts msgid "What's personal today?" msgstr "" @@ -4856,6 +4964,14 @@ msgstr "" msgid "When a webhook fires" msgstr "当 Webhook 触发时" +#: src/lib/onboarding-copy.ts +msgid "When should I check in with you?" +msgstr "" + +#: src/lib/onboarding-copy.ts +msgid "When should I stay quiet?" +msgstr "" + #: src/pages/RoutineEditor.tsx msgid "When to run" msgstr "何时运行" @@ -4881,10 +4997,6 @@ msgstr "" msgid "Work channel open — go ahead." msgstr "" -#: src/pages/Onboarding.tsx -msgid "Work style" -msgstr "" - #: src/components/stage/OoStage.tsx msgid "Working" msgstr "" @@ -4941,8 +5053,11 @@ msgstr "" msgid "Your email address" msgstr "你的邮箱" +#: src/lib/onboarding-copy.ts +msgid "Your goal" +msgstr "" + #: src/pages/Auth.tsx -#: src/pages/Onboarding.tsx msgid "Your name" msgstr "你的名字" diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 1b03bf7..6060637 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import { markAfterPaint, markOnce } from "./lib/performance"; import { installPreloadRecovery } from "./lib/preload-recovery"; import { applyUiAppearance, watchSystemAppearance } from "./lib/ui-appearance"; import { resolveUiLocale } from "./lib/ui-locale"; +import { registerPushServiceWorker } from "./lib/web-push"; import "./styles.css"; markOnce("rk:renderer:module-evaluated"); @@ -19,6 +20,7 @@ try { installPreloadRecovery(); applyUiDirection(resolveUiLocale()); applyUiAppearance(); +void registerPushServiceWorker(); function PerformanceProbe() { useLayoutEffect(() => { diff --git a/apps/web/src/pages/HeuteBrief.tsx b/apps/web/src/pages/HeuteBrief.tsx index b6e9664..4bef9db 100644 --- a/apps/web/src/pages/HeuteBrief.tsx +++ b/apps/web/src/pages/HeuteBrief.tsx @@ -1,22 +1,23 @@ import { i18n } from "@lingui/core"; import { Plural, Trans, useLingui } from "@lingui/react/macro"; -import type { Bot, Group, RunActivityRow } from "@rakazo/contracts"; +import type { Bot, CoachToday, Group, HabitToday, RunActivityRow } from "@rakazo/contracts"; import { botColors } from "@rakazo/ui-tokens"; import { BotAvatar, Button, EvooMascot, GroupAvatar } from "@rakazo/ui-web"; -import { ExternalLink } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { CoachRhythmControl } from "../components/heute/CoachRhythmControl"; +import { HeuteCoach } from "../components/heute/HeuteCoach"; import { runActivityLabel } from "../lib/agent-activity"; +import type { DoingItem, DoneItem, JetztItem } from "../lib/heute-brief"; import { buildHeuteBrief, cleanBriefPreview, - type DoingItem, - type DoneItem, - type JetztItem, + coachRefreshSignal, + formatLocalDate, + withHabitDone, } from "../lib/heute-brief"; +import { localizedPreview } from "../lib/onboarding-copy"; import { rpc } from "../lib/rpc"; -const MISSION_CONTROL_URL = "http://127.0.0.1:3088/"; - type HeuteBriefProps = { bots: Bot[]; groups: Group[]; @@ -26,6 +27,8 @@ type HeuteBriefProps = { onStartChat: () => void; }; +const DATE_LABEL: Intl.DateTimeFormatOptions = { weekday: "long", day: "numeric", month: "long" }; + export function HeuteBrief({ bots, groups, @@ -37,6 +40,9 @@ export function HeuteBrief({ const [activeRuns, setActiveRuns] = useState([]); const [recentRuns, setRecentRuns] = useState([]); const [loading, setLoading] = useState(true); + const [coach, setCoach] = useState(null); + const [coachSettled, setCoachSettled] = useState(false); + const latestCoachRequest = useRef(0); useEffect(() => { let cancelled = false; @@ -68,6 +74,50 @@ export function HeuteBrief({ }; }, []); + const refreshCoach = useCallback(async () => { + const request = ++latestCoachRequest.current; + try { + const today = await rpc.coach.today(); + if (request === latestCoachRequest.current) setCoach(today); + } catch { + // Keep the last view; the next refresh retries. + } finally { + setCoachSettled(true); + } + }, []); + + // `coachSignal` is the trigger: a finished run may have logged a check-in or written the brief. + const coachSignal = useMemo(() => coachRefreshSignal(recentRuns), [recentRuns]); + useEffect(() => { + void refreshCoach(); + }, [refreshCoach, coachSignal]); + + useEffect(() => { + const onVisible = () => { + if (document.visibilityState === "visible") void refreshCoach(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + document.removeEventListener("visibilitychange", onVisible); + // Ignore responses that arrive after unmount. + latestCoachRequest.current += 1; + }; + }, [refreshCoach]); + + const setHabitDone = useCallback( + async (habit: HabitToday, done: boolean) => { + if (!coach) return; + setCoach((current) => (current ? withHabitDone(current, habit.id, done) : current)); + latestCoachRequest.current += 1; + try { + await rpc.coach.checkIns.log({ kind: "habit", habitId: habit.id, date: coach.date, done }); + } finally { + void refreshCoach(); + } + }, + [coach, refreshCoach], + ); + const brief = useMemo( () => buildHeuteBrief({ @@ -81,31 +131,45 @@ export function HeuteBrief({ const botColorById = useMemo(() => new Map(bots.map((bot) => [bot.id, bot.color])), [bots]); - const dateLabel = formatHeuteDate(new Date()); - const hasContent = brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; + const locale = i18n.locale || "de"; + const dateLabel = coach + ? formatLocalDate(coach.date, locale, DATE_LABEL) + : new Date().toLocaleDateString(locale, DATE_LABEL); + const hasCoachContent = Boolean( + coach && (coach.brief || coach.habits.length > 0 || coach.goals.length > 0), + ); + const hasContent = + hasCoachContent || brief.needsYou.length > 0 || brief.doing.length > 0 || brief.done.length > 0; return (
    -
    -

    {dateLabel}

    -

    - -

    +
    +
    +

    + {dateLabel} +

    +

    + +

    +
    +
    - {loading && !hasContent ? ( + {(loading || !coachSettled) && !hasContent ? (

    Loading…

    ) : !hasContent ? ( 0} /> ) : ( -
    +
    + {coach ? : null} + {brief.needsYou.length > 0 ? (

    @@ -172,19 +236,6 @@ export function HeuteBrief({ ) : null}

    )} - -
    ); @@ -286,7 +337,7 @@ function NeedsYouRow({ const { chatKind, chat } = item; const title = chat.name; - const preview = cleanBriefPreview(chat.preview) || t`Unread reply`; + const preview = cleanBriefPreview(localizedPreview(chat)) || t`Unread reply`; const label = chatKind === "bot" ? t`1:1 conversation with ${title}` : t`Group conversation ${title}`; return ( @@ -399,12 +450,3 @@ function jetztRowKey(item: JetztItem): string { if (item.kind === "run") return `run:${item.run.runId}`; return `unread:${item.chatKind}:${item.chat.id}`; } - -function formatHeuteDate(date: Date): string { - const locale = i18n.locale || "de"; - return date.toLocaleDateString(locale, { - weekday: "long", - day: "numeric", - month: "long", - }); -} diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 0423eaa..f21c80d 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"; @@ -9,7 +9,7 @@ import { type OnboardingEmojiConfig, OnboardingOoDesigner, } from "../components/onboarding/OnboardingOoDesigner"; -import { OnboardingScanProgress } from "../components/onboarding/OnboardingScanProgress"; +import { OnboardingScanStep } from "../components/onboarding/OnboardingScanStep"; import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, @@ -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,8 @@ 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,15 +90,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(); - const mainBot = botList.find((b) => b.kind === "main"); - if (mainBot && !cancelled) setScanBotId(mainBot.id); - } catch { - /* best effort */ - } setIntegrationSetup(integrations); // The first step renders before this resolves; never pull a user back who moved on. if (!me.needsModel && integrations?.needsSetup) { @@ -178,17 +119,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 +163,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 +184,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 +225,7 @@ export function OnboardingPage() { {error && step !== "bot" ?

    {error}

    : null} @@ -313,140 +234,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 +268,7 @@ export function OnboardingPage() { ))}

    -
    ) : null} - {step === "scan" ? ( - setStep("bot")} - botId={scanBotId ?? undefined} - /> - ) : null} + {step === "scan" ? setStep("bot")} /> : null} {step === "bot" && error ? (
    diff --git a/apps/web/src/pages/RoutineEditor.tsx b/apps/web/src/pages/RoutineEditor.tsx index 5f55284..b79f2db 100644 --- a/apps/web/src/pages/RoutineEditor.tsx +++ b/apps/web/src/pages/RoutineEditor.tsx @@ -24,6 +24,7 @@ import { } from "@rakazo/ui-web"; import { ChevronLeft, Clock, GitBranch, Globe, MessageSquare, Pause, Plus, X } from "lucide-react"; import { useId } from "react"; +import { routineName } from "../lib/routine-name"; import { RoutineSchedule } from "./RoutineSchedule"; function toDatetimeLocalValue(date: Date): string { @@ -85,7 +86,7 @@ export function emptyRoutineDraft(): RoutineDraftState { export function draftFromRoutine(routine: Routine): RoutineDraftState { return { - name: routine.name, + name: routineName(routine), prompt: routine.prompt, schedules: routine.crons.map(presetFromCron), webhookEnabled: routine.webhookEnabled, @@ -156,7 +157,7 @@ export function RoutineListRow({ - {routine.name} + {routineName(routine)} {routineTriggerSummary(routine)} diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx index 9240751..ac32825 100644 --- a/apps/web/src/pages/SettingsOverlay.tsx +++ b/apps/web/src/pages/SettingsOverlay.tsx @@ -2,6 +2,7 @@ import { useLingui } from "@lingui/react/macro"; import type { AvatarStyle, SpaceMemoryConfig } from "@rakazo/contracts"; import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; import { + Bell, Brain, CloudDownload, Cpu, @@ -16,6 +17,7 @@ import { } from "lucide-react"; import { type ComponentType, useEffect, useRef, useState } from "react"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; +import { NotificationSettingsPanel } from "../components/NotificationSettingsPanel"; import { UserProfileSettings } from "../components/UserProfileSettings"; import { ComputerSettingsPanel, @@ -36,6 +38,7 @@ export type SettingsSection = | "memory" | "profile" | "voice" + | "notifications" | "usage" | "computer" | "updates" @@ -108,6 +111,7 @@ export function SettingsOverlay({ { id: "memory", label: t`Memory`, icon: Brain }, { id: "profile", label: t`About you`, icon: UserRound }, { id: "voice", label: t`Voice`, icon: Volume2 }, + { id: "notifications", label: t`Notifications`, icon: Bell }, { id: "usage", label: t`Usage`, icon: Gauge }, ...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []), { id: "updates", label: t`Updates`, icon: CloudDownload }, @@ -246,6 +250,7 @@ export function SettingsOverlay({ /> ) : null} {section === "profile" ? : null} + {section === "notifications" ? : null} {section === "usage" ? ( ) : null} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 3ff3391..a07547f 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -216,6 +216,7 @@ import { lazyWithRecovery } from "../lib/lazy-recovery"; import { localTimezone } from "../lib/local-timezone"; import { copyableMessageText } from "../lib/message-text"; import { messageProviderLabel } from "../lib/messaging"; +import { localizedPreview, withIntakeNoteText } from "../lib/onboarding-copy"; import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire"; import { ooSound } from "../lib/oo-sound"; import { ooVocals } from "../lib/oo-vocals"; @@ -229,6 +230,7 @@ import { markAfterPaint, markOnce } from "../lib/performance"; import { isEmptyProjectSpace } from "../lib/project-empty-state"; import { readProjectSidebarOrder, writeProjectSidebarOrder } from "../lib/project-sidebar-order"; import { quoteDraftForSelection } from "../lib/quote-selection"; +import { routineSaveName } from "../lib/routine-name"; import { clearSpaceSelection, rpc, selectedSpaceId, selectSpace } from "../lib/rpc"; import { readSeenRunErrorIds, rememberSeenRunErrorId } from "../lib/run-error-storage"; import { sharedInflight } from "../lib/shared-inflight"; @@ -255,6 +257,7 @@ import { transcriptMovedDown, } from "../lib/transcript-scroll"; import { speaker } from "../lib/tts"; +import { disableWebPush } from "../lib/web-push"; import { readScreenPaneHabit, writeScreenPaneHabit } from "../lib/workspace-pane-pref"; import { ActivityList } from "./ActivityList"; import type { ContextMenuPosition } from "./BotContextMenu"; @@ -3652,7 +3655,7 @@ export function ShellPage() { time={formatRosterTime(mainConversationEntry.group.updatedAt)} subtitle={rosterActivitySubtitle( mainConversationEntry.bot.status, - cleanRosterPreview(mainConversationEntry.group.preview), + cleanRosterPreview(localizedPreview(mainConversationEntry.group)), )} active={inGroup && groupId === mainConversationEntry.groupId} onOpen={openMainConversation} @@ -4166,7 +4169,7 @@ export function ShellPage() { item.chat.unread, )}`} > - {cleanRosterPreview(item.chat.preview)} + {cleanRosterPreview(localizedPreview(item.chat))}
    @@ -4314,10 +4317,14 @@ export function ShellPage() { variant="ghost" className="w-full justify-start font-normal" onClick={() => - void authClient.signOut().then(() => { - clearSpaceSelection(); - navigate("/"); - }) + // This browser stops receiving the account's pushes once it signs out. + void disableWebPush() + .catch(() => undefined) + .then(() => authClient.signOut()) + .then(() => { + clearSpaceSelection(); + navigate("/"); + }) } > @@ -4662,7 +4669,12 @@ export function ShellPage() { )} {activeGroup?.kind === "main" && !showProjectTimeline ? ( - + ) : null} {recordingSkill ? (
    @@ -5087,7 +5099,7 @@ export function ShellPage() { } saved = await rpc.routines.update({ routineId: targetRoutine.id, - name: routineDraft.name || t`Routine`, + name: routineSaveName(targetRoutine, routineDraft.name) || t`Routine`, prompt: routineDraft.prompt || t`Check in.`, crons, active: armOneShot ? true : routineDraft.active, @@ -5841,7 +5853,11 @@ const Transcript = memo(function Transcript({ () => new Map(messages.map((message) => [message.id, message])), [messages], ); - const reactionView = useMemo(() => projectMessageReactions(messages), [messages]); + const reactionView = useMemo( + () => projectMessageReactions(messages.map(withIntakeNoteText)), + // Onboarding notes are worded in the UI language. + [messages, i18n.locale], + ); const transcriptSubordinate = encounterMode && voiceEncounterActive; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const workingLabel = diff --git a/apps/web/src/pages/shell/message-cards.tsx b/apps/web/src/pages/shell/message-cards.tsx index d53e9f8..a6e7bca 100644 --- a/apps/web/src/pages/shell/message-cards.tsx +++ b/apps/web/src/pages/shell/message-cards.tsx @@ -1,15 +1,35 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { MessageBlock } from "@rakazo/contracts"; +import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts"; import { abortableDelay } from "@rakazo/core"; -import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web"; -import { X } from "lucide-react"; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogTitle, + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@rakazo/ui-web"; +import { ArrowUp, Check, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { BuiCard, SuccessPop } from "../../components/ai/primitives"; import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open"; import { chartViewport } from "../../lib/chart-viewport"; import { connectMcpOauth } from "../../lib/mcp-connect"; +import { chosenOption, localizedChoice } from "../../lib/onboarding-copy"; import { rpc } from "../../lib/rpc"; +import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push"; +type ChoiceOption = Extract["options"][number]; + +/** + * A question card. Lettered options are the answers; an option without a letter ("Skip", + * "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding + * cards may also take an answer in the user's own words. + */ export function ChoiceCard({ botId, block, @@ -22,14 +42,33 @@ export function ChoiceCard({ const { t } = useLingui(); const [pending, setPending] = useState(false); const [error, setError] = useState(null); + const [text, setText] = useState(""); const [locallyDismissed, setLocallyDismissed] = useState(false); const dismissed = locallyDismissed || block.answerId === "_dismissed"; + const answered = Boolean(block.answerId); + // What the card says, in the user's language; answers still go out by step and option id. + const card = localizedChoice(block); + const answers = card.options.filter((option) => option.letter); + const waysOut = card.options.filter((option) => !option.letter); + const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`; - async function choose(optionId: string) { + async function choose(optionId: string, words?: string) { setPending(true); setError(null); try { - await rpc.onboarding.choose({ botId, optionId }); + // Tapping "Turn on" is the gesture the browser needs before it asks for permission. + const answer = + block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled" + ? "later" + : optionId; + await rpc.onboarding.choose({ + botId, + step: block.step, + optionId: answer, + ...(words ? { text: words } : {}), + // The server offers the notifications card only to a browser that can still turn push on. + canEnablePush: webPushSupported() && !(await webPushEnabled()), + }); await onBotChanged(); } catch (err) { setError(err instanceof Error ? err.message : t`Could not save this choice`); @@ -52,10 +91,16 @@ export function ChoiceCard({ if (dismissed) return null; + const chosen = chosenOption(card); + return (
    -
    - {!block.answerId ? ( + + {!answered && waysOut.length === 0 ? ( + ))} +
    + ) : null} + {card.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={card.textInput.placeholder} + aria-label={card.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/apps/worker/src/index.ts b/apps/worker/src/index.ts index 804281c..39fba39 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -19,7 +19,7 @@ import { createWebProvider, databaseCapacityBackoffMs, EncryptedSecretStore, - ExpoPushProvider, + ExpoPushSender, GraphileJobPublisher, GraphileJobWorkerHost, InMemoryJobQueue, @@ -36,6 +36,7 @@ import { PiAgentRuntime, PipedreamConnector, PostgresRealtimeFanout, + PushNotificationProvider, pipedreamConfigFromEnv, reconcileCloudAgents, reconcileComputerUpdates, @@ -45,6 +46,9 @@ import { resolveSandboxProvider, ScriptedAgentRuntime, SpaceMemoryProviderResolver, + VapidKeys, + vapidEnvFromProcess, + WebPushSender, } from "@rakazo/adapters"; import { resolveEncryptionKey, resolveSupervisorToken } from "@rakazo/core"; import { @@ -193,7 +197,10 @@ async function main() { secretStore: secrets, deploymentModelKey, dataDir, - notifications: new ExpoPushProvider(dataDir), + notifications: new PushNotificationProvider(prisma, { + webpush: new WebPushSender(new VapidKeys(prisma, secrets, vapidEnvFromProcess(process.env))), + expo: new ExpoPushSender(), + }), jobs, events, messaging: messaging ? createMessagingContextLoader(prisma) : undefined, diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 8c69d87..4577151 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -127,6 +127,19 @@ Called from `Onboarding.tsx` after first bot ensure. Seeded in user `MEMORY.md` (`user-relationship-contract.ts`). Persona copy and prompts follow. Details: `docs/OO-RELATIONSHIP.md`. +## Memory and the user model + +| Store | Holds | Written by | +|-------|-------|------------| +| `UserProfile` + `UserFact` (Settings → About you) | The user model: who the user is, across spaces; a fact can be limited to chosen spaces | `update_user_profile`, onboarding, chat consolidation, the user | +| User-scoped `MEMORY.md` (Settings → Memory) | Space notes shared by the bots of one space | The user, onboarding harvest | +| Bot-scoped `MEMORY.md` (bot → Knowledge) | One bot's notes in one space | `remember` appends dated notes; `mode: "replace"` rewrites | + +Space notes are not migrated into facts: facts are short, consented statements about the user, +space notes are free-form context for one space. A memory document holds at most 16 KB, so a +bot's notes and the space notes both reach the prompt; when it is full, `remember` asks the bot +to condense it. + ## Capability vision — ambient & video (future roadmap) **This PR does not build surveillance.** Current Gesamterfahrung stays **Stage + sound + calm onboarding** (`VOICE.md`). The following is Ingo's long-term direction — optional layers only. diff --git a/packages/adapter-kit/src/index.ts b/packages/adapter-kit/src/index.ts index 43f7898..ac00d1b 100644 --- a/packages/adapter-kit/src/index.ts +++ b/packages/adapter-kit/src/index.ts @@ -1,5 +1,6 @@ export * from "./background-jobs.js"; export * from "./cloud-agents.js"; export * from "./interfaces.js"; +export * from "./memory.js"; export * from "./registry.js"; export * from "./types.js"; diff --git a/packages/adapter-kit/src/memory.ts b/packages/adapter-kit/src/memory.ts new file mode 100644 index 0000000..9e1ccd3 --- /dev/null +++ b/packages/adapter-kit/src/memory.ts @@ -0,0 +1,13 @@ +/** + * Largest Markdown memory document a commit may store, in UTF-8 bytes: half of what a run reads, + * so a bot's document and the space's shared one both reach the prompt. + */ +export const MEMORY_DOCUMENT_MAX_BYTES = 16 * 1024; + +/** A commit would grow a memory document past MEMORY_DOCUMENT_MAX_BYTES. */ +export class MemoryDocumentTooLargeError extends Error { + constructor(readonly path: string) { + super(`${path} would exceed ${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB`); + this.name = "MemoryDocumentTooLargeError"; + } +} diff --git a/packages/adapter-kit/src/types.ts b/packages/adapter-kit/src/types.ts index 0c480ac..557ed0a 100644 --- a/packages/adapter-kit/src/types.ts +++ b/packages/adapter-kit/src/types.ts @@ -250,6 +250,8 @@ export interface MemoryCommitRequest { botId?: string; path: string; content: string; + /** "replace" (default) stores `content` as the whole document; "append" adds it at the end. */ + mode?: "replace" | "append"; sourceRunId?: string; sourceThreadId?: string; } @@ -435,7 +437,7 @@ export interface ScriptedTurn { ask?: { text: string; detail?: string; actions?: Array<{ id: string; label: string }> }; takeover?: { reason: string }; files?: Array<{ path: string; content: string }>; - memory?: Array<{ scope: "bot" | "user"; path: string; content: string }>; + memory?: Array>; complete?: boolean; } @@ -553,7 +555,7 @@ export interface ArtifactPut { } export interface NotificationMessage { - kind: "completion" | "failure" | "help" | "takeover"; + kind: "completion" | "failure" | "help" | "takeover" | "nudge"; title: string; body: string; botId: string; diff --git a/packages/adapters/package.json b/packages/adapters/package.json index c9b7855..701d625 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -42,6 +42,7 @@ "sharp": "0.35.4", "shell-quote": "1.10.0", "undici": "8.10.2", + "web-push": "3.6.7", "zod": "4.6.2" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/jsdom": "30.0.0", "@types/nodemailer": "8.0.1", "@types/pg": "8.23.1", + "@types/web-push": "3.6.4", "typescript": "7.0.2", "vitest": "4.1.11" } diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index 44cb6cb..df1238f 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -8,6 +8,8 @@ import { USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; +import { coachTools } from "./coach-tools.js"; +import { NOTIFY_USER_BODY_MAX_LENGTH, NOTIFY_USER_TITLE_MAX_LENGTH } from "./notify-user-tool.js"; export const SUBAGENT_MODEL_TIER_DESCRIPTION = "Optional model tier: fast for quick simple work, code for programming, standard otherwise. Uses the model this space maps to the tier, or your own model when it maps none. Ignored when model_provider and model_id are set."; @@ -385,12 +387,18 @@ export const builtinAgentTools: ConnectorTool[] = [ }, { name: "remember", - description: "Store a durable fact in this bot's explicit memory.", + description: + "Add a durable note to this bot's memory document; it is appended with today's date. Use mode replace only to rewrite the whole document, e.g. to condense it when it is full.", inputSchema: { type: "object", properties: { content: { type: "string" }, - path: { type: "string" }, + path: { type: "string", description: "Memory document, default MEMORY.md." }, + mode: { + type: "string", + enum: ["append", "replace"], + description: "append (default) adds a note; replace sets the whole document to content.", + }, }, required: ["content"], }, @@ -429,6 +437,28 @@ export const builtinAgentTools: ConnectorTool[] = [ }, }, }, + ...coachTools, + { + name: "notify_user", + description: + "Send the user a short push notification that opens the main conversation. Use it sparingly, for a check-in or reminder that matters now, and say the same in the conversation. The user's quiet hours, their nudge setting and a daily limit apply; the result says whether it was sent.", + inputSchema: { + type: "object", + properties: { + body: { + type: "string", + maxLength: NOTIFY_USER_BODY_MAX_LENGTH, + description: "One or two short sentences.", + }, + title: { + type: "string", + maxLength: NOTIFY_USER_TITLE_MAX_LENGTH, + description: "Optional; defaults to your name.", + }, + }, + required: ["body"], + }, + }, { name: "web_search", description: diff --git a/packages/adapters/src/coach-rhythm.postgres.test.ts b/packages/adapters/src/coach-rhythm.postgres.test.ts new file mode 100644 index 0000000..e55c5f6 --- /dev/null +++ b/packages/adapters/src/coach-rhythm.postgres.test.ts @@ -0,0 +1,294 @@ +import { randomUUID } from "node:crypto"; +import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import { nextCronDate } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { bootstrapUserSpace, createDb, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { COACH_ROUTINES, getCoachRhythm, setCoachRhythm } from "./coach-rhythm.js"; +import { updateUserProfileFromTool } from "./user-profile-tools.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("coach rhythm (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `cr${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return id; + } + + function fakeJobs() { + return { enqueue: vi.fn(async () => undefined), cancel: vi.fn(async () => undefined) }; + } + + function coachRoutines(userId: string) { + return prisma.routine.findMany({ + where: { userId, coachKey: { not: null } }, + orderBy: { coachKey: "asc" }, + }); + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("keeps one main-bot routine per check-in in the main conversation and user's time zone", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const rhythm = { morning: "07:30", evening: null, weekly: { day: 0, time: "18:00" } }; + + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + const conversation = await ensureMainConversation(prisma, userId); + const created = await coachRoutines(userId); + expect( + created.map( + ({ coachKey, botId, threadId, spaceId, crons, timezone, active, notify, name }) => ({ + coachKey, + botId, + threadId, + spaceId, + crons, + timezone, + active, + notify, + name, + }), + ), + ).toEqual([ + { + coachKey: "morning", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["30 7 * * *"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.morning.name, + }, + { + coachKey: "weekly", + botId: conversation.leadBotId, + threadId: conversation.threadId, + spaceId: conversation.spaceId, + crons: ["0 18 * * 0"], + timezone: "Europe/Vienna", + active: true, + notify: true, + name: COACH_ROUTINES.weekly.name, + }, + ]); + for (const routine of created) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(jobs.enqueue).toHaveBeenCalledWith( + expect.objectContaining({ + name: "routine.wakeup", + replaceKey: routineJobKey(routine.id), + availableAt: routine.nextRunAt, + }), + ); + } + expect(created[0]?.prompt).toBe(COACH_ROUTINES.morning.prompt); + + // The same rhythm again changes nothing. + jobs.enqueue.mockClear(); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect(jobs.enqueue).not.toHaveBeenCalled(); + expect(await coachRoutines(userId)).toEqual(created); + + // Moving, adding and turning off check-ins. + const next = { morning: "06:45", evening: "21:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: next })).toEqual(next); + const updated = await coachRoutines(userId); + expect(updated.map((routine) => [routine.coachKey, routine.crons[0]])).toEqual([ + ["evening", "0 21 * * *"], + ["morning", "45 6 * * *"], + ]); + expect(updated.find((routine) => routine.coachKey === "morning")?.id).toBe(created[0]?.id); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(created[1]!.id)); + expect(await getCoachRhythm(prisma, userId)).toEqual(next); + }); + + it("reads paused or reshaped routines as off and turns them back on", async () => { + const userId = await newUser("UTC"); + const jobs = fakeJobs(); + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "08:00", evening: "20:00", weekly: null } }, + ); + const routines = await coachRoutines(userId); + await prisma.routine.update({ where: { id: routines[0]!.id }, data: { active: false } }); + await prisma.routine.update({ + where: { id: routines[1]!.id }, + data: { crons: ["0 8 * * 1-5"] }, + }); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + + const rhythm = { morning: "08:00", evening: "20:00", weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm })).toEqual(rhythm); + expect((await coachRoutines(userId)).map((routine) => routine.id)).toEqual( + routines.map((routine) => routine.id), + ); + }); + + it("turning everything off removes the routines and never creates a main bot", async () => { + const userId = await newUser(null); + const jobs = fakeJobs(); + const off = { morning: null, evening: null, weekly: null }; + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await prisma.bot.count({ where: { userId, kind: "main" } })).toBe(0); + expect(await getCoachRhythm(prisma, userId)).toEqual(off); + + await setCoachRhythm( + { prisma, jobs }, + { userId, rhythm: { morning: "09:15", evening: null, weekly: null } }, + ); + const [routine] = await coachRoutines(userId); + expect(routine).toMatchObject({ timezone: "UTC", crons: ["15 9 * * *"] }); + expect(await setCoachRhythm({ prisma, jobs }, { userId, rhythm: off })).toEqual(off); + expect(await coachRoutines(userId)).toEqual([]); + expect(jobs.cancel).toHaveBeenCalledWith(routineJobKey(routine!.id)); + }); + + it("moves the check-ins, and only them, when the profile time zone changes", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const deps = { prisma, jobs }; + await setCoachRhythm(deps, { + userId, + rhythm: { morning: "07:30", evening: "21:00", weekly: { day: 0, time: "18:00" } }, + }); + const conversation = await ensureMainConversation(prisma, userId); + const evening = await prisma.routine.findFirstOrThrow({ + where: { userId, coachKey: "evening" }, + }); + await prisma.routine.update({ + where: { id: evening.id }, + data: { active: false, nextRunAt: null }, + }); + const own = await prisma.routine.create({ + data: { + botId: conversation.leadBotId, + userId, + spaceId: conversation.spaceId, + name: "Water the plants", + prompt: "Remind me to water the plants.", + crons: ["0 9 * * *"], + timezone: "Europe/Vienna", + active: true, + nextRunAt: nextCronDate("0 9 * * *", new Date(), "Europe/Vienna"), + }, + }); + const setZone = (time_zone: string) => + updateUserProfileFromTool( + deps, + { + userId, + bot: { id: conversation.leadBotId, kind: "main" }, + threadId: conversation.threadId, + sourceMessageId: null, + }, + { time_zone }, + ); + const before = await coachRoutines(userId); + + // Saving the zone the check-ins already use changes nothing. + jobs.enqueue.mockClear(); + expect(await setZone("Europe/Vienna")).toMatchObject({ ok: true }); + expect(await coachRoutines(userId)).toEqual(before); + expect(jobs.enqueue).not.toHaveBeenCalled(); + + expect(await setZone("America/New_York")).toMatchObject({ ok: true }); + const moved = await coachRoutines(userId); + expect(moved.map((routine) => [routine.coachKey, routine.timezone])).toEqual([ + ["evening", "America/New_York"], + ["morning", "America/New_York"], + ["weekly", "America/New_York"], + ]); + const localTime = (date: Date) => + new Intl.DateTimeFormat("en-GB", { + timeZone: "America/New_York", + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(date); + const [pausedEvening, morning, weekly] = moved; + expect(localTime(morning!.nextRunAt!)).toMatch(/ 07:30$/); + expect(localTime(weekly!.nextRunAt!)).toBe("Sun 18:00"); + for (const routine of [morning!, weekly!]) { + expect(routine.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + expect(routine.nextRunAt).not.toEqual(before.find((old) => old.id === routine.id)?.nextRunAt); + } + expect(jobs.enqueue).toHaveBeenCalledTimes(2); + expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(morning!.id, morning!.nextRunAt!)); + expect(jobs.enqueue).toHaveBeenCalledWith(routineWakeupJob(weekly!.id, weekly!.nextRunAt!)); + // A paused check-in moves but stays paused; the user's own routine keeps its zone. + expect(pausedEvening).toMatchObject({ active: false, nextRunAt: null }); + expect(await prisma.routine.findUniqueOrThrow({ where: { id: own.id } })).toEqual(own); + expect(await getCoachRhythm(prisma, userId)).toEqual({ + morning: "07:30", + evening: null, + weekly: { day: 0, time: "18:00" }, + }); + + // Saving the new zone again changes nothing. + jobs.enqueue.mockClear(); + await setZone("America/New_York"); + expect(await coachRoutines(userId)).toEqual(moved); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); + + it("creates no check-ins for a user without any when the time zone changes", async () => { + const userId = await newUser("Europe/Vienna"); + const jobs = fakeJobs(); + const main = await ensureMainConversation(prisma, userId); + await updateUserProfileFromTool( + { prisma, jobs }, + { + userId, + bot: { id: main.leadBotId, kind: "main" }, + threadId: main.threadId, + sourceMessageId: null, + }, + { time_zone: "Asia/Tokyo" }, + ); + expect(await prisma.userProfile.findUnique({ where: { userId } })).toMatchObject({ + timeZone: "Asia/Tokyo", + }); + expect(await prisma.routine.count({ where: { userId } })).toBe(0); + expect(jobs.enqueue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/adapters/src/coach-rhythm.ts b/packages/adapters/src/coach-rhythm.ts new file mode 100644 index 0000000..c385eec --- /dev/null +++ b/packages/adapters/src/coach-rhythm.ts @@ -0,0 +1,177 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; +import { routineJobKey, routineWakeupJob } from "@rakazo/adapter-kit"; +import type { CoachRhythm, CoachRoutineKey } from "@rakazo/contracts"; +import { + COACH_RHYTHM_OFF, + COACH_ROUTINE_KEYS, + COACH_ROUTINE_NAMES, + clockTimeToMinutes, + minutesToClockTime, +} from "@rakazo/contracts"; +import { nextCronDate, nextCronDateAcross } from "@rakazo/core"; +import type { PrismaClient } from "@rakazo/db"; +import { coachTimeZone, ensureMainConversation, findMainBotId } from "@rakazo/db"; + +export const COACH_KEYS = COACH_ROUTINE_KEYS; +export type CoachKey = CoachRoutineKey; + +/** + * The main bot's check-ins. Names show in the main bot's routine list; prompts are its task when + * a check-in fires. Product text in English: the main bot answers in the user's language, and + * its instructions name the coach tools it has. + */ +export const COACH_ROUTINES: Record = { + morning: { + name: COACH_ROUTINE_NAMES.morning, + prompt: + "Morning check-in. Review the user's active goals and habits, and today's calendar if a calendar app is connected. Write today's brief: two to five short lines on what matters most today, and save it. Then post one short, friendly message with the gist. Leave out what is not relevant today.", + }, + evening: { + name: COACH_ROUTINE_NAMES.evening, + prompt: + "Evening check-in. Log as check-ins what the user already told you about today. Then ask in one or two sentences how the day went and about habits not yet checked off. Keep it light, and do not ask when everything is already logged.", + }, + weekly: { + name: COACH_ROUTINE_NAMES.weekly, + prompt: + "Weekly review. Look at the past week's check-ins and the progress on the user's active goals. Post a short summary: what moved, what stalled, and one concrete suggestion for next week. Offer to adjust, complete or drop goals, and change them only when the user agrees.", + }, +}; + +export interface CoachRhythmDeps { + prisma: PrismaClient; + jobs: Pick; +} + +/** The cron for one check-in, or null when it is off. */ +export function coachCron(key: CoachKey, rhythm: CoachRhythm): string | null { + const slot = key === "weekly" ? rhythm.weekly : rhythm[key]; + if (!slot) return null; + const minutes = clockTimeToMinutes(typeof slot === "string" ? slot : slot.time); + return `${minutes % 60} ${Math.floor(minutes / 60)} * * ${typeof slot === "string" ? "*" : slot.day}`; +} + +/** Reads a check-in back from its routine; a schedule edited into another shape reads as off. */ +function rhythmSlot(key: CoachKey, crons: readonly string[]) { + const match = + crons.length === 1 ? /^(\d{1,2}) (\d{1,2}) \* \* (\*|[0-6])$/.exec(crons[0]!) : null; + if (!match || Number(match[1]) > 59 || Number(match[2]) > 23) return null; + const time = minutesToClockTime(Number(match[2]) * 60 + Number(match[1])); + if (key === "weekly") return match[3] === "*" ? null : { day: Number(match[3]), time }; + return match[3] === "*" ? time : null; +} + +function isCoachKey(value: string | null): value is CoachKey { + return (COACH_KEYS as readonly string[]).includes(value ?? ""); +} + +/** The user's check-in times, read from the main bot's active coach routines. */ +export async function getCoachRhythm(prisma: PrismaClient, userId: string): Promise { + const botId = await findMainBotId(prisma, userId); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const routines = await prisma.routine.findMany({ + where: { botId, userId, active: true, coachKey: { not: null } }, + select: { coachKey: true, crons: true }, + }); + const rhythm: CoachRhythm = { ...COACH_RHYTHM_OFF }; + for (const routine of routines) { + if (!isCoachKey(routine.coachKey)) continue; + const slot = rhythmSlot(routine.coachKey, routine.crons); + if (routine.coachKey === "weekly") rhythm.weekly = slot as CoachRhythm["weekly"]; + else rhythm[routine.coachKey] = slot as string | null; + } + return rhythm; +} + +/** + * Makes the main bot's coach routines match `rhythm`: one routine per check-in that is on, + * waking in the main conversation in the user's time zone. Check-ins that are off are deleted. + * Calling it again with the same rhythm changes nothing. + */ +export async function setCoachRhythm( + deps: CoachRhythmDeps, + input: { userId: string; rhythm: CoachRhythm }, +): Promise { + const { prisma, jobs } = deps; + const { userId, rhythm } = input; + const anyOn = COACH_KEYS.some((key) => coachCron(key, rhythm) !== null); + const conversation = anyOn ? await ensureMainConversation(prisma, userId) : null; + const botId = conversation?.leadBotId ?? (await findMainBotId(prisma, userId)); + if (!botId) return { ...COACH_RHYTHM_OFF }; + const existing = await prisma.routine.findMany({ + where: { botId, userId, coachKey: { not: null } }, + }); + const timezone = anyOn ? await coachTimeZone(prisma, userId) : "UTC"; + for (const key of COACH_KEYS) { + const cron = coachCron(key, rhythm); + const current = existing.find((routine) => routine.coachKey === key); + if (!cron || !conversation) { + if (current) { + await prisma.routine.deleteMany({ where: { id: current.id } }); + await jobs.cancel(routineJobKey(current.id)); + } + continue; + } + const { name, prompt } = COACH_ROUTINES[key]; + const unchanged = + current?.active && + current.nextRunAt && + current.crons.length === 1 && + current.crons[0] === cron && + current.timezone === timezone && + current.threadId === conversation.threadId && + current.spaceId === conversation.spaceId && + current.name === name && + current.prompt === prompt; + if (unchanged) continue; + const nextRunAt = nextCronDate(cron, new Date(), timezone); + const fields = { + spaceId: conversation.spaceId, + threadId: conversation.threadId, + name, + prompt, + crons: [cron], + timezone, + active: true, + notify: true, + nextRunAt, + }; + const saved = await prisma.routine.upsert({ + where: { botId_coachKey: { botId, coachKey: key } }, + create: { ...fields, botId, userId, coachKey: key }, + update: fields, + }); + // The job reconciler re-arms an active routine whose wakeup could not be enqueued. + await jobs.enqueue(routineWakeupJob(saved.id, nextRunAt)); + } + return getCoachRhythm(prisma, userId); +} + +/** + * Moves the main bot's check-ins into the user's current profile time zone and re-arms each + * armed one for its next local run. Check-ins already in that zone and routines the user made + * stay as they are, so calling it after every time zone save is safe. + */ +export async function syncCoachTimeZone(deps: CoachRhythmDeps, userId: string): Promise { + const { prisma, jobs } = deps; + const botId = await findMainBotId(prisma, userId); + if (!botId) return; + const timezone = await coachTimeZone(prisma, userId); + const moved = await prisma.routine.findMany({ + where: { botId, userId, coachKey: { not: null }, timezone: { not: timezone } }, + }); + for (const routine of moved) { + const armed = routine.active && routine.nextRunAt !== null; + const nextRunAt = armed + ? (nextCronDateAcross(routine.crons, new Date(), timezone) ?? routine.nextRunAt) + : routine.nextRunAt; + // Skipped when another save moved it meanwhile. + const { count } = await prisma.routine.updateMany({ + where: { id: routine.id, timezone: routine.timezone }, + data: { timezone, nextRunAt }, + }); + if (count === 1 && armed && nextRunAt) { + await jobs.enqueue(routineWakeupJob(routine.id, nextRunAt)); + } + } +} diff --git a/packages/adapters/src/coach-tools.test.ts b/packages/adapters/src/coach-tools.test.ts new file mode 100644 index 0000000..12fdd48 --- /dev/null +++ b/packages/adapters/src/coach-tools.test.ts @@ -0,0 +1,102 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it } from "vitest"; +import { COACH_TOOL_NAMES, executeCoachTool } from "./coach-tools.js"; +import { selectBuiltinToolsForRun } from "./executor-tools.js"; + +function toolNames(options: { + mainBot?: boolean; + /** A project's main bot rather than the user's personal agent. */ + projectLead?: boolean; + messagingChannelRun?: boolean; + foreignAgentRun?: boolean; + trigger?: string; +}) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: false, + groupId: null, + trigger: options.trigger ?? "user", + semanticMemoryEnabled: false, + messagingChannelRun: options.messagingChannelRun ?? false, + foreignAgentRun: options.foreignAgentRun, + bot: { + kind: options.mainBot ? "main" : "specialist", + restrictBuiltinTools: false, + allowedBuiltinTools: [], + personalAgent: options.mainBot && !options.projectLead, + }, + }).map((tool) => tool.name); +} + +/** + * Only the space lookup that tells the personal agent from a project lead may reach the + * database: refusals and invalid input must not write anything. + */ +const lookupOnlyPrisma = new Proxy( + { + space: { + findUnique: async ({ where }: { where: { id: string } }) => ({ + isDefault: where.id === "space-home", + }), + }, + }, + { + get(target, property) { + if (property === "space") return target.space; + throw new Error(`unexpected database access: ${String(property)}`); + }, + }, +) as unknown as PrismaClient; + +const context = (kind: string, spaceId = "space-home") => ({ + userId: "user-1", + spaceId, + bot: { id: "bot-1", kind }, +}); + +describe("coach tools", () => { + it("are the personal main agent's alone, also in its check-in routines, and never for other people", () => { + for (const name of COACH_TOOL_NAMES) { + expect(toolNames({ mainBot: true })).toContain(name); + expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name); + expect(toolNames({ mainBot: false })).not.toContain(name); + expect(toolNames({ mainBot: true, projectLead: true })).not.toContain(name); + expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name); + expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name); + } + }); + + it("refuse specialists and project leads even when they call a tool anyway", async () => { + for (const name of COACH_TOOL_NAMES) { + for (const caller of [context("specialist"), context("main", "space-project")]) { + await expect( + executeCoachTool(lookupOnlyPrisma, caller, name, { title: "Run" }), + ).resolves.toEqual({ error: "Only the user's main bot can coach the user." }); + } + } + }); + + it("reject invalid or incomplete arguments before writing", async () => { + const main = context("main"); + await expect(executeCoachTool(lookupOnlyPrisma, main, "update_goal", {})).resolves.toEqual({ + error: "A new goal needs a title.", + }); + await expect( + executeCoachTool(lookupOnlyPrisma, main, "update_goal", { title: "x", target_date: "soon" }), + ).resolves.toEqual({ error: expect.stringContaining("target_date") }); + await expect( + executeCoachTool(lookupOnlyPrisma, main, "update_habit", { + title: "Walk", + cadence: "hourly", + }), + ).resolves.toEqual({ error: expect.stringContaining("cadence") }); + await expect( + executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }), + ).resolves.toEqual({ error: "Log a habit and a goal in separate calls." }); + await expect( + executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { mood: 9 }), + ).resolves.toEqual({ error: expect.stringContaining("mood") }); + await expect( + executeCoachTool(lookupOnlyPrisma, main, "write_daily_brief", { text: " " }), + ).resolves.toEqual({ error: expect.stringContaining("text") }); + }); +}); diff --git a/packages/adapters/src/coach-tools.ts b/packages/adapters/src/coach-tools.ts new file mode 100644 index 0000000..c50bcad --- /dev/null +++ b/packages/adapters/src/coach-tools.ts @@ -0,0 +1,357 @@ +import type { ConnectorTool } from "@rakazo/adapter-kit"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + GOAL_STATUSES, + GoalStatusSchema, + HABIT_CADENCES, + HabitCadenceSchema, + LocalDateSchema, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + isPersonalMainBot, + logCheckIn, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "@rakazo/db"; +import { z } from "zod"; + +/** The main bot's coach tools, in the order they appear in its tool list. */ +export const COACH_TOOL_NAMES = [ + "update_goal", + "update_habit", + "log_check_in", + "write_daily_brief", +] as const; +export type CoachToolName = (typeof COACH_TOOL_NAMES)[number]; + +export function isCoachToolName(name: string): name is CoachToolName { + return (COACH_TOOL_NAMES as readonly string[]).includes(name); +} + +const DATE_HINT = "The user's local date, YYYY-MM-DD."; + +export const coachTools: ConnectorTool[] = [ + { + name: "update_goal", + description: + "Track a goal the user committed to. Without id it creates one; with id it changes it, and status done or dropped closes it. Track only what the user stated or agreed to.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the goal to change." }, + title: { type: "string", description: 'A short outcome, e.g. "Run a 10k in November".' }, + detail: { type: "string", description: "Optional context, such as why it matters." }, + status: { type: "string", enum: [...GOAL_STATUSES] }, + target_date: { type: "string", description: `${DATE_HINT} An empty string clears it.` }, + }, + }, + }, + { + name: "update_habit", + description: + "Track a habit the user wants to keep. Without id it creates one; with id it changes it, and active false stops tracking it.", + inputSchema: { + type: "object", + properties: { + id: { type: "string", description: "Id or exact title of the habit to change." }, + title: { type: "string", description: 'A short practice, e.g. "Walk 20 minutes".' }, + cadence: { type: "string", enum: [...HABIT_CADENCES] }, + goal: { + type: "string", + description: "Id or exact title of the goal it serves. An empty string unlinks it.", + }, + active: { type: "boolean" }, + }, + }, + }, + { + name: "log_check_in", + description: + "Record what the user reported for a day: a habit kept or missed, progress on a goal, or, with neither, how the day went. Logging the same day again updates it.", + inputSchema: { + type: "object", + properties: { + habit: { type: "string", description: "Id or exact title of the habit." }, + goal: { type: "string", description: "Id or exact title of the goal." }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + done: { type: "boolean", description: "Whether the habit was kept." }, + mood: { type: "integer", minimum: 1, maximum: 5 }, + energy: { type: "integer", minimum: 1, maximum: 5 }, + note: { type: "string", description: "The user's words, briefly." }, + }, + }, + }, + { + name: "write_daily_brief", + description: + "Save the user's brief for the day, shown at the top of their Today page. Plain text, two to five short lines. Writing again replaces it.", + inputSchema: { + type: "object", + properties: { + text: { type: "string" }, + date: { type: "string", description: `${DATE_HINT} Defaults to today.` }, + }, + required: ["text"], + }, + }, +]; + +const Ref = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const ClearableDate = z.union([LocalDateSchema, z.literal("")]); + +const UpdateGoalArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + detail: z.string().trim().max(GOAL_DETAIL_MAX_LENGTH).optional(), + status: GoalStatusSchema.optional(), + target_date: ClearableDate.optional(), +}); + +const UpdateHabitArgs = z.object({ + id: Ref.optional(), + title: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).optional(), + cadence: HabitCadenceSchema.optional(), + goal: z.union([Ref, z.literal("")]).optional(), + active: z.boolean().optional(), +}); + +const Rating = z.number().int().min(1).max(5); + +const LogCheckInArgs = z.object({ + habit: Ref.optional(), + goal: Ref.optional(), + date: LocalDateSchema.optional(), + done: z.boolean().optional(), + mood: Rating.optional(), + energy: Rating.optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), +}); + +const WriteDailyBriefArgs = z.object({ + text: z.string().trim().min(1).max(DAILY_BRIEF_MAX_LENGTH), + date: LocalDateSchema.optional(), +}); + +export interface CoachToolContext { + userId: string; + /** The space the run happens in. */ + spaceId: string; + bot: { id: string; kind: string }; +} + +type ToolResult = Record; + +function invalid(name: string, error: z.ZodError): ToolResult { + const issues = error.issues + .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`) + .join("; "); + return { error: `Invalid ${name} arguments: ${issues}` }; +} + +/** An id first, then an exact title (any case) among the user's items. */ +async function resolveRef( + find: (where: object) => Promise>, + ref: string, + what: "goal" | "habit", +): Promise { + const byId = await find({ id: ref }); + if (byId[0]) return byId[0].id; + const byTitle = await find({ title: { equals: ref, mode: "insensitive" } }); + if (byTitle.length === 1) return byTitle[0]!.id; + return byTitle.length > 1 + ? { error: `Several ${what}s are titled "${ref}". Use the id.` } + : { error: `No ${what} "${ref}".` }; +} + +function goalRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.userGoal.findMany({ + where: { userId, ...where }, + orderBy: [{ status: "asc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "goal", + ); +} + +function habitRef(prisma: PrismaClient, userId: string, ref: string) { + return resolveRef( + (where) => + prisma.habit.findMany({ + where: { userId, ...where }, + orderBy: [{ active: "desc" }, { createdAt: "desc" }], + select: { id: true }, + take: 2, + }), + ref, + "habit", + ); +} + +async function updateGoalFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateGoalArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_goal", parsed.error); + const args = parsed.data; + const targetDate = args.target_date === undefined ? undefined : args.target_date || null; + if (!args.id) { + if (!args.title) return { error: "A new goal needs a title." }; + const goal = await createGoal(prisma, context.userId, { + title: args.title, + detail: args.detail, + targetDate, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, goal: { id: goal.id, title: goal.title, status: goal.status } }; + } + const goalId = await goalRef(prisma, context.userId, args.id); + if (typeof goalId !== "string") return goalId; + const goal = await updateGoal(prisma, context.userId, goalId, { + title: args.title, + detail: args.detail, + status: args.status, + targetDate, + }); + return { + ok: true, + goal: { id: goal.id, title: goal.title, status: goal.status, targetDate: goal.targetDate }, + }; +} + +async function updateHabitFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = UpdateHabitArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("update_habit", parsed.error); + const args = parsed.data; + let goalId: string | null | undefined; + if (args.goal) { + const resolved = await goalRef(prisma, context.userId, args.goal); + if (typeof resolved !== "string") return resolved; + goalId = resolved; + } else if (args.goal === "") { + goalId = null; + } + if (!args.id) { + if (!args.title) return { error: "A new habit needs a title." }; + const habit = await createHabit(prisma, context.userId, { + title: args.title, + cadence: args.cadence, + goalId, + source: "chat", + createdByBotId: context.bot.id, + }); + return { ok: true, habit: { id: habit.id, title: habit.title, cadence: habit.cadence } }; + } + const habitId = await habitRef(prisma, context.userId, args.id); + if (typeof habitId !== "string") return habitId; + const habit = await updateHabit(prisma, context.userId, habitId, { + title: args.title, + cadence: args.cadence, + goalId, + active: args.active, + }); + return { + ok: true, + habit: { id: habit.id, title: habit.title, cadence: habit.cadence, active: habit.active }, + }; +} + +async function logCheckInFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = LogCheckInArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("log_check_in", parsed.error); + const { habit, goal, ...fields } = parsed.data; + if (habit && goal) return { error: "Log a habit and a goal in separate calls." }; + const habitId = habit ? await habitRef(prisma, context.userId, habit) : undefined; + if (habitId !== undefined && typeof habitId !== "string") return habitId; + const goalId = goal ? await goalRef(prisma, context.userId, goal) : undefined; + if (goalId !== undefined && typeof goalId !== "string") return goalId; + const checkIn = await logCheckIn(prisma, context.userId, { + kind: habitId ? "habit" : goalId ? "goal" : "daily", + habitId, + goalId, + ...fields, + createdByBotId: context.bot.id, + }); + return { + ok: true, + checkIn: { + kind: checkIn.kind, + date: checkIn.date, + done: checkIn.done, + mood: checkIn.mood, + energy: checkIn.energy, + }, + }; +} + +async function writeDailyBriefFromTool( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, +): Promise { + const parsed = WriteDailyBriefArgs.safeParse(rawArgs); + if (!parsed.success) return invalid("write_daily_brief", parsed.error); + const brief = await upsertDailyBrief(prisma, context.userId, { + text: parsed.data.text, + date: parsed.data.date, + botId: context.bot.id, + }); + return { ok: true, date: brief.date }; +} + +const HANDLERS: Record< + CoachToolName, + ( + prisma: PrismaClient, + context: CoachToolContext, + rawArgs: Record, + ) => Promise +> = { + update_goal: updateGoalFromTool, + update_habit: updateHabitFromTool, + log_check_in: logCheckInFromTool, + write_daily_brief: writeDailyBriefFromTool, +}; + +/** Runs one coach tool for the user's personal main agent; every other bot is refused. */ +export async function executeCoachTool( + prisma: PrismaClient, + context: CoachToolContext, + name: CoachToolName, + rawArgs: Record, +): Promise { + if (!(await isPersonalMainBot(prisma, { kind: context.bot.kind, spaceId: context.spaceId }))) { + return { error: "Only the user's main bot can coach the user." }; + } + try { + return await HANDLERS[name](prisma, context, rawArgs); + } catch (error) { + if (error instanceof CoachNotFoundError || error instanceof InvalidCoachInputError) { + return { error: error.message }; + } + throw error; + } +} diff --git a/packages/adapters/src/current-time.test.ts b/packages/adapters/src/current-time.test.ts index 55d2676..88aaee2 100644 --- a/packages/adapters/src/current-time.test.ts +++ b/packages/adapters/src/current-time.test.ts @@ -1,6 +1,6 @@ import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; -import { formatCurrentTimeInstruction, loadUserTimeZone } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate, loadUserTimeZone } from "./current-time.js"; describe("formatCurrentTimeInstruction", () => { it("anchors the present moment in UTC with the weekday and no sub-second noise", () => { @@ -93,3 +93,12 @@ describe("loadUserTimeZone", () => { ).resolves.toBeUndefined(); }); }); + +describe("formatLocalDate", () => { + it("is the user's local date, or the UTC date without a known zone", () => { + const now = new Date("2026-01-01T03:00:00Z"); + expect(formatLocalDate("America/Los_Angeles", now)).toBe("2025-12-31"); + expect(formatLocalDate("Not/AZone", now)).toBe("2026-01-01"); + expect(formatLocalDate(null, now)).toBe("2026-01-01"); + }); +}); diff --git a/packages/adapters/src/current-time.ts b/packages/adapters/src/current-time.ts index e1dd058..ad57e41 100644 --- a/packages/adapters/src/current-time.ts +++ b/packages/adapters/src/current-time.ts @@ -27,6 +27,14 @@ export function formatCurrentTimeInstruction( ].join(" "); } +/** Today's date as YYYY-MM-DD in the user's time zone, or in UTC when none is known. */ +export function formatLocalDate( + timeZone: string | null | undefined, + now: Date = new Date(), +): string { + return zonedClock(now, knownTimeZone(timeZone) ?? "UTC").local.slice(0, 10); +} + /** The user's time zone from their profile when this runtime knows it. */ export async function loadUserTimeZone( prisma: Pick, diff --git a/packages/adapters/src/executor-capabilities.test.ts b/packages/adapters/src/executor-capabilities.test.ts index 93fbadc..dfd5e23 100644 --- a/packages/adapters/src/executor-capabilities.test.ts +++ b/packages/adapters/src/executor-capabilities.test.ts @@ -112,6 +112,9 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record ({ name: "Ada" })) }, userProfile: { findUnique: vi.fn(async () => null) }, userFact: { findMany: vi.fn(async () => []) }, + // The main bot's goals and habits. + userGoal: { findMany: vi.fn(async () => []) }, + habit: { findMany: vi.fn(async () => []) }, ...models, }; let request: AgentRunRequest | undefined; diff --git a/packages/adapters/src/executor-effect-idempotency.test.ts b/packages/adapters/src/executor-effect-idempotency.test.ts index 374494a..9901102 100644 --- a/packages/adapters/src/executor-effect-idempotency.test.ts +++ b/packages/adapters/src/executor-effect-idempotency.test.ts @@ -319,6 +319,14 @@ describe("mutating tool effect idempotency keys", () => { ]); await f.run(); expect(f.memoryCommit).toHaveBeenCalledOnce(); + // remember appends, so only the effect record keeps a retry from adding the note twice. + expect(f.memoryCommit).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "append", + content: expect.stringMatching(/^- \d{4}-\d{2}-\d{2}: durable fact$/), + }), + expect.anything(), + ); expect(f.effects).toHaveLength(1); f.setCalls([ diff --git a/packages/adapters/src/executor-instructions.test.ts b/packages/adapters/src/executor-instructions.test.ts index 3cfe494..4f19ad2 100644 --- a/packages/adapters/src/executor-instructions.test.ts +++ b/packages/adapters/src/executor-instructions.test.ts @@ -1,5 +1,6 @@ import type { AgentRunRequest } from "@rakazo/adapter-kit"; import type { MessageBlock } from "@rakazo/contracts"; +import { addDays, localDateIn } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import type * as ComputerLifecycleModule from "./computer-lifecycle.js"; import { createRunExecutor } from "./executor.js"; @@ -20,6 +21,10 @@ interface FixtureOptions { sender?: { name: string } | null; profile?: Record | null; facts?: Array<{ category: string; value: string; confidence: number }>; + goals?: Array>; + habits?: Array>; + doneCheckIns?: Array<{ habitId: string; date: Date }>; + onboarding?: { completedAt: Date | null } | null; peers?: Array<{ id: string; name: string; @@ -117,7 +122,12 @@ async function runOnce(options: FixtureOptions = {}) { agentSecret: { findMany: vi.fn(async () => []) }, agentSkill: { findMany: vi.fn(async () => []) }, scratchpadItem: { findMany: vi.fn(async () => []) }, - user: { findUnique: vi.fn(async () => ({ name: "Ada Example" })) }, + user: { + findUnique: vi.fn(async () => ({ + name: "Ada Example", + onboarding: options.onboarding ?? null, + })), + }, userProfile: { findUnique: vi.fn(async () => options.profile === undefined @@ -136,6 +146,9 @@ async function runOnce(options: FixtureOptions = {}) { async () => options.facts ?? [{ category: "work", value: "Owns a bakery", confidence: 1 }], ), }, + userGoal: { findMany: vi.fn(async () => options.goals ?? []) }, + habit: { findMany: vi.fn(async () => options.habits ?? []) }, + checkIn: { findMany: vi.fn(async () => options.doneCheckIns ?? []) }, actionApprovalRule: { findMany: vi.fn(async () => []) }, actionAutoReviewPreference: { findUnique: vi.fn(async () => ({ enabled: false })) }, externalEffect: { @@ -302,6 +315,11 @@ describe("run instructions", () => { expect(request.tools.map((tool) => tool.name)).not.toContain("update_user_profile"); expect(request.instructions).not.toContain("update_user_profile"); + for (const name of ["update_goal", "update_habit", "log_check_in", "write_daily_brief"]) { + expect(request.tools.map((tool) => tool.name)).not.toContain(name); + expect(request.instructions).not.toContain(name); + } + expect(request.instructions).not.toContain("Active goals"); }); it("gives runs woken by the user's own bot the full user block", async () => { @@ -343,6 +361,84 @@ describe("run instructions", () => { expect(request.instructions).not.toContain("save lasting facts they share with remember"); }); + it("gives the main bot the user's goals and habits and names its coach tools", async () => { + const today = localDateIn("Europe/Berlin"); + const row = { + source: "chat", + createdByBotId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + const coach = { + goals: [ + { + ...row, + id: "goal-1", + title: "Ship the beta", + detail: "", + status: "active", + targetDate: new Date(`${addDays(today, 30)}T00:00:00Z`), + spaceId: null, + completedAt: null, + }, + ], + habits: [ + { ...row, id: "habit-1", title: "Walk", cadence: "daily", goalId: null, active: true }, + { ...row, id: "habit-2", title: "Long run", cadence: "weekly", goalId: null, active: true }, + ], + doneCheckIns: [today, addDays(today, -1)].map((date) => ({ + habitId: "habit-1", + date: new Date(`${date}T00:00:00Z`), + })), + }; + const main = await runOnce({ + bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }, + ...coach, + }); + const names = main.request.tools.map((tool) => tool.name); + expect(names).toEqual( + expect.arrayContaining(["update_goal", "update_habit", "log_check_in", "write_daily_brief"]), + ); + expect(main.request.instructions).toContain("You are also their coach"); + expect(main.request.instructions).toContain("save today's brief with write_daily_brief"); + expect(main.request.instructions).toContain( + `Active goals:\n- Ship the beta, due ${addDays(today, 30)} (id: goal-1)\nHabits:\n- Walk: daily, done today, streak 2 (id: habit-1)\n- Long run: weekly, not done this week, streak 0 (id: habit-2)\n`, + ); + + const specialist = await runOnce(coach); + expect(specialist.request.instructions).not.toContain("Active goals"); + expect(specialist.request.instructions).not.toContain("You are also their coach"); + expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); + + // A project's main bot leads its project; coaching stays with the personal agent. + const lead = await runOnce({ + bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" }, + space: { name: "Launch", isDefault: false }, + ...coach, + }); + expect(lead.request.instructions).not.toContain("Active goals"); + expect(lead.request.instructions).not.toContain("You are also their coach"); + expect(lead.request.tools.map((tool) => tool.name)).not.toContain("update_goal"); + expect(lead.request.tools.map((tool) => tool.name)).not.toContain("notify_user"); + }); + + it("leaves open onboarding questions to their cards, for the main bot only", async () => { + const main = { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" }; + const open = { completedAt: null }; + const note = "still answering onboarding cards"; + + expect((await runOnce({ bot: main, onboarding: open })).request.instructions).toContain(note); + expect( + (await runOnce({ bot: main, onboarding: { completedAt: new Date() } })).request.instructions, + ).not.toContain(note); + expect((await runOnce({ bot: main })).request.instructions).not.toContain(note); + expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note); + const project = { name: "Launch", isDefault: false }; + expect( + (await runOnce({ bot: main, space: project, onboarding: open })).request.instructions, + ).not.toContain(note); + }); + it("describes a project's main bot as the lead of that project", async () => { const { request } = await runOnce({ bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" }, diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 71c3f14..4570495 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", () => { @@ -96,6 +97,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: " ", facts: [{ category: "work", value: " ", confidence: 1 }], + onboardingOpen: false, }); expect(block).toContain("\nName: Ada\n"); expect(block).not.toContain("Time zone"); @@ -112,6 +114,7 @@ describe("formatUserContext", () => { communicationStyle: "", summary: "", facts: [], + onboardingOpen: false, }), ).toBeUndefined(); }); diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 2e4648a..83581f6 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -1,9 +1,12 @@ import { escapePromptData, knownTimeZone } from "@rakazo/core"; -import type { UserContext } from "@rakazo/db"; +import type { CoachSummary, UserContext } from "@rakazo/db"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; /** Per-field caps so user-written profile text cannot crowd out the rest of the prompt. */ const USER_FIELD_MAX_CHARS = 500; const USER_SUMMARY_MAX_CHARS = 2_000; +/** Goals and habits listed in the user block; the rest are counted. */ +const COACH_PROMPT_ITEM_LIMIT = 20; export interface BotIdentity { name: string; @@ -53,11 +56,37 @@ export function formatMainAgentRole( : toolNames.has("remember") ? "Keep what you know about the user current: save lasting facts they share with remember." : "Keep what you know about the user current.", + ...formatCoachGuidance(toolNames), ] .filter(Boolean) .join(" "); } +/** How the main bot coaches, naming only the coach tools this run has. */ +function formatCoachGuidance(toolNames: ReadonlySet): Array { + if (!COACH_TOOL_NAMES.some((name) => toolNames.has(name))) return []; + return [ + "You are also their coach: help them reach the goals and keep the habits they chose. Be brief and encouraging, notice progress, and never nag.", + toolNames.has("update_goal") + ? "Record goals they commit to with update_goal, and close them when they are done or dropped." + : undefined, + toolNames.has("update_habit") ? "Track habits they want to keep with update_habit." : undefined, + toolNames.has("log_check_in") + ? "When they tell you how a habit, a goal or their day went, record it with log_check_in." + : undefined, + toolNames.has("write_daily_brief") + ? "At the morning check-in, save today's brief with write_daily_brief." + : undefined, + ]; +} + +/** + * 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(); @@ -69,12 +98,16 @@ function userText(value: string | null | undefined, max = USER_FIELD_MAX_CHARS): * by the user or inferred from conversations, so every value is escaped and the block says * it never carries instructions. Empty fields are left out, and with nothing to say the block * is omitted. A run whose replies other people read (a shared messaging channel, or another - * owner's agent over an agent connection) gets only the basics: the summary and facts stay - * with the user. + * owner's agent over an agent connection) gets only the basics: the summary, facts, goals and + * habits stay with the user. */ export function formatUserContext( user: UserContext, - options: { basicsOnly?: boolean } = {}, + options: { + basicsOnly?: boolean; + /** The main bot's own runs: the user's active goals and habits. */ + coach?: Pick; + } = {}, ): string | undefined { const field = (label: string, value: string | null | undefined, max?: number) => { const text = userText(value, max); @@ -94,6 +127,7 @@ export function formatUserContext( field("Communication style", user.communicationStyle), options.basicsOnly ? undefined : field("Summary", user.summary, USER_SUMMARY_MAX_CHARS), ...(facts.length > 0 ? ["Facts:", ...facts] : []), + ...(options.coach && !options.basicsOnly ? coachLines(options.coach) : []), ].filter((line): line is string => Boolean(line)); if (lines.length === 0) return undefined; return [ @@ -104,6 +138,30 @@ export function formatUserContext( ].join("\n"); } +function listed(label: string, items: readonly T[], line: (item: T) => string): string[] { + if (items.length === 0) return []; + const shown = items.slice(0, COACH_PROMPT_ITEM_LIMIT).map(line); + const more = items.length - shown.length; + return [`${label}:`, ...shown, ...(more > 0 ? [`- and ${more} more`] : [])]; +} + +/** Active goals and habits with ids, so coach tools can name them exactly. */ +function coachLines(coach: Pick): string[] { + return [ + ...listed( + "Active goals", + coach.goals, + (goal) => + `- ${userText(goal.title)}${goal.targetDate ? `, due ${goal.targetDate}` : ""} (id: ${goal.id})`, + ), + ...listed("Habits", coach.habits, (habit) => { + const period = habit.cadence === "weekly" ? "this week" : "today"; + const done = habit.doneToday ? `done ${period}` : `not done ${period}`; + return `- ${userText(habit.title)}: ${habit.cadence}, ${done}, streak ${habit.streak} (id: ${habit.id})`; + }), + ]; +} + /** * Background a run_subagent helper shares with the bot that started it: who it works for, * the present moment, and the user block. diff --git a/packages/adapters/src/executor-tools.test.ts b/packages/adapters/src/executor-tools.test.ts index d987728..f1035d0 100644 --- a/packages/adapters/src/executor-tools.test.ts +++ b/packages/adapters/src/executor-tools.test.ts @@ -4,6 +4,7 @@ import { builtinToolNamesForBot, filterBuiltinToolsForBot, MAIN_ONLY_BUILTIN_TOOLS, + PERSONAL_AGENT_TOOLS, selectBuiltinToolsForRun, unknownBuiltinToolNames, } from "./executor-tools.js"; @@ -64,7 +65,15 @@ describe("builtin tools per bot", () => { }); it("lists main-only tools for the main bot alone", () => { - expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual(["delegate_task", "update_user_profile"]); + expect([...MAIN_ONLY_BUILTIN_TOOLS].sort()).toEqual([ + "delegate_task", + "log_check_in", + "notify_user", + "update_goal", + "update_habit", + "update_user_profile", + "write_daily_brief", + ]); const specialist = builtinToolNamesForBot({ kind: "specialist", chatLinked: false }); const main = builtinToolNamesForBot({ kind: "main", chatLinked: false }); for (const name of MAIN_ONLY_BUILTIN_TOOLS) { @@ -73,6 +82,17 @@ describe("builtin tools per bot", () => { } }); + it("keeps the personal agent's tools from a project's main bot", () => { + const main = { kind: "main", restrictBuiltinTools: false, allowedBuiltinTools: [] }; + const personal = filterBuiltinToolsForBot(builtinAgentTools, { ...main, personalAgent: true }); + const lead = filterBuiltinToolsForBot(builtinAgentTools, main).map((tool) => tool.name); + expect(personal.map((tool) => tool.name)).toEqual( + expect.arrayContaining([...PERSONAL_AGENT_TOOLS]), + ); + for (const name of PERSONAL_AGENT_TOOLS) expect(lead).not.toContain(name); + expect(lead).toEqual(expect.arrayContaining(["delegate_task", "update_user_profile"])); + }); + it("lists agent connection tools only for chat-linked bots", () => { expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).toEqual( specialistToolNames, diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 2e819b8..a06de96 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -1,6 +1,7 @@ import type { ConnectorTool } from "@rakazo/adapter-kit"; import { agentConnectionTools, builtinAgentTools } from "./builtin-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { COACH_TOOL_NAMES } from "./coach-tools.js"; import { selectMemoryTools } from "./memory-tools.js"; import { filterImageReturningComputerTools } from "./model-vision.js"; import { @@ -16,10 +17,27 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([ "browser_act", ]); +/** + * Main-only tools that stay with the user's personal main agent: a project's main bot leads its + * project but does not coach or nudge the user. + */ +export const PERSONAL_AGENT_TOOLS: ReadonlySet = new Set([ + "notify_user", + ...COACH_TOOL_NAMES, +]); + /** Builtin tools only the user's main bot may use; every other bot's runs never see them. */ export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet = new Set([ "delegate_task", "update_user_profile", + ...PERSONAL_AGENT_TOOLS, +]); + +/** Tools that write what the user's bots know about the user, so only the user may drive them. */ +const USER_MODEL_TOOLS: ReadonlySet = new Set([ + "update_user_profile", + "notify_user", + ...COACH_TOOL_NAMES, ]); const BUILTIN_TOOL_NAMES = [...builtinAgentTools, ...agentConnectionTools].map((tool) => tool.name); @@ -29,6 +47,8 @@ export interface BuiltinToolPolicy { kind: string; restrictBuiltinTools: boolean; allowedBuiltinTools: readonly string[]; + /** The main bot of the user's home space; only it gets the personal agent's tools. */ + personalAgent?: boolean; } /** @@ -55,11 +75,13 @@ export function filterBuiltinToolsForBot( bot: BuiltinToolPolicy, ): ConnectorTool[] { const main = bot.kind === "main"; + const personal = main && bot.personalAgent === true; const allowed = new Set(bot.allowedBuiltinTools); return tools .filter( (tool) => (main || !MAIN_ONLY_BUILTIN_TOOLS.has(tool.name)) && + (personal || !PERSONAL_AGENT_TOOLS.has(tool.name)) && (!bot.restrictBuiltinTools || allowed.has(tool.name)), ) .map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool)); @@ -105,15 +127,10 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !(options.foreignAgentRun && tool.name === "update_user_profile") && + !(options.foreignAgentRun && USER_MODEL_TOOLS.has(tool.name)) && (!options.messagingChannelRun || - (![ - "remember", - "save_memory", - "recall_memory", - "forget_memory", - "update_user_profile", - ].includes(tool.name) && + (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && + !USER_MODEL_TOOLS.has(tool.name) && !tool.name.startsWith("scratchpad_"))), ); return filterBuiltinToolsForBot( diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 2fe688e..49dfd97 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -86,6 +86,7 @@ import { stableJsonValue, toolEffectIdempotencyKey, } from "@rakazo/core/node/approval-effect-key"; +import type { McpServer, Prisma, PrismaClient, ThreadEvents } from "@rakazo/db"; import { appendEventInTransaction, createSpaceForMember, @@ -98,14 +99,11 @@ import { InvalidSpaceNameError, isTooManyDatabaseConnections, listSpaceUserBotIds, + loadCoachSummary, loadRunHistoryMessages, loadUserContext, - type McpServer, - type Prisma, - type PrismaClient, parseComputerMode, SpaceLimitError, - type ThreadEvents, } from "@rakazo/db"; import { getLogger } from "@rakazo/logging"; import { @@ -178,6 +176,7 @@ import { type CloudAgentConnection, cloudAgentsEnabled } from "./cloud-agent-fac import { executeCloudAgentTool } from "./cloud-agent-service.js"; import { validCloudAgentArgs } from "./cloud-agent-tools.js"; import { selectCloudAgentTools } from "./cloud-agent-tools-select.js"; +import { executeCoachTool, isCoachToolName } from "./coach-tools.js"; import { collectLogIds, mergeConnectedPlugins, @@ -215,7 +214,7 @@ import { observationToolResult, parseComputerActions } from "./computer-tools.js import { checkpointRunComputerWorkspace } from "./computer-workspace.js"; import { sanitizeConnectorError } from "./connector-safety.js"; import { createProject } from "./create-project.js"; -import { formatCurrentTimeInstruction } from "./current-time.js"; +import { formatCurrentTimeInstruction, formatLocalDate } from "./current-time.js"; import { delegateTask, loadDelegatedTaskInstruction, @@ -256,6 +255,7 @@ import { formatMainAgentRole, formatSubagentContext, formatUserContext, + ONBOARDING_OPEN_INSTRUCTION, } from "./executor-prompt.js"; import { isProtectedComputerLifecycleCommandReadingScripts, @@ -306,6 +306,7 @@ import { modelAcceptsImageInput, modelIdSupportsImages, } from "./model-vision.js"; +import { notifyUserFromTool } from "./notify-user-tool.js"; import { toOAuthCredential } from "./pi-credentials.js"; import { parseModelSecret, @@ -327,6 +328,7 @@ import { tryRecoverProseHandoff, workshopFallbackAfterBlockedHandoff, } from "./prose-handoff-recovery.js"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; import type { RemoteTransportDependencies } from "./remote-mcp.js"; import { loadReplyContext, messageToAgentHistoryText } from "./reply-context.js"; import { @@ -1478,6 +1480,16 @@ export function createRunExecutor(deps: ExecutorDeps) { select: { id: true }, })) : false; + // A main bot outside the home space leads that project; the home one is the user's + // personal agent, the only one that coaches and nudges them. + const mainAgentSpace = + bot.kind === "main" + ? await deps.prisma.space.findUnique({ + where: { id: run.spaceId }, + select: { name: true, isDefault: true }, + }) + : null; + const personalAgent = mainAgentSpace?.isDefault === true; const builtins = selectBuiltinToolsForRun({ graphicalToolsAllowed, pageBrowserAllowed, @@ -1488,7 +1500,7 @@ export function createRunExecutor(deps: ExecutorDeps) { messagingChannelRun, agentConnectionsEnabled: hasMessagingIdentity, foreignAgentRun, - bot, + bot: { ...bot, personalAgent }, }); const exposedConnectorTools = discovered.filter( (tool) => !builtinAgentTools.some((builtin) => builtin.name === tool.name), @@ -2582,23 +2594,20 @@ export function createRunExecutor(deps: ExecutorDeps) { }, finish); } if (name === "remember") { - await deps.memory.commit( - { - scope: "bot", + return finish( + await rememberFromTool(deps.memory, context, { botId: bot.id, - path: String(args.path ?? "MEMORY.md"), - content: String(args.content ?? ""), - sourceRunId: runId, - sourceThreadId: thread.id, - }, - context, + runId, + threadId: thread.id, + date: formatLocalDate(userContext.timeZone), + args, + }), ); - return finish({ ok: true }); } if (name === "update_user_profile") { return finish( await updateUserProfileFromTool( - deps.prisma, + deps, { userId: run.userId, bot, @@ -2609,6 +2618,25 @@ export function createRunExecutor(deps: ExecutorDeps) { ), ); } + if (name === "notify_user") { + return finish( + await notifyUserFromTool( + deps, + { userId: run.userId, spaceId: run.spaceId, runId, bot, signal: context.signal }, + args, + ), + ); + } + if (isCoachToolName(name)) { + return finish( + await executeCoachTool( + deps.prisma, + { userId: run.userId, spaceId: run.spaceId, bot }, + name, + args, + ), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } @@ -3730,19 +3758,16 @@ export function createRunExecutor(deps: ExecutorDeps) { ) : undefined; const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone); - // A main bot outside the home space leads that project. - const mainAgentSpace = - bot.kind === "main" - ? await deps.prisma.space.findUnique({ - where: { id: run.spaceId }, - select: { name: true, isDefault: true }, - }) - : null; const mainAgentProject = mainAgentSpace && !mainAgentSpace.isDefault ? mainAgentSpace.name : undefined; // Replies to another owner's agent, like a shared channel, only get the basics. + const basicsOnly = messagingChannelRun || foreignAgentRun; const userInstruction = formatUserContext(userContext, { - basicsOnly: messagingChannelRun || foreignAgentRun, + basicsOnly, + coach: + personalAgent && !basicsOnly + ? await loadCoachSummary(deps.prisma, run.userId) + : undefined, }); const userBlock = userInstruction ? redactSecrets(userInstruction, runSecrets) : undefined; @@ -3777,6 +3802,11 @@ export function createRunExecutor(deps: ExecutorDeps) { : undefined, timeInstruction, userBlock, + personalAgent && + userContext.onboardingOpen && + !(messagingChannelRun || foreignAgentRun) + ? ONBOARDING_OPEN_INSTRUCTION + : undefined, groupContext?.instructions, inboundHandoffInstruction, delegatedTaskInstruction, @@ -4294,7 +4324,11 @@ export function createRunExecutor(deps: ExecutorDeps) { scope: mem.scope, botId: mem.scope === "bot" ? bot.id : undefined, path: mem.path, - content: mem.content, + content: + mem.mode === "append" + ? datedMemoryNote(mem.content, formatLocalDate(userContext.timeZone)) + : mem.content, + mode: mem.mode, sourceRunId: runId, sourceThreadId: thread.id, }, diff --git a/packages/adapters/src/expo-push.test.ts b/packages/adapters/src/expo-push.test.ts index c8a7f4a..9052444 100644 --- a/packages/adapters/src/expo-push.test.ts +++ b/packages/adapters/src/expo-push.test.ts @@ -1,15 +1,16 @@ -import { chmod, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; +import type { PrismaClient } from "@rakazo/db"; import { afterEach, describe, expect, it, vi } from "vitest"; import { - deletePushToken, - ExpoPushProvider, + ExpoPushSender, expoPushErrorMessage, - loadPushToken, + importLegacyPushTokens, + legacyPushTokenDir, MAX_EXPO_PUSH_RESPONSE_BYTES, - savePushToken, } from "./expo-push.js"; +import type { PushPayload } from "./push-notifications.js"; const dirs: string[] = []; @@ -18,13 +19,19 @@ afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); -const notifyContext = { - operationId: "n", - traceId: "n", - spaceId: "w", - userId: "user-1", - signal: new AbortController().signal, -}; +const device = { endpoint: "ExponentPushToken[test]", p256dh: null, auth: null }; + +function payload(overrides: Partial = {}): PushPayload { + return { + kind: "completion", + title: "done", + body: "ok", + botId: "b", + threadId: "t", + url: "/app/b", + ...overrides, + }; +} function jsonResponse(body: unknown, status = 200) { return Response.json(body, { status }); @@ -49,75 +56,24 @@ describe("expo push tickets", () => { }); }); -describe("expo push", () => { - it("keeps refreshed push tokens owner-only", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const tokenFile = path.join(dataDir, "push-tokens", "user-1.txt"); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - await chmod(tokenFile, 0o644); - - await savePushToken(dataDir, "user-1", "ExponentPushToken[new]"); - - expect((await stat(tokenFile)).mode & 0o777).toBe(0o600); - }); - - it("does not follow a token-file symlink for reads or writes", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const tokenDir = path.join(dataDir, "push-tokens"); - const tokenFile = path.join(tokenDir, "user-1.txt"); - const target = path.join(dataDir, "outside.txt"); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - await writeFile(target, "not-a-push-token"); - await rm(tokenFile); - await symlink(target, tokenFile); - - await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined(); - await expect(savePushToken(dataDir, "user-1", "ExponentPushToken[new]")).rejects.toThrow(); - await expect(readFile(target, "utf8")).resolves.toBe("not-a-push-token"); - }); - - it("removes a registered token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); - await deletePushToken(dataDir, "user-1"); - await expect(loadPushToken(dataDir, "user-1")).resolves.toBeUndefined(); - }); - - it("does not call Expo when the user has no token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const push = new ExpoPushProvider(dataDir); - await push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { - operationId: "n", - traceId: "n", - spaceId: "w", - userId: "missing", - signal: new AbortController().signal, - }, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it("posts to Expo when a token is registered", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); +describe("expo push sender", () => { + it("posts the notification to Expo for the device token", async () => { const fetchMock = vi .fn() .mockResolvedValue(jsonResponse({ data: { status: "ok", id: "ticket" } })); vi.stubGlobal("fetch", fetchMock); - const push = new ExpoPushProvider(dataDir); - await push.send( - { kind: "takeover", title: "Need you", body: "on screen", botId: "bot-1", threadId: "th-1" }, - notifyContext, - ); + await expect( + new ExpoPushSender().send( + device, + payload({ + kind: "takeover", + title: "Need you", + body: "on screen", + botId: "bot-1", + threadId: "th-1", + }), + ), + ).resolves.toBe("sent"); expect(fetchMock).toHaveBeenCalledOnce(); const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(url).toBe("https://exp.host/--/api/v2/push/send"); @@ -126,55 +82,51 @@ describe("expo push", () => { title: string; collapseId: string; tag: string; - data: { kind: string }; + data: { kind: string; botId: string; threadId: string }; }; - expect(body.to).toBe("ExponentPushToken[test]"); - expect(body.title).toBe("Need you"); - expect(body.collapseId).toBe("th-1"); - expect(body.tag).toBe("th-1"); - expect(body.data.kind).toBe("takeover"); + expect(body).toMatchObject({ + to: "ExponentPushToken[test]", + title: "Need you", + collapseId: "th-1", + tag: "th-1", + data: { kind: "takeover", botId: "bot-1", threadId: "th-1" }, + }); + }); + + it("reports a device Expo no longer knows as gone", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue( + jsonResponse({ data: { status: "error", details: { error: "DeviceNotRegistered" } } }), + ), + ); + await expect(new ExpoPushSender().send(device, payload())).resolves.toBe("gone"); }); it("throws when Expo rejects the request", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(jsonResponse({ errors: [{ message: "boom" }] }, 500)), ); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("boom"); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("boom"); }); it("rejects and cancels a declared oversized response", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); const response = new Response("oversized", { headers: { "content-length": String(MAX_EXPO_PUSH_RESPONSE_BYTES + 1) }, }); const cancel = vi.spyOn(response.body!, "cancel"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response)); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push response is too large.", + ); expect(cancel).toHaveBeenCalledOnce(); }); it("does not wait past cancellation when an oversized body cancel hangs", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); let cancelStarted = false; const hangingBody = new ReadableStream({ cancel() { @@ -194,51 +146,31 @@ describe("expo push", () => { ); const started = Date.now(); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { ...notifyContext, signal: abort.signal }, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload(), abort.signal)).rejects.toThrow( + "Expo push response is too large.", + ); expect(cancelStarted).toBe(true); expect(Date.now() - started).toBeLessThan(500); }); it("caps a streamed response without a content length", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue(new Response(new Uint8Array(MAX_EXPO_PUSH_RESPONSE_BYTES + 1))), ); - - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push response is too large."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push response is too large.", + ); }); it("rejects malformed successful responses", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("not json"))); - - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("Expo push returned an invalid response."); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow( + "Expo push returned an invalid response.", + ); }); it("passes caller cancellation to the Expo request", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); const controller = new AbortController(); controller.abort(new Error("notification cancelled")); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { @@ -247,49 +179,69 @@ describe("expo push", () => { }); vi.stubGlobal("fetch", fetchMock); - await expect( - new ExpoPushProvider(dataDir).send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - { ...notifyContext, signal: controller.signal }, - ), - ).rejects.toThrow("notification cancelled"); + await expect(new ExpoPushSender().send(device, payload(), controller.signal)).rejects.toThrow( + "notification cancelled", + ); expect(fetchMock).toHaveBeenCalledOnce(); }); it("throws when the Expo request never reaches the network", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[test]"); vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("offline"))); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("offline"); - }); - - it("reports DeviceNotRegistered without deleting a stored or replacement token", async () => { - const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); - dirs.push(dataDir); - await savePushToken(dataDir, "user-1", "ExponentPushToken[old]"); - vi.stubGlobal( - "fetch", - vi.fn(async () => { - await savePushToken(dataDir, "user-1", "ExponentPushToken[new]"); - return jsonResponse({ - data: { status: "error", details: { error: "DeviceNotRegistered" } }, - }); - }), - ); - const push = new ExpoPushProvider(dataDir); - await expect( - push.send( - { kind: "completion", title: "done", body: "ok", botId: "b", threadId: "t" }, - notifyContext, - ), - ).rejects.toThrow("DeviceNotRegistered"); - await expect(loadPushToken(dataDir, "user-1")).resolves.toBe("ExponentPushToken[new]"); + await expect(new ExpoPushSender().send(device, payload())).rejects.toThrow("offline"); + }); +}); + +describe("legacy push token import", () => { + function fakePrisma(existingUsers: string[]) { + const upsert = vi.fn().mockResolvedValue({}); + const prisma = { + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + existingUsers.includes(where.id) ? { id: where.id } : null, + ), + }, + pushSubscription: { + upsert, + findMany: vi.fn().mockResolvedValue([]), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + } as unknown as PrismaClient; + return { prisma, upsert }; + } + + it("imports each token once, skips unknown users and removes the files", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); + dirs.push(dataDir); + const dir = legacyPushTokenDir(dataDir); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "user-1.txt"), "ExponentPushToken[one]\n"); + await writeFile(path.join(dir, "gone-user.txt"), "ExponentPushToken[two]"); + await writeFile(path.join(dir, "user-2.txt"), "not a token"); + const { prisma, upsert } = fakePrisma(["user-1", "user-2"]); + + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(1); + + expect(upsert).toHaveBeenCalledOnce(); + expect(upsert.mock.calls[0]![0]).toMatchObject({ + where: { endpoint: "ExponentPushToken[one]" }, + create: { endpoint: "ExponentPushToken[one]", userId: "user-1", kind: "expo" }, + }); + await expect(readdir(dataDir)).resolves.toEqual([]); + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0); + }); + + it("does not follow a token-file symlink", async () => { + const dataDir = await mkdtemp(path.join(tmpdir(), "rakazo-push-")); + dirs.push(dataDir); + const dir = legacyPushTokenDir(dataDir); + await mkdir(dir, { recursive: true }); + const outside = path.join(dataDir, "outside.txt"); + await writeFile(outside, "ExponentPushToken[outside]"); + await symlink(outside, path.join(dir, "user-1.txt")); + const { prisma, upsert } = fakePrisma(["user-1"]); + + await expect(importLegacyPushTokens(prisma, dataDir)).resolves.toBe(0); + expect(upsert).not.toHaveBeenCalled(); + await expect(readdir(dataDir)).resolves.toEqual(["outside.txt"]); }); }); diff --git a/packages/adapters/src/expo-push.ts b/packages/adapters/src/expo-push.ts index 3ff5dd9..20192f4 100644 --- a/packages/adapters/src/expo-push.ts +++ b/packages/adapters/src/expo-push.ts @@ -1,29 +1,29 @@ import { constants } from "node:fs"; -import { mkdir, open, unlink } from "node:fs/promises"; +import { open, readdir, rmdir, unlink } from "node:fs/promises"; import path from "node:path"; -import type { - AdapterContext, - NotificationMessage, - NotificationProvider, -} from "@rakazo/adapter-kit"; -import { getLogger } from "@rakazo/logging"; +import { ExpoPushTokenSchema } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { savePushSubscription } from "@rakazo/db"; import { combineSignals } from "./connector-safety.js"; +import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js"; const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0; const EXPO_PUSH_TIMEOUT_MS = 15_000; export const MAX_EXPO_PUSH_RESPONSE_BYTES = 64 * 1024; +const LEGACY_TOKEN_FILE = /^([A-Za-z0-9_-]{1,128})\.txt$/; -export function pushTokenPath(dataDir: string, userId: string) { - return path.join(dataDir, "push-tokens", `${userId}.txt`); +/** Where the API kept the single Expo token per user before push_subscriptions existed. */ +export function legacyPushTokenDir(dataDir: string) { + return path.join(dataDir, "push-tokens"); } -export async function loadPushToken(dataDir: string, userId: string): Promise { +async function readLegacyToken(file: string): Promise { try { - const handle = await open(pushTokenPath(dataDir, userId), constants.O_RDONLY | O_NOFOLLOW); + const handle = await open(file, constants.O_RDONLY | O_NOFOLLOW); try { - const token = (await handle.readFile("utf8")).trim(); - return token || undefined; + const token = ExpoPushTokenSchema.safeParse(await handle.readFile("utf8")); + return token.success ? token.data : undefined; } finally { await handle.close(); } @@ -32,26 +32,39 @@ export async function loadPushToken(dataDir: string, userId: string): Promise { - const file = pushTokenPath(dataDir, userId); - await mkdir(path.dirname(file), { recursive: true }); - const handle = await open( - file, - constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | O_NOFOLLOW, - 0o600, - ); +/** + * Moves tokens from the legacy files into push_subscriptions once, then deletes each file. A file + * whose user no longer exists, or that holds no usable token, is deleted without import; a + * database failure keeps the file for the next start. + */ +export async function importLegacyPushTokens( + prisma: PrismaClient, + dataDir: string, +): Promise { + const dir = legacyPushTokenDir(dataDir); + let names: string[]; try { - await handle.chmod(0o600); - await handle.writeFile(token.trim(), "utf8"); - } finally { - await handle.close(); + names = await readdir(dir); + } catch { + return 0; } -} - -export async function deletePushToken(dataDir: string, userId: string): Promise { - await unlink(pushTokenPath(dataDir, userId)).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); + let imported = 0; + for (const name of names) { + const userId = LEGACY_TOKEN_FILE.exec(name)?.[1]; + if (!userId) continue; + const file = path.join(dir, name); + const token = await readLegacyToken(file); + if (token) { + const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } }); + if (user) { + await savePushSubscription(prisma, userId, { kind: "expo", endpoint: token }); + imported += 1; + } + } + await unlink(file).catch(() => undefined); + } + await rmdir(dir).catch(() => undefined); + return imported; } export type ExpoPushTicket = { @@ -87,49 +100,33 @@ export function expoPushErrorMessage(body: unknown, status: number): string | un return undefined; } -export class ExpoPushProvider implements NotificationProvider { - constructor(private readonly dataDir: string) {} - - describe() { - return { - id: "expo-push", - contractVersion: "1", - adapterVersion: "0.1.0", - capabilities: { push: true, email: false }, - }; - } - - async send(message: NotificationMessage, context: AdapterContext): Promise { - const token = await loadPushToken(this.dataDir, context.userId); - if (!token) return; - const signal = combineSignals(context.signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS)); - let response: Response; - try { - response = await fetch("https://exp.host/--/api/v2/push/send", { - method: "POST", - headers: { "content-type": "application/json", accept: "application/json" }, - body: JSON.stringify({ - to: token, - title: message.title, - body: message.body, - collapseId: message.threadId, - tag: message.threadId, - data: { kind: message.kind, botId: message.botId, threadId: message.threadId }, - }), - signal, - }); - } catch (error) { - getLogger().error("expo push request failed", error); - throw error; - } - const body = await readExpoPushBody(response, signal); +/** Sends to one Expo push token; "gone" when Expo reports the device is no longer registered. */ +export class ExpoPushSender { + async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { + const deadline = combineSignals(signal, AbortSignal.timeout(EXPO_PUSH_TIMEOUT_MS)); + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + to: target.endpoint, + title: payload.title, + body: payload.body, + collapseId: payload.threadId, + tag: payload.threadId, + data: { kind: payload.kind, botId: payload.botId, threadId: payload.threadId }, + }), + signal: deadline, + }); + const body = await readExpoPushBody(response, deadline); if (response.ok && body === undefined) { throw new Error("Expo push returned an invalid response."); } + if (expoPushTickets(body).some((ticket) => ticket.details?.error === "DeviceNotRegistered")) { + return "gone"; + } const failure = expoPushErrorMessage(body, response.status); - if (!failure) return; - getLogger().error(failure); - throw new Error(failure); + if (failure) throw new Error(failure); + return "sent"; } } diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 7b4ecd0..bbeaf86 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -23,6 +23,8 @@ export * from "./cloud-agent-provider-env.js"; export * from "./cloud-agent-service.js"; export * from "./cloud-agent-tools.js"; export * from "./cloud-agent-tools-select.js"; +export * from "./coach-rhythm.js"; +export * from "./coach-tools.js"; export * from "./composio-catalog-cache.js"; export * from "./composio-connector.js"; export * from "./composio-emulator.js"; @@ -100,6 +102,7 @@ export * from "./pipedream-connector.js"; export * from "./profile-consolidation.js"; export * from "./project-workspace-files.js"; export * from "./project-workspace-path.js"; +export * from "./push-notifications.js"; export * from "./realtime.js"; export * from "./release-watch.js"; export * from "./remote-mcp.js"; @@ -124,5 +127,6 @@ export * from "./voice-factory.js"; export * from "./wakeup.js"; export * from "./web-limits.js"; export * from "./web-provider-factory.js"; +export * from "./web-push.js"; export * from "./web-ssrf.js"; export * from "./web-tools.js"; diff --git a/packages/adapters/src/memory-context.test.ts b/packages/adapters/src/memory-context.test.ts index 3df4d13..93bcd21 100644 --- a/packages/adapters/src/memory-context.test.ts +++ b/packages/adapters/src/memory-context.test.ts @@ -51,6 +51,36 @@ describe("agent memory context", () => { expect(result?.endsWith("")).toBe(true); }); + it("keeps the newest notes of a document that does not fit, from a line start", async () => { + const notes = Array.from( + { length: 40 }, + (_, index) => `- 2026-10-0${index % 9}: note ${index}`, + ); + const read = vi.fn(async ({ scope }: { scope: "bot" | "user" }) => + snapshot( + scope === "bot" + ? [ + document( + "bot", + "MEMORY.md", + `# Chief\n${notes.join("\n")}\n`, + 41, + "2026-10-03T12:00:00.000Z", + ), + ] + : [], + ), + ); + + const result = await loadAgentMemoryContext(storeWith(read), "bot-1", context, 600); + + expect(Buffer.byteLength(result ?? "", "utf8")).toBeLessThanOrEqual(600); + expect(result).toContain("## bot: MEMORY.md (revision 41)\n[earlier notes omitted]\n- "); + expect(result).toContain("note 39\n\n"); + expect(result).not.toContain("# Chief"); + expect(result).not.toContain("note 0\n"); + }); + it("omits the memory block when neither scope has documents", async () => { const read = vi.fn(async () => snapshot([])); diff --git a/packages/adapters/src/memory-context.ts b/packages/adapters/src/memory-context.ts index 1912225..901ba7e 100644 --- a/packages/adapters/src/memory-context.ts +++ b/packages/adapters/src/memory-context.ts @@ -1,6 +1,9 @@ import type { AdapterContext, MemorySnapshot, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES } from "@rakazo/adapter-kit"; -const MAX_AGENT_MEMORY_BYTES = 32 * 1024; +/** Room for a full bot document and a full space document. */ +const MAX_AGENT_MEMORY_BYTES = 2 * MEMORY_DOCUMENT_MAX_BYTES; +const OMITTED_MARKER = "[earlier notes omitted]\n"; type ScopedMemoryDocument = MemorySnapshot["documents"][number] & { scope: "bot" | "user"; @@ -45,10 +48,14 @@ export async function loadAgentMemoryContext( sections.push(heading); remainingBytes -= headingBytes; - const content = truncateUtf8(document.content, remainingBytes); - sections.push(content); - remainingBytes -= byteLength(content); - if (content !== document.content) break; + if (byteLength(document.content) <= remainingBytes) { + sections.push(document.content); + remainingBytes -= byteLength(document.content); + continue; + } + // Notes are appended, so the end of a document is its newest part. + sections.push(newestLines(document.content, remainingBytes)); + break; } return `${preamble}${sections.join("")}${closing}`; @@ -58,6 +65,30 @@ function byteLength(value: string): number { return Buffer.byteLength(value, "utf8"); } +/** The end of `content` within `maxBytes`, from a line start, after an omission marker. */ +function newestLines(content: string, maxBytes: number): string { + const markerBytes = byteLength(OMITTED_MARKER); + if (maxBytes <= markerBytes) return ""; + const tail = tailUtf8(content, maxBytes - markerBytes); + const startsLine = content.charAt(content.length - tail.length - 1) === "\n"; + const newline = tail.indexOf("\n"); + const partialFirstLine = !startsLine && newline >= 0 && newline < tail.length - 1; + return `${OMITTED_MARKER}${partialFirstLine ? tail.slice(newline + 1) : tail}`; +} + +function tailUtf8(value: string, maxBytes: number): string { + const characters = Array.from(value); + let bytes = 0; + let start = characters.length; + while (start > 0) { + const characterBytes = byteLength(characters[start - 1]!); + if (bytes + characterBytes > maxBytes) break; + bytes += characterBytes; + start -= 1; + } + return characters.slice(start).join(""); +} + function truncateUtf8(value: string, maxBytes: number): string { if (maxBytes <= 0) return ""; const characters: string[] = []; diff --git a/packages/adapters/src/notify-user-tool.ts b/packages/adapters/src/notify-user-tool.ts new file mode 100644 index 0000000..5e2fd01 --- /dev/null +++ b/packages/adapters/src/notify-user-tool.ts @@ -0,0 +1,88 @@ +import type { NotificationProvider } from "@rakazo/adapter-kit"; +import { NUDGES_PER_DAY } from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + claimDailyNudge, + countPushSubscriptions, + ensureMainConversation, + isPersonalMainBot, + loadNotificationPolicy, +} from "@rakazo/db"; +import { z } from "zod"; + +export const NOTIFY_USER_BODY_MAX_LENGTH = 200; +export const NOTIFY_USER_TITLE_MAX_LENGTH = 60; + +const NotifyUserArgs = z.object({ + body: z.string().trim().min(1).max(NOTIFY_USER_BODY_MAX_LENGTH), + title: z.string().trim().max(NOTIFY_USER_TITLE_MAX_LENGTH).optional(), +}); + +export interface NotifyUserToolContext { + userId: string; + spaceId: string; + runId: string; + bot: { id: string; name: string; kind: string }; + signal: AbortSignal; +} + +export type NotifyUserResult = { error: string } | { sent: true } | { sent: false; reason: string }; + +/** + * A nudge from the main bot: a push that opens the main conversation. The user's nudge switch, + * quiet hours and the daily cap all apply; a held-back nudge is reported, not an error. + */ +export async function notifyUserFromTool( + deps: { prisma: PrismaClient; notifications?: NotificationProvider }, + context: NotifyUserToolContext, + rawArgs: Record, +): Promise { + if ( + !(await isPersonalMainBot(deps.prisma, { kind: context.bot.kind, spaceId: context.spaceId })) + ) { + return { error: "Only the user's main bot can notify the user." }; + } + const parsed = NotifyUserArgs.safeParse(rawArgs); + if (!parsed.success) { + return { + error: `Invalid notification: body is required, up to ${NOTIFY_USER_BODY_MAX_LENGTH} characters; title up to ${NOTIFY_USER_TITLE_MAX_LENGTH}.`, + }; + } + const { prisma, notifications } = deps; + if (!notifications) { + return { sent: false, reason: "Push notifications are not available on this server." }; + } + const policy = await loadNotificationPolicy(prisma, context.userId); + if (!policy.nudges) return { sent: false, reason: "The user turned nudges off." }; + if (policy.quiet) return { sent: false, reason: "It is the user's quiet hours." }; + if ((await countPushSubscriptions(prisma, context.userId)) === 0) { + return { sent: false, reason: "The user has no device with notifications turned on." }; + } + if (!(await claimDailyNudge(prisma, context.userId, policy.localDate))) { + return { sent: false, reason: `The daily limit of ${NUDGES_PER_DAY} nudges is reached.` }; + } + const conversation = await ensureMainConversation(prisma, context.userId); + try { + await notifications.send( + { + kind: "nudge", + title: parsed.data.title || context.bot.name, + body: parsed.data.body, + botId: context.bot.id, + threadId: conversation.threadId, + }, + { + operationId: `notify_user:${context.runId}`, + traceId: context.runId, + spaceId: context.spaceId, + userId: context.userId, + botId: context.bot.id, + runId: context.runId, + signal: context.signal, + }, + ); + } catch { + return { sent: false, reason: "No device accepted the notification." }; + } + return { sent: true }; +} diff --git a/packages/adapters/src/notify-user.postgres.test.ts b/packages/adapters/src/notify-user.postgres.test.ts new file mode 100644 index 0000000..2ac50e6 --- /dev/null +++ b/packages/adapters/src/notify-user.postgres.test.ts @@ -0,0 +1,231 @@ +import { randomUUID } from "node:crypto"; +import type { PrismaClient } from "@rakazo/db"; +import { + bootstrapUserSpace, + createDb, + createSpaceForMember, + ensureMainConversation, + savePushSubscription, + updateNotificationSettings, +} from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { notifyUserFromTool } from "./notify-user-tool.js"; +import type { PushPayload, PushSender } from "./push-notifications.js"; +import { PushNotificationProvider } from "./push-notifications.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("push delivery and notify_user (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `nu${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + await bootstrapUserSpace(prisma, { id }, signupEnv, { claimDeploymentOwner: false }); + const conversation = await ensureMainConversation(prisma, id); + const main = await prisma.bot.findUniqueOrThrow({ + where: { id: conversation.leadBotId }, + select: { id: true, name: true, kind: true }, + }); + return { userId: id, conversation, main }; + } + + function recordingSender(outcome: "sent" | "gone" = "sent") { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload: PushPayload) => { + sent.push(payload); + return outcome; + }), + }; + return { sender, sent }; + } + + function toolContext(user: Awaited>, bot = user.main) { + return { + userId: user.userId, + spaceId: user.conversation.spaceId, + runId: `run-${randomUUID()}`, + bot, + signal: new AbortController().signal, + }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("delivers to each device, links the conversation and drops gone devices", async () => { + const user = await newUser(); + const browser = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: browser, + ...keys, + }); + await savePushSubscription(prisma, user.userId, { + kind: "expo", + endpoint: `ExponentPushToken[${user.userId}]`, + }); + const web = recordingSender("sent"); + const expo = recordingSender("gone"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + { + kind: "completion", + title: "Chief finished", + body: "Done", + botId: user.main.id, + threadId: user.conversation.threadId, + }, + { + operationId: "notify", + traceId: "notify", + spaceId: user.conversation.spaceId, + userId: user.userId, + signal: new AbortController().signal, + }, + ); + expect(web.sent[0]?.url).toBe(`/app/g/${user.conversation.groupId}`); + const devices = await prisma.pushSubscription.findMany({ where: { userId: user.userId } }); + expect(devices).toHaveLength(1); + expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) }); + }); + + it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => { + const user = await newUser(); + const web = recordingSender(); + const deps = { + prisma, + notifications: new PushNotificationProvider(prisma, { webpush: web.sender }), + }; + + await expect( + notifyUserFromTool(deps, toolContext(user, { ...user.main, kind: "specialist" }), { + body: "Hi", + }), + ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); + // A project's main bot leads its project; nudging the user is the personal agent's job. + const project = await createSpaceForMember(prisma, { + currentSpaceId: user.conversation.spaceId, + userId: user.userId, + name: "Launch", + }); + const lead = await ensureMainConversation(prisma, user.userId, project.id); + await expect( + notifyUserFromTool( + deps, + { ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id }, + { body: "Hi" }, + ), + ).resolves.toEqual({ error: "Only the user's main bot can notify the user." }); + await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({ + error: expect.stringContaining("body is required"), + }); + await expect( + notifyUserFromTool({ prisma }, toolContext(user), { body: "Hi" }), + ).resolves.toMatchObject({ sent: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user has no device with notifications turned on.", + }); + + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + await updateNotificationSettings(prisma, user.userId, { nudges: false }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "The user turned nudges off.", + }); + + // Quiet hours around the current time. + await updateNotificationSettings(prisma, user.userId, { + nudges: true, + quietHours: quietAroundNow(), + }); + await expect(notifyUserFromTool(deps, toolContext(user), { body: "Hi" })).resolves.toEqual({ + sent: false, + reason: "It is the user's quiet hours.", + }); + expect(web.sent).toHaveLength(0); + + await updateNotificationSettings(prisma, user.userId, { quietHours: null }); + for (let index = 0; index < 3; index += 1) { + await expect( + notifyUserFromTool(deps, toolContext(user), { body: `Check-in ${index}` }), + ).resolves.toEqual({ sent: true }); + } + await expect( + notifyUserFromTool(deps, toolContext(user), { body: "One too many" }), + ).resolves.toEqual({ sent: false, reason: "The daily limit of 3 nudges is reached." }); + + expect(web.sent).toHaveLength(3); + expect(web.sent[0]).toMatchObject({ + kind: "nudge", + title: user.main.name, + body: "Check-in 0", + threadId: user.conversation.threadId, + url: `/app/g/${user.conversation.groupId}`, + }); + }); + + it("reports a nudge no device accepted", async () => { + const user = await newUser(); + await savePushSubscription(prisma, user.userId, { + kind: "webpush", + endpoint: `https://push.example.test/${randomUUID()}`, + ...keys, + }); + const failing: PushSender = { + send: async () => { + throw new Error("Web push failed (500)"); + }, + }; + await expect( + notifyUserFromTool( + { prisma, notifications: new PushNotificationProvider(prisma, { webpush: failing }) }, + toolContext(user), + { body: "Hi", title: "Evening check-in" }, + ), + ).resolves.toEqual({ sent: false, reason: "No device accepted the notification." }); + }); +}); + +/** A UTC range that covers the current minute (users without a time zone use UTC). */ +function quietAroundNow() { + const now = new Date(); + const minute = now.getUTCHours() * 60 + now.getUTCMinutes(); + const clock = (value: number) => { + const wrapped = (value + 1440) % 1440; + return `${String(Math.floor(wrapped / 60)).padStart(2, "0")}:${String(wrapped % 60).padStart(2, "0")}`; + }; + return { start: clock(minute - 60), end: clock(minute + 60) }; +} diff --git a/packages/adapters/src/push-notifications.test.ts b/packages/adapters/src/push-notifications.test.ts new file mode 100644 index 0000000..b368799 --- /dev/null +++ b/packages/adapters/src/push-notifications.test.ts @@ -0,0 +1,179 @@ +import type { NotificationMessage } from "@rakazo/adapter-kit"; +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import type { PushOutcome, PushPayload, PushSender } from "./push-notifications.js"; +import { PushNotificationProvider } from "./push-notifications.js"; + +const context = { + operationId: "notify", + traceId: "t", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; +const message: NotificationMessage = { + kind: "completion", + title: "Chief finished", + body: "Booked the table for Friday.", + botId: "bot-1", + threadId: "thread-1", +}; + +function fakeDb(options: { + devices: Array<{ id: string; kind: "webpush" | "expo" }>; + quietHours?: { start: number; end: number }; + timeZone?: string; + groupId?: string | null; +}) { + const forgotten: string[] = []; + const used: string[] = []; + const prisma = { + userNotificationSettings: { + findUnique: async () => + options.quietHours + ? { + quietHoursStart: options.quietHours.start, + quietHoursEnd: options.quietHours.end, + nudges: true, + } + : null, + }, + userProfile: { findUnique: async () => ({ timeZone: options.timeZone ?? null }) }, + pushSubscription: { + findMany: async () => + options.devices.map((device) => ({ + ...device, + endpoint: `endpoint-${device.id}`, + p256dh: null, + auth: null, + })), + deleteMany: async ({ where }: { where: { id: string } }) => { + forgotten.push(where.id); + return { count: 1 }; + }, + updateMany: async ({ where }: { where: { id: string } }) => { + used.push(where.id); + return { count: 1 }; + }, + }, + thread: { findUnique: async () => ({ groupId: options.groupId ?? null }) }, + } as unknown as PrismaClient; + return { prisma, forgotten, used }; +} + +function fakeSender(outcome: PushOutcome | Error) { + const sent: PushPayload[] = []; + const sender: PushSender = { + send: vi.fn(async (_target, payload) => { + sent.push(payload); + if (outcome instanceof Error) throw outcome; + return outcome; + }), + }; + return { sender, sent }; +} + +// 23:30 in Vienna (UTC+2 in summer). +const lateEvening = () => new Date("2026-07-01T21:30:00Z"); + +describe("push notification fan-out", () => { + it("sends to every device with a link to the conversation", async () => { + const { prisma, used } = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + groupId: "group-1", + }); + const web = fakeSender("sent"); + const expo = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender, expo: expo.sender }).send( + message, + context, + ); + expect(web.sent).toEqual([{ ...message, url: "/app/g/group-1" }]); + expect(expo.sent).toHaveLength(1); + expect(used.sort()).toEqual(["browser", "phone"]); + }); + + it("links a direct thread to its bot and shortens long bodies", async () => { + const { prisma } = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send( + { ...message, body: "x".repeat(1_000) }, + context, + ); + expect(web.sent[0]?.url).toBe("/app/bot-1"); + expect(web.sent[0]?.body.length).toBeLessThanOrEqual(240); + }); + + it("holds every push back during quiet hours in the user's time zone", async () => { + const quiet = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "Europe/Vienna", + }); + const web = fakeSender("sent"); + await new PushNotificationProvider(quiet.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(0); + + // The same moment is mid-afternoon in Los Angeles. + const awake = fakeDb({ + devices: [{ id: "browser", kind: "webpush" }], + quietHours: { start: 22 * 60, end: 7 * 60 }, + timeZone: "America/Los_Angeles", + }); + await new PushNotificationProvider(awake.prisma, { webpush: web.sender }, lateEvening).send( + message, + context, + ); + expect(web.sent).toHaveLength(1); + }); + + it("drops devices the push service no longer knows", async () => { + const { prisma, forgotten, used } = fakeDb({ + devices: [ + { id: "old-browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await new PushNotificationProvider(prisma, { + webpush: fakeSender("gone").sender, + expo: fakeSender("sent").sender, + }).send(message, context); + expect(forgotten).toEqual(["old-browser"]); + expect(used).toEqual(["phone"]); + }); + + it("succeeds when one device works and fails only when all of them fail", async () => { + const mixed = fakeDb({ + devices: [ + { id: "browser", kind: "webpush" }, + { id: "phone", kind: "expo" }, + ], + }); + await expect( + new PushNotificationProvider(mixed.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + expo: fakeSender("sent").sender, + }).send(message, context), + ).resolves.toBeUndefined(); + + const broken = fakeDb({ devices: [{ id: "browser", kind: "webpush" }] }); + await expect( + new PushNotificationProvider(broken.prisma, { + webpush: fakeSender(new Error("Web push failed (500)")).sender, + }).send(message, context), + ).rejects.toThrow("Push delivery failed"); + }); + + it("does nothing without devices or senders", async () => { + const { prisma } = fakeDb({ devices: [{ id: "phone", kind: "expo" }] }); + const web = fakeSender("sent"); + await new PushNotificationProvider(prisma, { webpush: web.sender }).send(message, context); + expect(web.sent).toHaveLength(0); + }); +}); diff --git a/packages/adapters/src/push-notifications.ts b/packages/adapters/src/push-notifications.ts new file mode 100644 index 0000000..820a132 --- /dev/null +++ b/packages/adapters/src/push-notifications.ts @@ -0,0 +1,102 @@ +import type { + AdapterContext, + NotificationMessage, + NotificationProvider, +} from "@rakazo/adapter-kit"; +import type { PrismaClient, PushSubscriptionKind } from "@rakazo/db"; +import { + forgetPushSubscription, + listPushSubscriptions, + loadNotificationPolicy, + markPushSubscriptionUsed, +} from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; + +/** Lock screens show a line or two; the rest lives in the conversation. */ +const PUSH_BODY_MAX_LENGTH = 240; + +/** What a device shows: the notification plus where a tap leads (a path in the web app). */ +export interface PushPayload extends NotificationMessage { + url: string; +} + +/** "gone": the push service no longer knows this device, so its registration can be dropped. */ +export type PushOutcome = "sent" | "gone"; + +export interface PushTarget { + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface PushSender { + send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise; +} + +function clip(text: string): string { + const trimmed = text.trim(); + return trimmed.length > PUSH_BODY_MAX_LENGTH + ? `${trimmed.slice(0, PUSH_BODY_MAX_LENGTH - 1).trimEnd()}…` + : trimmed; +} + +/** + * Delivers one notification to every device the user registered (browsers and the mobile app), + * except during their quiet hours. Devices the push service reports gone are dropped. Throws only + * when every device failed. + */ +export class PushNotificationProvider implements NotificationProvider { + constructor( + private readonly prisma: PrismaClient, + private readonly senders: Partial>, + private readonly now: () => Date = () => new Date(), + ) {} + + describe() { + return { + id: "push", + contractVersion: "1", + adapterVersion: "0.1.0", + capabilities: { push: true, email: false }, + }; + } + + async send(message: NotificationMessage, context: AdapterContext): Promise { + const policy = await loadNotificationPolicy(this.prisma, context.userId, this.now()); + if (policy.quiet) return; + const devices = (await listPushSubscriptions(this.prisma, context.userId)).filter( + (device) => this.senders[device.kind], + ); + if (devices.length === 0) return; + const payload: PushPayload = { + ...message, + title: message.title.trim().slice(0, 120), + body: clip(message.body), + url: await this.conversationPath(message), + }; + const results = await Promise.allSettled( + devices.map(async (device) => { + const outcome = await this.senders[device.kind]!.send(device, payload, context.signal); + if (outcome === "gone") await forgetPushSubscription(this.prisma, device.id); + else await markPushSubscriptionUsed(this.prisma, device.id, this.now()); + }), + ); + const failures = results.filter((result) => result.status === "rejected"); + for (const failure of failures) { + // Errors carry a status or a reason, never the endpoint or token. + getLogger().error("push delivery failed", failure.reason); + } + if (failures.length === devices.length) throw new Error("Push delivery failed"); + } + + /** The web app path of the conversation the notification is about. */ + private async conversationPath(message: NotificationMessage): Promise { + const thread = await this.prisma.thread.findUnique({ + where: { id: message.threadId }, + select: { groupId: true }, + }); + return thread?.groupId + ? `/app/g/${encodeURIComponent(thread.groupId)}` + : `/app/${encodeURIComponent(message.botId)}`; + } +} diff --git a/packages/adapters/src/remember.test.ts b/packages/adapters/src/remember.test.ts new file mode 100644 index 0000000..05a86d0 --- /dev/null +++ b/packages/adapters/src/remember.test.ts @@ -0,0 +1,96 @@ +import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; +import { describe, expect, it, vi } from "vitest"; +import { datedMemoryNote, rememberFromTool } from "./remember.js"; + +const context: AdapterContext = { + operationId: "run-1", + traceId: "run-1", + spaceId: "space-1", + userId: "user-1", + signal: new AbortController().signal, +}; + +function remember(args: Record, commit = vi.fn(async () => ({}) as never)) { + return { + commit, + result: rememberFromTool({ commit }, context, { + botId: "bot-1", + runId: "run-1", + threadId: "thread-1", + date: "2026-10-03", + args, + }), + }; +} + +describe("datedMemoryNote", () => { + it("is one dated bullet whose later lines stay inside it", () => { + expect(datedMemoryNote(" Prefers tea ", "2026-10-03")).toBe("- 2026-10-03: Prefers tea"); + expect(datedMemoryNote("Trip plan\nBerlin first\n\nthen Vienna", "2026-10-03")).toBe( + "- 2026-10-03: Trip plan\n Berlin first\n\n then Vienna", + ); + }); +}); + +describe("rememberFromTool", () => { + it("appends a dated note to MEMORY.md by default", async () => { + const { commit, result } = remember({ content: "Prefers tea" }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + { + scope: "bot", + botId: "bot-1", + path: "MEMORY.md", + content: "- 2026-10-03: Prefers tea", + mode: "append", + sourceRunId: "run-1", + sourceThreadId: "thread-1", + } satisfies MemoryCommitRequest, + context, + ); + }); + + it("rewrites the whole document only when asked to", async () => { + const { commit, result } = remember({ + content: "# Chief\n\n- Prefers tea\n", + path: "notes.md", + mode: "replace", + }); + await expect(result).resolves.toEqual({ ok: true }); + expect(commit).toHaveBeenCalledWith( + expect.objectContaining({ + path: "notes.md", + content: "# Chief\n\n- Prefers tea\n", + mode: "replace", + }), + context, + ); + }); + + it("refuses an empty note without writing", async () => { + const { commit, result } = remember({ content: " " }); + await expect(result).resolves.toEqual({ error: "content is empty" }); + expect(commit).not.toHaveBeenCalled(); + }); + + it("tells the bot to condense a full document", async () => { + const commit = vi.fn(async () => { + throw new MemoryDocumentTooLargeError("MEMORY.md"); + }); + const { result } = remember({ content: "One more fact" }, commit); + await expect(result).resolves.toEqual({ + error: + 'MEMORY.md is full (16 KB). Condense it with mode "replace", then save this note again.', + }); + }); + + it("lets other storage failures fail the call", async () => { + const commit = vi.fn(async () => { + throw new Error("database unavailable"); + }); + await expect(remember({ content: "fact" }, commit).result).rejects.toThrow( + "database unavailable", + ); + }); +}); diff --git a/packages/adapters/src/remember.ts b/packages/adapters/src/remember.ts new file mode 100644 index 0000000..7f411ac --- /dev/null +++ b/packages/adapters/src/remember.ts @@ -0,0 +1,55 @@ +import type { AdapterContext, MemoryStore } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; + +const DEFAULT_MEMORY_PATH = "MEMORY.md"; + +/** One dated Markdown bullet; later lines are indented so the note stays a single list item. */ +export function datedMemoryNote(content: string, date: string): string { + const [first = "", ...rest] = content.trim().split(/\r?\n/); + return [`- ${date}: ${first}`, ...rest.map((line) => (line.trim() ? ` ${line}` : ""))].join( + "\n", + ); +} + +/** + * The builtin `remember` tool: appends a dated note to the bot's memory document, or rewrites the + * whole document with `mode: "replace"`. Retries of the same call are replayed from its effect + * record by the executor, so a note is appended once. + */ +export async function rememberFromTool( + memory: Pick, + context: AdapterContext, + input: { + botId: string; + runId: string; + threadId: string; + /** The user's local date, YYYY-MM-DD. */ + date: string; + args: Record; + }, +): Promise<{ ok: true } | { error: string }> { + const replace = input.args.mode === "replace"; + const content = String(input.args.content ?? ""); + const path = String(input.args.path ?? "").trim() || DEFAULT_MEMORY_PATH; + if (!replace && !content.trim()) return { error: "content is empty" }; + try { + await memory.commit( + { + scope: "bot", + botId: input.botId, + path, + content: replace ? content : datedMemoryNote(content, input.date), + mode: replace ? "replace" : "append", + sourceRunId: input.runId, + sourceThreadId: input.threadId, + }, + context, + ); + } catch (error) { + if (!(error instanceof MemoryDocumentTooLargeError)) throw error; + return { + error: `${path} is full (${MEMORY_DOCUMENT_MAX_BYTES / 1024} KB). Condense it with mode "replace", then save this note again.`, + }; + } + return { ok: true }; +} diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index 0028095..bd849a5 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -6,6 +6,7 @@ import type { } from "@rakazo/adapter-kit"; import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core"; +import { COACH_ROUTINES } from "./coach-rhythm.js"; const running = new Map(); @@ -15,6 +16,56 @@ const LEARN_ABOUT_ME = new RegExp( "i", ); +/** "set a goal: …", "track a habit: …", "done today: " and "write today's brief: …". */ +const COACH_COMMANDS: Array<{ + pattern: RegExp; + reply: string; + call: (value: string) => { name: string; args: Record }; +}> = [ + { + pattern: /^set a goal:\s*([\s\S]+)$/i, + reply: "goal noted.", + call: (title) => ({ name: "update_goal", args: { title } }), + }, + { + pattern: /^track a habit:\s*([\s\S]+)$/i, + reply: "habit noted.", + call: (title) => ({ name: "update_habit", args: { title } }), + }, + { + pattern: /^done today:\s*([\s\S]+)$/i, + reply: "checked off.", + call: (habit) => ({ name: "log_check_in", args: { habit, done: true } }), + }, + { + pattern: /^write today's brief:\s*([\s\S]+)$/i, + reply: "today's brief is ready.", + call: (text) => ({ name: "write_daily_brief", args: { text } }), + }, +]; + +/** The morning check-in writes a fixed brief so tests can follow it to the Today page. */ +export const SCRIPTED_MORNING_BRIEF = "One thing at a time today."; + +function inferCoachScript(prompt: string): NonNullable | undefined { + if (prompt.startsWith(COACH_ROUTINES.morning.prompt)) { + return [ + { + assistant: "good morning. today's brief is ready.", + toolCalls: [{ name: "write_daily_brief", args: { text: SCRIPTED_MORNING_BRIEF } }], + complete: true, + }, + ]; + } + for (const command of COACH_COMMANDS) { + const value = command.pattern.exec(prompt.trim())?.[1]?.trim(); + if (value) { + return [{ assistant: command.reply, toolCalls: [command.call(value)], complete: true }]; + } + } + return undefined; +} + export class ScriptedAgentRuntime implements AgentRuntime { describe() { return { @@ -207,6 +258,8 @@ export function inferScript( }, ]; } + const coach = inferCoachScript(prompt); + if (coach) return coach; if (lower.includes("delegate to the bot named")) { const name = namedBot(prompt) ?? "Researcher"; const brief = @@ -499,7 +552,7 @@ export function inferScript( return [ { assistant: "noted — i will keep that in memory.", - memory: [{ scope: "bot", path: "MEMORY.md", content: `# Memory\n\n- ${prompt}\n` }], + memory: [{ scope: "bot", path: "MEMORY.md", content: prompt, mode: "append" }], complete: true, }, ]; diff --git a/packages/adapters/src/user-profile-tools.test.ts b/packages/adapters/src/user-profile-tools.test.ts index b6200cf..0b118a3 100644 --- a/packages/adapters/src/user-profile-tools.test.ts +++ b/packages/adapters/src/user-profile-tools.test.ts @@ -1,8 +1,16 @@ +import type { JobPublisher } from "@rakazo/adapter-kit"; import type { PrismaClient } from "@rakazo/db"; import { describe, expect, it, vi } from "vitest"; +import type * as CoachRhythmModule from "./coach-rhythm.js"; +import { syncCoachTimeZone } from "./coach-rhythm.js"; import { selectBuiltinToolsForRun } from "./executor-tools.js"; import { updateUserProfileFromTool } from "./user-profile-tools.js"; +vi.mock("./coach-rhythm.js", async (importOriginal) => ({ + ...(await importOriginal()), + syncCoachTimeZone: vi.fn(async () => undefined), +})); + function toolNames(options: { mainBot?: boolean; messagingChannelRun?: boolean; @@ -23,15 +31,16 @@ function toolNames(options: { }).map((tool) => tool.name); } -/** Any database access fails the test: refusals and invalid input must not reach it. */ -const untouchedPrisma = new Proxy( +/** Any database or job access fails the test: refusals and invalid input must not reach it. */ +const untouched = new Proxy( {}, { get(_target, property) { - throw new Error(`unexpected database access: ${String(property)}`); + throw new Error(`unexpected access: ${String(property)}`); }, }, -) as PrismaClient; +); +const untouchedDeps = { prisma: untouched as PrismaClient, jobs: untouched as JobPublisher }; const context = (kind: string) => ({ userId: "user-1", @@ -58,24 +67,24 @@ describe("update_user_profile", () => { it("refuses specialists even when they call the tool anyway", async () => { await expect( - updateUserProfileFromTool(untouchedPrisma, context("specialist"), { + updateUserProfileFromTool(untouchedDeps, context("specialist"), { facts: [{ category: "work", value: "Nurse" }], }), ).resolves.toEqual({ error: "Only the user's main bot can update the user profile." }); }); it("rejects invalid or empty updates before writing", async () => { - const invalidCategory = await updateUserProfileFromTool(untouchedPrisma, context("main"), { + const invalidCategory = await updateUserProfileFromTool(untouchedDeps, context("main"), { facts: [{ category: "secrets", value: "x" }], }); expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") }); - const unknownZone = await updateUserProfileFromTool(untouchedPrisma, context("main"), { + const unknownZone = await updateUserProfileFromTool(untouchedDeps, context("main"), { time_zone: "Vienna", }); expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") }); - await expect(updateUserProfileFromTool(untouchedPrisma, context("main"), {})).resolves.toEqual({ + await expect(updateUserProfileFromTool(untouchedDeps, context("main"), {})).resolves.toEqual({ error: "Nothing to save. Pass facts or a profile field.", }); }); @@ -96,8 +105,9 @@ describe("update_user_profile", () => { const prisma = { $transaction: (run: (client: typeof tx) => Promise) => run(tx), } as unknown as PrismaClient; + const jobs = { enqueue: vi.fn(), cancel: vi.fn() }; - const result = await updateUserProfileFromTool(prisma, context("main"), { + const result = await updateUserProfileFromTool({ prisma, jobs }, context("main"), { facts: [ { category: "work", value: "Nurse" }, { category: "health", value: "Asthma" }, @@ -121,5 +131,12 @@ describe("update_user_profile", () => { }), expect.objectContaining({ category: "health", status: "pending", sensitive: true }), ]); + // A new time zone moves the check-ins; facts alone leave them be. + expect(syncCoachTimeZone).toHaveBeenCalledExactlyOnceWith({ prisma, jobs }, "user-1"); + vi.mocked(syncCoachTimeZone).mockClear(); + await updateUserProfileFromTool({ prisma, jobs }, context("main"), { + facts: [{ category: "work", value: "Nurse" }], + }); + expect(syncCoachTimeZone).not.toHaveBeenCalled(); }); }); diff --git a/packages/adapters/src/user-profile-tools.ts b/packages/adapters/src/user-profile-tools.ts index 19255cb..9ba0991 100644 --- a/packages/adapters/src/user-profile-tools.ts +++ b/packages/adapters/src/user-profile-tools.ts @@ -5,9 +5,10 @@ import { UserFactCategorySchema, UserFactValueSchema, } from "@rakazo/contracts"; -import type { PrismaClient } from "@rakazo/db"; import { InvalidUserFactError, updateUserProfile } from "@rakazo/db"; import { z } from "zod"; +import type { CoachRhythmDeps } from "./coach-rhythm.js"; +import { syncCoachTimeZone } from "./coach-rhythm.js"; const UpdateUserProfileArgs = z.object({ facts: z @@ -29,7 +30,7 @@ export interface UserProfileToolContext { /** Records what the main bot learned about the user, attributed to the conversation. */ export async function updateUserProfileFromTool( - prisma: PrismaClient, + deps: CoachRhythmDeps, context: UserProfileToolContext, rawArgs: Record, ) { @@ -54,7 +55,7 @@ export async function updateUserProfileFromTool( return { error: "Nothing to save. Pass facts or a profile field." }; } try { - const added = await updateUserProfile(prisma, context.userId, { + const added = await updateUserProfile(deps.prisma, context.userId, { timeZone: args.time_zone, language: args.language, communicationStyle: args.communication_style, @@ -66,6 +67,7 @@ export async function updateUserProfileFromTool( sourceMessageId: context.sourceMessageId, })), }); + if (args.time_zone !== undefined) await syncCoachTimeZone(deps, context.userId); const pending = added.filter((fact) => fact.status === "pending").length; return pending > 0 ? { diff --git a/packages/adapters/src/web-push.test.ts b/packages/adapters/src/web-push.test.ts new file mode 100644 index 0000000..df381de --- /dev/null +++ b/packages/adapters/src/web-push.test.ts @@ -0,0 +1,156 @@ +import { createECDH, randomBytes } from "node:crypto"; +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import webPush from "web-push"; +import type { PushPayload } from "./push-notifications.js"; +import { EncryptedSecretStore } from "./secrets.js"; +import { VapidKeys, vapidEnvFromProcess, WebPushSender } from "./web-push.js"; + +const browserKey = createECDH("prime256v1"); +browserKey.generateKeys(); +const device = { + endpoint: "https://push.example.test/device-1", + p256dh: browserKey.getPublicKey().toString("base64url"), + auth: randomBytes(16).toString("base64url"), +}; +const publicHost = async () => [{ address: "93.184.216.34", family: 4 }]; +const payload: PushPayload = { + kind: "completion", + title: "Chief finished", + body: "Done", + botId: "bot-1", + threadId: "thread-1", + url: "/app/g/group-1", +}; + +function settingsTable() { + const rows = new Map(); + const prisma = { + integrationProviderConfig: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + rows.has(where.id) ? { ciphertext: rows.get(where.id)! } : null, + ), + createMany: vi.fn(async ({ data }: { data: Array<{ id: string; ciphertext: string }> }) => { + for (const row of data) if (!rows.has(row.id)) rows.set(row.id, row.ciphertext); + return { count: data.length }; + }), + }, + } as unknown as PrismaClient; + return { prisma, rows }; +} + +const fixedKeys = { subject: "mailto:ops@example.test", ...webPush.generateVAPIDKeys() }; + +describe("VAPID keys", () => { + it("uses the configured key pair", async () => { + const { prisma, rows } = settingsTable(); + const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), fixedKeys); + await expect(keys.details()).resolves.toEqual(fixedKeys); + expect(rows.size).toBe(0); + }); + + it("generates one key pair, stores it encrypted and shares it across processes", async () => { + const { prisma, rows } = settingsTable(); + const secrets = new EncryptedSecretStore("test-key"); + const api = new VapidKeys(prisma, secrets, { publicKey: fixedKeys.publicKey }); + const worker = new VapidKeys(prisma, secrets); + const [fromApi, fromWorker] = await Promise.all([api.details(), worker.details()]); + expect(fromWorker).toEqual(fromApi); + expect(fromApi.publicKey).not.toBe(fixedKeys.publicKey); + expect(fromApi.subject).toBe("mailto:admin@localhost"); + expect(rows.size).toBe(1); + const [ciphertext] = [...rows.values()]; + expect(ciphertext).not.toContain(fromApi.privateKey); + await expect(new VapidKeys(prisma, secrets).publicKey()).resolves.toBe(fromApi.publicKey); + }); + + it("reports no public key when the configured keys are invalid", async () => { + const { prisma } = settingsTable(); + const keys = new VapidKeys(prisma, new EncryptedSecretStore("test-key"), { + ...fixedKeys, + privateKey: "not-a-key", + }); + await expect(keys.publicKey()).resolves.toBeNull(); + }); + + it("reads the environment and uses an https web origin as the contact", () => { + expect( + vapidEnvFromProcess({ + VAPID_PUBLIC_KEY: " pub ", + VAPID_PRIVATE_KEY: "priv", + WEB_ORIGIN: "https://app.example.test", + }), + ).toEqual({ publicKey: "pub", privateKey: "priv", subject: "https://app.example.test" }); + expect(vapidEnvFromProcess({ WEB_ORIGIN: "http://127.0.0.1:5173" }).subject).toBeUndefined(); + expect( + vapidEnvFromProcess({ VAPID_SUBJECT: "mailto:a@example.test", WEB_ORIGIN: "https://x.test" }) + .subject, + ).toBe("mailto:a@example.test"); + }); +}); + +describe("web push sender", () => { + const vapid = { details: async () => fixedKeys }; + + it("posts an encrypted, VAPID-signed message to the endpoint", async () => { + const fetchMock = vi.fn(async () => new Response(null, { status: 201 })); + const sender = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect(sender.send(device, payload)).resolves.toBe("sent"); + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(device.endpoint); + expect(init.method).toBe("POST"); + expect(init.redirect).toBe("manual"); + const headers = new Headers(init.headers); + expect(headers.get("content-encoding")).toBe("aes128gcm"); + expect(headers.get("ttl")).toBe("86400"); + expect(headers.get("authorization")).toMatch(/^vapid t=.+, k=/); + expect(headers.has("content-length")).toBe(false); + // The body is ciphertext: the notification text never travels in the clear. + expect(Buffer.from(init.body as Uint8Array).toString("utf8")).not.toContain("Chief finished"); + }); + + it("reports an expired subscription as gone", async () => { + for (const status of [404, 410]) { + const sender = new WebPushSender(vapid, { + fetch: async () => new Response(null, { status }), + resolveHostname: publicHost, + }); + await expect(sender.send(device, payload)).resolves.toBe("gone"); + } + }); + + it("throws on other failures without naming the endpoint", async () => { + const sender = new WebPushSender(vapid, { + fetch: async () => new Response("quota exceeded", { status: 429 }), + resolveHostname: publicHost, + }); + const error = await sender.send(device, payload).catch((caught: Error) => caught); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Web push failed (429)"); + expect((error as Error).message).not.toContain("push.example.test"); + }); + + it("never connects to a private or non-https endpoint", async () => { + const fetchMock = vi.fn(); + const privateHost = new WebPushSender(vapid, { + fetch: fetchMock, + resolveHostname: async () => [{ address: "10.0.0.5", family: 4 }], + }); + await expect(privateHost.send(device, payload)).rejects.toThrow("private address"); + const plainHttp = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect( + plainHttp.send({ ...device, endpoint: "http://push.example.test/device-1" }, payload), + ).rejects.toThrow("https"); + const loopback = new WebPushSender(vapid, { fetch: fetchMock, resolveHostname: publicHost }); + await expect( + loopback.send({ ...device, endpoint: "https://127.0.0.1/device-1" }, payload), + ).rejects.toThrow("private or internal host"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("treats a subscription without keys as gone", async () => { + const sender = new WebPushSender(vapid, { fetch: vi.fn(), resolveHostname: publicHost }); + await expect(sender.send({ ...device, p256dh: null }, payload)).resolves.toBe("gone"); + }); +}); diff --git a/packages/adapters/src/web-push.ts b/packages/adapters/src/web-push.ts new file mode 100644 index 0000000..9381523 --- /dev/null +++ b/packages/adapters/src/web-push.ts @@ -0,0 +1,207 @@ +import { lookup } from "node:dns/promises"; +import type { PrismaClient } from "@rakazo/db"; +import { getLogger } from "@rakazo/logging"; +import { Agent } from "undici"; +import webPush from "web-push"; +import { combineSignals } from "./connector-safety.js"; +import type { ResolveHostname } from "./network-address.js"; +import { createAddressCheckedLookup } from "./network-address.js"; +import type { PushOutcome, PushPayload, PushTarget } from "./push-notifications.js"; +import type { EncryptedSecretStore } from "./secrets.js"; +import { dispatcherFetch } from "./undici-fetch.js"; +import { assertPublicAddresses, assertSafeWebUrl } from "./web-ssrf.js"; + +/** Generated keys live encrypted in the deployment-wide settings table under this id. */ +const VAPID_RECORD_ID = "web-push-vapid"; +/** RFC 8292 wants a contact; deployments set VAPID_SUBJECT or serve the web app over https. */ +const FALLBACK_SUBJECT = "mailto:admin@localhost"; +const WEB_PUSH_TIMEOUT_MS = 15_000; +/** Undelivered pushes expire after a day; older news belongs in the inbox. */ +const WEB_PUSH_TTL_SECONDS = 86_400; + +export interface VapidDetails { + subject: string; + publicKey: string; + privateKey: string; +} + +export interface VapidEnv { + publicKey?: string; + privateKey?: string; + subject?: string; +} + +/** VAPID settings from the environment; the web origin is the contact when it is https. */ +export function vapidEnvFromProcess(source: Record): VapidEnv { + const value = (raw: string | undefined) => raw?.trim() || undefined; + const origin = value(source.WEB_ORIGIN); + return { + publicKey: value(source.VAPID_PUBLIC_KEY), + privateKey: value(source.VAPID_PRIVATE_KEY), + subject: value(source.VAPID_SUBJECT) ?? (origin?.startsWith("https://") ? origin : undefined), + }; +} + +function assertVapidDetails(details: VapidDetails): VapidDetails { + // Validates subject and both keys; the error messages never contain the private key. + webPush.getVapidHeaders( + "https://push.invalid", + details.subject, + details.publicKey, + details.privateKey, + "aes128gcm", + ); + return details; +} + +/** + * The deployment's VAPID key pair: the configured one, or one generated on first use and stored + * encrypted, so the API and every worker sign with the same keys. + */ +export class VapidKeys { + private pending: Promise | undefined; + + constructor( + private readonly prisma: PrismaClient, + private readonly secrets: EncryptedSecretStore, + private readonly env: VapidEnv = {}, + ) {} + + details(): Promise { + this.pending ??= this.resolve().catch((error: unknown) => { + this.pending = undefined; + throw error; + }); + return this.pending; + } + + /** The public key browsers subscribe with, or null when no usable key pair exists. */ + async publicKey(): Promise { + try { + return (await this.details()).publicKey; + } catch (error) { + getLogger().error("web push keys unavailable", error); + return null; + } + } + + private async resolve(): Promise { + const subject = this.env.subject ?? FALLBACK_SUBJECT; + if (this.env.publicKey && this.env.privateKey) { + return assertVapidDetails({ + subject, + publicKey: this.env.publicKey, + privateKey: this.env.privateKey, + }); + } + if (this.env.publicKey || this.env.privateKey) { + getLogger().warn("VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be set together; ignoring"); + } + const stored = await this.load(); + if (stored) return assertVapidDetails({ subject, ...stored }); + const generated = webPush.generateVAPIDKeys(); + const sealed = await this.secrets.put( + JSON.stringify({ publicKey: generated.publicKey, privateKey: generated.privateKey }), + { + operationId: "web-push.keys", + traceId: "web-push.keys", + spaceId: "", + userId: "", + signal: AbortSignal.timeout(WEB_PUSH_TIMEOUT_MS), + }, + VAPID_RECORD_ID, + ); + // When the API and a worker generate at once, the first insert wins and both read it back. + await this.prisma.integrationProviderConfig.createMany({ + data: [{ id: VAPID_RECORD_ID, ciphertext: sealed.ciphertext }], + skipDuplicates: true, + }); + const winner = await this.load(); + if (!winner) throw new Error("Web push keys could not be stored"); + return assertVapidDetails({ subject, ...winner }); + } + + private async load(): Promise<{ publicKey: string; privateKey: string } | null> { + const row = await this.prisma.integrationProviderConfig.findUnique({ + where: { id: VAPID_RECORD_ID }, + select: { ciphertext: true }, + }); + if (!row) return null; + const parsed = JSON.parse(this.secrets.load(row.ciphertext, VAPID_RECORD_ID)) as { + publicKey?: unknown; + privateKey?: unknown; + }; + if (typeof parsed.publicKey !== "string" || typeof parsed.privateKey !== "string") { + throw new Error("Stored web push keys are malformed"); + } + return { publicKey: parsed.publicKey, privateKey: parsed.privateKey }; + } +} + +export interface WebPushSenderOptions { + fetch?: typeof globalThis.fetch; + resolveHostname?: ResolveHostname; + timeoutMs?: number; +} + +const defaultResolveHostname: ResolveHostname = (hostname) => + lookup(hostname, { all: true, verbatim: true }); + +/** + * Sends encrypted web push messages (RFC 8030/8291/8292). The endpoint comes from a browser, so + * the request only ever connects to public addresses, pinned at connect time. + */ +export class WebPushSender { + constructor( + private readonly vapid: Pick, + private readonly options: WebPushSenderOptions = {}, + ) {} + + async send(target: PushTarget, payload: PushPayload, signal?: AbortSignal): Promise { + if (!target.p256dh || !target.auth) return "gone"; + const request = webPush.generateRequestDetails( + { endpoint: target.endpoint, keys: { p256dh: target.p256dh, auth: target.auth } }, + JSON.stringify({ + title: payload.title, + body: payload.body, + url: payload.url, + tag: payload.threadId, + kind: payload.kind, + }), + { vapidDetails: await this.vapid.details(), TTL: WEB_PUSH_TTL_SECONDS }, + ); + const resolve = this.options.resolveHostname ?? defaultResolveHostname; + const deadline = combineSignals( + signal, + AbortSignal.timeout(this.options.timeoutMs ?? WEB_PUSH_TIMEOUT_MS), + ); + const url = await assertSafeWebUrl(request.endpoint, resolve, deadline); + if (url.protocol !== "https:") throw new Error("Web push endpoint must use https"); + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (name.toLowerCase() !== "content-length") headers.set(name, String(value)); + } + const dispatcher = new Agent({ + connect: { lookup: createAddressCheckedLookup(resolve, assertPublicAddresses) }, + }); + try { + const response = await (this.options.fetch ?? dispatcherFetch)(url.href, { + method: "POST", + headers, + body: request.body, + redirect: "manual", + signal: deadline, + dispatcher, + } as RequestInit & { dispatcher: Agent }); + await response.body?.cancel().catch(() => undefined); + if (response.status === 404 || response.status === 410) return "gone"; + if (response.status < 200 || response.status >= 300) { + // Status only: push service bodies and endpoints identify the device. + throw new Error(`Web push failed (${response.status})`); + } + return "sent"; + } finally { + await dispatcher.close().catch(() => undefined); + } + } +} diff --git a/packages/adapters/src/web-ssrf.ts b/packages/adapters/src/web-ssrf.ts index 869cb80..25b7c3d 100644 --- a/packages/adapters/src/web-ssrf.ts +++ b/packages/adapters/src/web-ssrf.ts @@ -229,7 +229,7 @@ export async function readBodyCapped( }); } -function assertPublicAddresses(addresses: ResolvedAddress[]): void { +export function assertPublicAddresses(addresses: ResolvedAddress[]): void { if (addresses.length === 0 || addresses.some((entry) => isPrivateAddress(entry.address))) { throw new Error("URL resolves to a private address"); } diff --git a/packages/contracts/src/clock-time.ts b/packages/contracts/src/clock-time.ts new file mode 100644 index 0000000..e8d1464 --- /dev/null +++ b/packages/contracts/src/clock-time.ts @@ -0,0 +1,19 @@ +import * as z from "zod"; + +const CLOCK_TIME = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** A 24-hour wall-clock time, e.g. "22:30". */ +export const ClockTimeSchema = z.string().regex(CLOCK_TIME, "Use HH:MM"); + +/** Minutes after midnight for a "HH:MM" time. */ +export function clockTimeToMinutes(value: string): number { + const match = CLOCK_TIME.exec(value); + if (!match) throw new Error("Use HH:MM"); + return Number(match[1]) * 60 + Number(match[2]); +} + +/** "HH:MM" for minutes after midnight. */ +export function minutesToClockTime(minutes: number): string { + const pad = (value: number) => String(value).padStart(2, "0"); + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} diff --git a/packages/contracts/src/coach.ts b/packages/contracts/src/coach.ts new file mode 100644 index 0000000..d2d5fdd --- /dev/null +++ b/packages/contracts/src/coach.ts @@ -0,0 +1,224 @@ +import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; +import { Id } from "./ids.js"; + +export const COACH_TITLE_MAX_LENGTH = 200; +export const GOAL_DETAIL_MAX_LENGTH = 2_000; +export const CHECK_IN_NOTE_MAX_LENGTH = 1_000; +export const DAILY_BRIEF_MAX_LENGTH = 4_000; +/** Upper bounds per user, so a runaway writer cannot grow the coach data without end. */ +export const COACH_ACTIVE_GOAL_LIMIT = 100; +export const COACH_ACTIVE_HABIT_LIMIT = 100; + +export const GOAL_STATUSES = ["active", "done", "dropped"] as const; +export const GoalStatusSchema = z.enum(GOAL_STATUSES); +export type GoalStatus = z.infer; + +/** Who started tracking a goal or habit: the user, onboarding, or the main bot in a chat. */ +export const COACH_SOURCES = ["user", "onboarding", "chat"] as const; +export const CoachSourceSchema = z.enum(COACH_SOURCES); +export type CoachSource = z.infer; + +export const HABIT_CADENCES = ["daily", "weekdays", "weekly"] as const; +export const HabitCadenceSchema = z.enum(HABIT_CADENCES); +export type HabitCadence = z.infer; + +export const CHECK_IN_KINDS = ["habit", "goal", "daily"] as const; +export const CheckInKindSchema = z.enum(CHECK_IN_KINDS); +export type CheckInKind = z.infer; + +function isCalendarDate(value: string): boolean { + const date = new Date(`${value}T00:00:00Z`); + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value; +} + +/** A calendar date in the user's time zone, e.g. "2026-10-03". */ +export const LocalDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD") + .refine(isCalendarDate, "Invalid date"); + +/** A 24-hour wall-clock time in the user's time zone, e.g. "07:30". */ + +/** When the main bot checks in with the user; null turns a check-in off. */ +export const CoachRhythmSchema = z.object({ + morning: ClockTimeSchema.nullable(), + evening: ClockTimeSchema.nullable(), + /** day: 0 = Sunday … 6 = Saturday. */ + weekly: z.object({ day: z.number().int().min(0).max(6), time: ClockTimeSchema }).nullable(), +}); +export type CoachRhythm = z.infer; + +export const COACH_RHYTHM_OFF: CoachRhythm = { morning: null, evening: null, weekly: null }; + +/** The main bot's check-in routines, one per key; a routine carries its key as `coachKey`. */ +export const COACH_ROUTINE_KEYS = ["morning", "evening", "weekly"] as const; +export const CoachRoutineKeySchema = z.enum(COACH_ROUTINE_KEYS); +export type CoachRoutineKey = z.infer; + +/** Stored names of the check-in routines; the web shows them in the user's language. */ +export const COACH_ROUTINE_NAMES: Record = { + morning: "Morning brief", + evening: "Evening check-in", + weekly: "Weekly review", +}; + +export const GoalSchema = z.object({ + id: Id, + title: z.string(), + detail: z.string(), + status: GoalStatusSchema, + targetDate: LocalDateSchema.nullable(), + spaceId: Id.nullable(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + completedAt: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Goal = z.infer; + +export const HabitSchema = z.object({ + id: Id, + title: z.string(), + cadence: HabitCadenceSchema, + goalId: Id.nullable(), + active: z.boolean(), + source: CoachSourceSchema, + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type Habit = z.infer; + +const Rating = z.number().int().min(1).max(5); + +export const CheckInSchema = z.object({ + id: Id, + kind: CheckInKindSchema, + habitId: Id.nullable(), + goalId: Id.nullable(), + date: LocalDateSchema, + done: z.boolean().nullable(), + mood: Rating.nullable(), + energy: Rating.nullable(), + note: z.string(), + createdByBotId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type CheckIn = z.infer; + +export const DailyBriefSchema = z.object({ + id: Id, + date: LocalDateSchema, + text: z.string(), + botId: Id.nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}); +export type DailyBrief = z.infer; + +export const HabitTodaySchema = HabitSchema.extend({ + /** Done in the current period: today, or this week for a weekly habit. */ + doneToday: z.boolean(), + /** Periods in a row the habit was done, counting the current one only once it is done. */ + streak: z.number().int().min(0), +}); +export type HabitToday = z.infer; + +/** The user's day as the coach sees it, in the user's time zone. */ +export const CoachTodaySchema = z.object({ + date: LocalDateSchema, + timeZone: z.string(), + brief: DailyBriefSchema.nullable(), + goals: z.array(GoalSchema), + habits: z.array(HabitTodaySchema), + /** Today's daily check-in (how the day went), if any. */ + checkIn: CheckInSchema.nullable(), +}); +export type CoachToday = z.infer; + +const Title = z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH); +const Detail = z.string().trim().max(GOAL_DETAIL_MAX_LENGTH); + +export const CreateGoalInput = z.object({ + title: Title, + detail: Detail.optional(), + targetDate: LocalDateSchema.nullable().optional(), + /** A space the goal belongs to; null or omitted for a personal goal. */ + spaceId: Id.nullable().optional(), +}); + +export const UpdateGoalInput = z.object({ + goalId: Id, + title: Title.optional(), + detail: Detail.optional(), + status: GoalStatusSchema.optional(), + targetDate: LocalDateSchema.nullable().optional(), + spaceId: Id.nullable().optional(), +}); + +export const ListGoalsInput = z.object({ status: GoalStatusSchema.optional() }); + +export const CreateHabitInput = z.object({ + title: Title, + cadence: HabitCadenceSchema.default("daily"), + goalId: Id.nullable().optional(), +}); + +export const UpdateHabitInput = z.object({ + habitId: Id, + title: Title.optional(), + cadence: HabitCadenceSchema.optional(), + goalId: Id.nullable().optional(), + active: z.boolean().optional(), +}); + +export const ListHabitsInput = z.object({ includeInactive: z.boolean().optional() }); + +/** + * Logs or updates the check-in for one day: a habit or goal check-in needs its id, the daily + * one needs neither. Fields left out keep their stored value. + */ +export const LogCheckInInput = z + .object({ + kind: CheckInKindSchema, + habitId: Id.optional(), + goalId: Id.optional(), + /** Defaults to today in the user's time zone. */ + date: LocalDateSchema.optional(), + done: z.boolean().nullable().optional(), + mood: Rating.nullable().optional(), + energy: Rating.nullable().optional(), + note: z.string().trim().max(CHECK_IN_NOTE_MAX_LENGTH).optional(), + }) + .superRefine((input, ctx) => { + const wantsHabit = input.kind === "habit"; + const wantsGoal = input.kind === "goal"; + if (wantsHabit !== Boolean(input.habitId)) { + ctx.addIssue({ + code: "custom", + message: wantsHabit + ? "A habit check-in needs habitId" + : "Only a habit check-in takes habitId", + path: ["habitId"], + }); + } + if (wantsGoal !== Boolean(input.goalId)) { + ctx.addIssue({ + code: "custom", + message: wantsGoal ? "A goal check-in needs goalId" : "Only a goal check-in takes goalId", + path: ["goalId"], + }); + } + }); +export type LogCheckIn = z.infer; + +export const ListCheckInsInput = z.object({ + from: LocalDateSchema.optional(), + to: LocalDateSchema.optional(), + kind: CheckInKindSchema.optional(), + habitId: Id.optional(), + goalId: Id.optional(), +}); diff --git a/packages/contracts/src/domain.ts b/packages/contracts/src/domain.ts index 88abbcb..d7c2fc3 100644 --- a/packages/contracts/src/domain.ts +++ b/packages/contracts/src/domain.ts @@ -1,5 +1,6 @@ import * as z from "zod"; -import { DelegatedTaskStatusSchema, ThreadMessageSchema } from "./events.js"; +import { CoachRoutineKeySchema } from "./coach.js"; +import { DelegatedTaskStatusSchema, IntakeBlockSchema, ThreadMessageSchema } from "./events.js"; import { Id, MemoryScope, RunStatus, SandboxKind } from "./ids.js"; import { McpHeadersSchema, McpRemoteEndpointSchema, McpTransportSchema } from "./mcp.js"; @@ -129,6 +130,8 @@ export const GroupSchema = z.object({ members: z.array(GroupMemberSchema), threadId: Id, preview: z.string(), + /** The latest message when it is an onboarding card or note; the web words it from this. */ + previewBlock: IntakeBlockSchema.optional(), unread: z.boolean(), updatedAt: z.string(), createdAt: z.string(), @@ -201,6 +204,7 @@ export const SpaceGroupSchema = GroupSchema.pick({ sectionId: true, members: true, preview: true, + previewBlock: true, unread: true, updatedAt: true, }); @@ -458,6 +462,8 @@ export const RoutineSchema = z.object({ lastRunAt: z.string().nullable(), nextRunAt: z.string().nullable(), createdAt: z.string(), + /** Set on the main bot's check-in routines. */ + coachKey: CoachRoutineKeySchema.nullable(), }); export type Routine = z.infer; diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index 264577d..5db6c34 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"), @@ -123,6 +137,41 @@ export const AskBlockSchema = z.object({ .optional(), }); +export const ChoiceBlockSchema = z.object({ + 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(), +}); + +/** + * A line onboarding writes. Clients that know `note` say it in the user's language and + * write the time where they are; everything else (mobile, agent history) uses `text`. + */ +export const IntakeNoteBlockSchema = z.object({ + kind: z.literal("intake_note"), + /** "done": onboarding is complete; the first check-in follows at `nextCheckInAt`. */ + note: z.enum(["done"]), + nextCheckInAt: z.string().nullable(), + text: z.string(), +}); + +/** What onboarding writes into a conversation: its cards and its closing note. */ +export const IntakeBlockSchema = z.discriminatedUnion("kind", [ + ChoiceBlockSchema, + IntakeNoteBlockSchema, +]); +export type IntakeBlock = z.infer; + export const MessageBlock = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("text"), text: z.string() }), z.object({ @@ -130,14 +179,8 @@ export const MessageBlock = z.discriminatedUnion("kind", [ lines: z.array(z.object({ k: z.string(), v: z.string() })), }), AskBlockSchema, - z.object({ - kind: z.literal("choice"), - question: z.string(), - subtitle: z.string().optional(), - options: z.array(z.object({ id: z.string(), letter: z.string(), label: z.string() })), - /** Set once the user picks an option; renders the picker as answered. */ - answerId: z.string().optional(), - }), + ChoiceBlockSchema, + IntakeNoteBlockSchema, z.object({ /** Inline app authorization card (Composio-backed): logo, name, one-line description, and an Authorize button that flips to connected. */ diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 7894cd2..3bd318d 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -13,6 +13,11 @@ const InboxItemBase = z.object({ /** The bot that needs the user, shown as the sender. */ bot: z.object({ id: Id, name: z.string(), color: z.string() }), target: InboxThreadTargetSchema, + /** + * Set when the item belongs to another of the user's spaces: call the thread procedures and + * open the thread in that space. + */ + space: z.object({ id: Id, name: z.string() }).optional(), createdAt: z.string(), }); @@ -24,8 +29,8 @@ const PendingAskItem = InboxItemBase.extend({ }); /** - * What a bot needs from the user outside the main conversation: an answer, an approval, the - * computer screen, or a look at an unread thread. + * What a bot needs from the user outside the main conversation, in any of the user's spaces: an + * answer, an approval, the computer screen, or a look at an unread thread. */ export const InboxItemSchema = z.discriminatedUnion("kind", [ PendingAskItem.extend({ kind: z.literal("ask") }), diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ecbfdb7..814b07a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,8 @@ export * from "./ai-consent.js"; export * from "./attachments.js"; export * from "./bot-secrets.js"; +export * from "./clock-time.js"; +export * from "./coach.js"; export * from "./computer-pointer.js"; export * from "./desktop.js"; export * from "./domain.js"; @@ -11,6 +13,8 @@ export * from "./integration-settings.js"; export * from "./local-settings.js"; export * from "./mcp.js"; export * from "./model-tiers.js"; +export * from "./notifications.js"; +export * from "./onboarding-scan.js"; export * from "./openai-compatible-ui.js"; export * from "./project-goals.js"; export * from "./project-sessions.js"; diff --git a/packages/contracts/src/notifications.ts b/packages/contracts/src/notifications.ts new file mode 100644 index 0000000..bcb7cc3 --- /dev/null +++ b/packages/contracts/src/notifications.ts @@ -0,0 +1,61 @@ +import * as z from "zod"; +import { ClockTimeSchema } from "./clock-time.js"; + +/** How many nudges the main agent may send per day in the user's time zone. */ +export const NUDGES_PER_DAY = 3; + +/** A daily range in the user's profile time zone; it may cross midnight ("22:00"–"07:00"). */ +export const QuietHoursSchema = z + .object({ start: ClockTimeSchema, end: ClockTimeSchema }) + .refine((range) => range.start !== range.end, "Start and end must differ"); +export type QuietHours = z.infer; + +export const NotificationSettingsSchema = z.object({ + /** Every push is held back during quiet hours; the inbox still lists what happened. */ + quietHours: QuietHoursSchema.nullable(), + /** Whether the main agent may notify the user on its own initiative. */ + nudges: z.boolean(), +}); +export type NotificationSettings = z.infer; + +export const UpdateNotificationSettingsInput = NotificationSettingsSchema.partial(); +export type NotificationSettingsPatch = z.infer; + +/** What a browser needs to subscribe; `available` is false when the server cannot send. */ +export const WebPushConfigSchema = z.object({ + available: z.boolean(), + publicKey: z.string().nullable(), +}); +export type WebPushConfig = z.infer; + +/** A push service URL. Browsers only hand out https endpoints. */ +export const WebPushEndpointSchema = z + .string() + .max(2048) + .refine((value) => { + try { + const url = new URL(value); + return url.protocol === "https:" && !url.username && !url.password; + } catch { + return false; + } + }, "Endpoint must be an https URL"); + +/** The mobile app's Expo push token, e.g. "ExponentPushToken[xxxxxxxx]". */ +export const ExpoPushTokenSchema = z + .string() + .trim() + .max(512) + .regex(/^Expo(nent)?PushToken\[[^\]\s]+\]$/, "Invalid Expo push token"); + +/** The JSON form of a browser PushSubscription (RFC 8291 keys, base64url). */ +export const WebPushSubscriptionInput = z.object({ + endpoint: WebPushEndpointSchema, + keys: z.object({ + /** An uncompressed P-256 public key: 65 bytes. */ + p256dh: z.string().regex(/^[A-Za-z0-9_-]{87}=?$/, "Invalid p256dh key"), + /** A 16-byte authentication secret. */ + auth: z.string().regex(/^[A-Za-z0-9_-]{22}(==)?$/, "Invalid auth secret"), + }), +}); +export type WebPushSubscriptionJson = z.infer; diff --git a/packages/contracts/src/onboarding-scan.ts b/packages/contracts/src/onboarding-scan.ts new file mode 100644 index 0000000..97faa35 --- /dev/null +++ b/packages/contracts/src/onboarding-scan.ts @@ -0,0 +1,49 @@ +import * as z from "zod"; +import { Id } from "./ids.js"; + +/** Upper bounds on one scan's choices; the server lists at most this many projects and facts. */ +export const ONBOARDING_SCAN_MAX_PROJECTS = 30; +export const ONBOARDING_SCAN_MAX_FACTS = 20; + +/** The folder a consented scan would read, or null when this account cannot scan. */ +export const OnboardingScanTargetSchema = z.object({ folder: z.string().nullable() }); +export type OnboardingScanTarget = z.infer; + +export const OnboardingScanProjectSchema = z.object({ + id: z.string(), + name: z.string(), + /** Relative to the scanned folder. */ + path: z.string(), +}); +export type OnboardingScanProject = z.infer; + +export const OnboardingScanFactSchema = z.object({ + id: z.string(), + /** A language or tool name, e.g. "TypeScript". */ + label: z.string(), +}); +export type OnboardingScanFact = z.infer; + +export const OnboardingScanStatusSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("scanning") }), + z.object({ status: z.literal("failed") }), + z.object({ + status: z.literal("complete"), + projects: z.array(OnboardingScanProjectSchema), + facts: z.array(OnboardingScanFactSchema), + }), +]); +export type OnboardingScanStatus = z.infer; + +export const OnboardingScanApplyInputSchema = z.object({ + scanId: z.string(), + projectIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_PROJECTS), + factIds: z.array(z.string()).max(ONBOARDING_SCAN_MAX_FACTS), +}); +export type OnboardingScanApplyInput = z.infer; + +export const OnboardingScanApplyResultSchema = z.object({ + spaces: z.array(z.object({ id: Id, name: z.string() })), + factsAdded: z.number().int().nonnegative(), +}); +export type OnboardingScanApplyResult = z.infer; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c4562c4..ab43522 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -2,6 +2,22 @@ import { eventIterator, oc } from "@orpc/contract"; import * as z from "zod"; import { AiConsentQuerySchema, AiConsentStatusSchema } from "./ai-consent.js"; import { ATTACHMENT_MAX_BASE64_LENGTH, ATTACHMENT_MAX_COUNT } from "./attachments.js"; +import { + CheckInSchema, + COACH_TITLE_MAX_LENGTH, + CoachRhythmSchema, + CoachTodaySchema, + CreateGoalInput, + CreateHabitInput, + GoalSchema, + HabitSchema, + ListCheckInsInput, + ListGoalsInput, + ListHabitsInput, + LogCheckInInput, + UpdateGoalInput, + UpdateHabitInput, +} from "./coach.js"; import { ActionApprovalRuleSchema, ActionAutoReviewSettingsSchema, @@ -76,7 +92,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 { @@ -84,6 +100,20 @@ import { IntegrationSetupStateSchema, } from "./integration-settings.js"; import { ModelTierMappingSchema, SetModelTierInput } from "./model-tiers.js"; +import { + ExpoPushTokenSchema, + NotificationSettingsSchema, + UpdateNotificationSettingsInput, + WebPushConfigSchema, + WebPushEndpointSchema, + WebPushSubscriptionInput, +} from "./notifications.js"; +import { + OnboardingScanApplyInputSchema, + OnboardingScanApplyResultSchema, + OnboardingScanStatusSchema, + OnboardingScanTargetSchema, +} from "./onboarding-scan.js"; import { CreateProjectGoalInput, ProjectGoalSchema, @@ -183,6 +213,30 @@ export const appContract = { update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema), forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema), }, + /** The user's goals, habits, check-ins and check-in times; owner-only, across spaces. */ + coach: { + today: oc.output(CoachTodaySchema), + goals: { + list: oc.input(ListGoalsInput).output(z.array(GoalSchema)), + create: oc.input(CreateGoalInput).output(GoalSchema), + update: oc.input(UpdateGoalInput).output(GoalSchema), + remove: oc.input(z.object({ goalId: Id })).output(z.object({ ok: z.literal(true) })), + }, + habits: { + list: oc.input(ListHabitsInput).output(z.array(HabitSchema)), + create: oc.input(CreateHabitInput).output(HabitSchema), + update: oc.input(UpdateHabitInput).output(HabitSchema), + remove: oc.input(z.object({ habitId: Id })).output(z.object({ ok: z.literal(true) })), + }, + checkIns: { + log: oc.input(LogCheckInInput).output(CheckInSchema), + list: oc.input(ListCheckInsInput).output(z.array(CheckInSchema)), + }, + rhythm: { + get: oc.output(CoachRhythmSchema), + set: oc.input(CoachRhythmSchema).output(CoachRhythmSchema), + }, + }, spaces: { list: oc.output(SpaceNavigationSchema), create: oc @@ -663,15 +717,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"; only the goal card takes one. */ + text: z.string().trim().min(1).max(COACH_TITLE_MAX_LENGTH).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 @@ -679,15 +749,13 @@ export const appContract = { z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }), ) .output(z.object({ ok: z.literal(true) })), - scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })), - scanStatus: oc.input(z.object({ scanId: z.string() })).output( - z.object({ - status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]), - progress: z.number(), - message: z.string(), - result: z.any().optional(), - }), - ), + /** The folder a scan would read; null when this account cannot scan, so the step is skipped. */ + scanTarget: oc.output(OnboardingScanTargetSchema), + /** Start scanning that folder. Only called after the user agreed. */ + scan: oc.output(z.object({ scanId: z.string() })), + scanStatus: oc.input(z.object({ scanId: z.string() })).output(OnboardingScanStatusSchema), + /** Create a project space per chosen project and save the chosen facts. */ + applyScan: oc.input(OnboardingScanApplyInputSchema).output(OnboardingScanApplyResultSchema), }, integrationSetup: { get: oc.output(IntegrationSetupStateSchema), @@ -803,9 +871,20 @@ export const appContract = { }, notifications: { registerPush: oc - .input(z.object({ token: z.string().min(8).max(512) })) + .input(z.object({ token: ExpoPushTokenSchema })) .output(z.object({ ok: z.literal(true) })), unregisterPush: oc.output(z.object({ ok: z.literal(true) })), + settings: { + get: oc.output(NotificationSettingsSchema), + update: oc.input(UpdateNotificationSettingsInput).output(NotificationSettingsSchema), + }, + webPush: { + config: oc.output(WebPushConfigSchema), + subscribe: oc.input(WebPushSubscriptionInput).output(z.object({ ok: z.literal(true) })), + unsubscribe: oc + .input(z.object({ endpoint: WebPushEndpointSchema })) + .output(z.object({ ok: z.literal(true) })), + }, }, search: { query: oc.input(z.object({ q: z.string().max(200) })).output(SearchQueryOutputSchema), @@ -818,7 +897,7 @@ export const appContract = { .input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() })) .output(z.array(DelegatedTaskSchema)), }, - /** Asks, approvals, takeovers and unread threads outside the main conversation. */ + /** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */ inbox: { list: oc.output(InboxListOutputSchema), }, diff --git a/packages/core/src/attachments.test.ts b/packages/core/src/attachments.test.ts index 0a3d661..cd17d8f 100644 --- a/packages/core/src/attachments.test.ts +++ b/packages/core/src/attachments.test.ts @@ -109,3 +109,40 @@ 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?", + ); + }); + + it("reads the onboarding closing note as its English line", () => { + expect( + blocksToAgentHistoryText([ + { + kind: "intake_note", + note: "done", + nextCheckInAt: "2026-10-08T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00 AM.", + }, + ]), + ).toBe("All set. I’ll check in tomorrow at 8:00 AM."); + }); +}); 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/core/src/coach.test.ts b/packages/core/src/coach.test.ts new file mode 100644 index 0000000..4f8f816 --- /dev/null +++ b/packages/core/src/coach.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + localDateIn, + weekdayOf, + weekStartOf, +} from "./coach.js"; + +const dates = (...values: string[]) => new Set(values); + +describe("calendar dates", () => { + it("reads the date in the user's time zone, not the server's", () => { + const instant = new Date("2026-10-03T23:30:00Z"); + expect(localDateIn("UTC", instant)).toBe("2026-10-03"); + expect(localDateIn("Europe/Vienna", instant)).toBe("2026-10-04"); + expect(localDateIn("America/Los_Angeles", instant)).toBe("2026-10-03"); + expect(localDateIn("Pacific/Kiritimati", new Date("2026-10-03T10:30:00Z"))).toBe("2026-10-04"); + }); + + it("moves across months, years and daylight saving changes by whole days", () => { + expect(addDays("2026-10-31", 1)).toBe("2026-11-01"); + expect(addDays("2027-01-01", -1)).toBe("2026-12-31"); + expect(addDays("2026-03-28", 2)).toBe("2026-03-30"); + expect(weekdayOf("2026-10-03")).toBe(6); + expect(weekStartOf("2026-10-03")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-04")).toBe("2026-09-28"); + expect(weekStartOf("2026-10-05")).toBe("2026-10-05"); + }); +}); + +describe("habit streaks", () => { + const saturday = "2026-10-03"; + + it("counts consecutive days and keeps today open until it is done", () => { + const done = dates("2026-09-30", "2026-10-01", "2026-10-02"); + expect(habitStreak("daily", done, saturday)).toBe(3); + expect(habitStreak("daily", new Set([...done, saturday]), saturday)).toBe(4); + expect(habitStreak("daily", dates("2026-09-30", "2026-10-01"), saturday)).toBe(0); + expect(habitStreak("daily", dates(), saturday)).toBe(0); + }); + + it("skips weekends for weekday habits", () => { + const monday = "2026-10-05"; + const done = dates("2026-10-01", "2026-10-02"); + expect(habitStreak("weekdays", done, saturday)).toBe(2); + expect(habitStreak("weekdays", done, monday)).toBe(2); + expect(habitStreak("weekdays", new Set([...done, monday]), monday)).toBe(3); + expect(habitStreak("weekdays", dates("2026-10-01"), monday)).toBe(0); + }); + + it("counts weeks with at least one done day for weekly habits", () => { + const done = dates("2026-09-15", "2026-09-26", "2026-09-29"); + expect(habitStreak("weekly", done, saturday)).toBe(3); + expect(habitStreak("weekly", dates("2026-09-15", "2026-09-26"), saturday)).toBe(2); + expect(habitStreak("weekly", dates("2026-09-15"), saturday)).toBe(0); + }); + + it("reads a weekly habit as done for the rest of the week", () => { + expect(habitDoneInPeriod("weekly", dates("2026-09-29"), saturday)).toBe(true); + expect(habitDoneInPeriod("weekly", dates("2026-09-27"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates("2026-10-02"), saturday)).toBe(false); + expect(habitDoneInPeriod("daily", dates(saturday), saturday)).toBe(true); + }); +}); diff --git a/packages/core/src/coach.ts b/packages/core/src/coach.ts new file mode 100644 index 0000000..caddf24 --- /dev/null +++ b/packages/core/src/coach.ts @@ -0,0 +1,99 @@ +import type { HabitCadence } from "@rakazo/contracts"; + +const DAY_MS = 86_400_000; + +function utcMidnight(date: string): number { + return Date.parse(`${date}T00:00:00Z`); +} + +/** The calendar date at `now` in `timeZone`, e.g. "2026-10-03". */ +export function localDateIn(timeZone: string, now: Date = new Date()): string { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`; +} + +/** `date` moved by `days` calendar days. */ +export function addDays(date: string, days: number): string { + return new Date(utcMidnight(date) + days * DAY_MS).toISOString().slice(0, 10); +} + +/** Day of the week, 0 = Sunday … 6 = Saturday. */ +export function weekdayOf(date: string): number { + return new Date(utcMidnight(date)).getUTCDay(); +} + +/** The Monday that starts the week containing `date`. */ +export function weekStartOf(date: string): string { + return addDays(date, -((weekdayOf(date) + 6) % 7)); +} + +function isWeekday(date: string): boolean { + const day = weekdayOf(date); + return day >= 1 && day <= 5; +} + +/** The latest Monday–Friday date strictly before `date`. */ +function previousWeekday(date: string): string { + let cursor = addDays(date, -1); + while (!isWeekday(cursor)) cursor = addDays(cursor, -1); + return cursor; +} + +/** Whether the habit is done in the period that contains `today`. */ +export function habitDoneInPeriod( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): boolean { + if (cadence !== "weekly") return doneDates.has(today); + const start = weekStartOf(today); + for (let date = start; date <= today; date = addDays(date, 1)) { + if (doneDates.has(date)) return true; + } + return false; +} + +/** + * Periods in a row the habit was done, ending today. The current period counts once it is done; + * until then it stays open and does not break the streak. Weekday habits skip weekends, weekly + * habits count Monday-to-Sunday weeks with at least one done day. + */ +export function habitStreak( + cadence: HabitCadence, + doneDates: ReadonlySet, + today: string, +): number { + let streak = 0; + if (cadence === "weekly") { + const weeks = new Set([...doneDates].map(weekStartOf)); + const current = weekStartOf(today); + for (let week = weeks.has(current) ? current : addDays(current, -7); weeks.has(week); ) { + streak += 1; + week = addDays(week, -7); + } + return streak; + } + if (cadence === "weekdays") { + let cursor = isWeekday(today) && doneDates.has(today) ? today : previousWeekday(today); + while (doneDates.has(cursor)) { + streak += 1; + cursor = previousWeekday(cursor); + } + return streak; + } + let cursor = doneDates.has(today) ? today : addDays(today, -1); + while (doneDates.has(cursor)) { + streak += 1; + cursor = addDays(cursor, -1); + } + return streak; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0b9a281..39f0baa 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from "./bot-messages.js"; export * from "./bot-reply-sanitize.js"; export * from "./bot-sections.js"; export * from "./cloud-agent.js"; +export * from "./coach.js"; export * from "./compose-update.js"; export * from "./composer-mention-picker.js"; export * from "./composer-mentions.js"; @@ -30,6 +31,7 @@ export * from "./messaging-prompts.js"; export * from "./model-oauth.js"; export * from "./model-probe.js"; export * from "./model-providers.js"; +export * from "./quiet-hours.js"; export * from "./response-bytes.js"; export * from "./run-state.js"; export * from "./sandbox-command.js"; diff --git a/packages/core/src/quiet-hours.test.ts b/packages/core/src/quiet-hours.test.ts new file mode 100644 index 0000000..183ee65 --- /dev/null +++ b/packages/core/src/quiet-hours.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { isQuietMinute, isQuietTime, localDateAndMinute } from "./quiet-hours.js"; + +const minute = (clock: string) => { + const [hour, min] = clock.split(":").map(Number); + return hour! * 60 + min!; +}; + +describe("quiet hours", () => { + it("covers a same-day range with an inclusive start and an exclusive end", () => { + const lunch = { start: minute("12:00"), end: minute("13:30") }; + expect(isQuietMinute(lunch, minute("11:59"))).toBe(false); + expect(isQuietMinute(lunch, minute("12:00"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:29"))).toBe(true); + expect(isQuietMinute(lunch, minute("13:30"))).toBe(false); + }); + + it("covers a range that crosses midnight", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietMinute(night, minute("21:59"))).toBe(false); + expect(isQuietMinute(night, minute("22:00"))).toBe(true); + expect(isQuietMinute(night, minute("23:59"))).toBe(true); + expect(isQuietMinute(night, minute("00:00"))).toBe(true); + expect(isQuietMinute(night, minute("06:59"))).toBe(true); + expect(isQuietMinute(night, minute("07:00"))).toBe(false); + expect(isQuietMinute(night, minute("12:00"))).toBe(false); + }); + + it("is never quiet without a range or with an empty one", () => { + expect(isQuietMinute(null, minute("03:00"))).toBe(false); + expect(isQuietMinute({ start: 300, end: 300 }, 300)).toBe(false); + }); + + it("reads the wall clock in the user's time zone", () => { + // 21:30 UTC is 23:30 in Vienna (CEST, UTC+2) and 14:30 in Los Angeles (PDT, UTC-7). + const now = new Date("2026-07-01T21:30:00Z"); + const night = { start: minute("22:00"), end: minute("07:00") }; + expect(isQuietTime(night, "Europe/Vienna", now)).toBe(true); + expect(isQuietTime(night, "America/Los_Angeles", now)).toBe(false); + expect(isQuietTime(night, null, now)).toBe(false); + }); + + it("falls back to UTC for an unknown zone and reports the local date", () => { + const now = new Date("2026-01-31T23:15:00Z"); + expect(localDateAndMinute(now, "Not/AZone")).toEqual({ + date: "2026-01-31", + minute: minute("23:15"), + }); + // Already the next day in Tokyo (UTC+9). + expect(localDateAndMinute(now, "Asia/Tokyo")).toEqual({ + date: "2026-02-01", + minute: minute("08:15"), + }); + }); + + it("follows daylight saving changes", () => { + const night = { start: minute("22:00"), end: minute("07:00") }; + // 05:30 UTC is 06:30 in Vienna in winter (CET) and 07:30 in summer (CEST). + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-01-15T05:30:00Z"))).toBe(true); + expect(isQuietTime(night, "Europe/Vienna", new Date("2026-07-15T05:30:00Z"))).toBe(false); + }); +}); diff --git a/packages/core/src/quiet-hours.ts b/packages/core/src/quiet-hours.ts new file mode 100644 index 0000000..11b7c4d --- /dev/null +++ b/packages/core/src/quiet-hours.ts @@ -0,0 +1,48 @@ +import { knownTimeZone } from "./cron.js"; + +/** Minutes after local midnight; `start` > `end` means the range crosses midnight. */ +export interface MinuteRange { + start: number; + end: number; +} + +/** The local calendar date ("YYYY-MM-DD") and minute of day at `now`, UTC when the zone is unknown. */ +export function localDateAndMinute( + now: Date, + timeZone: string | null | undefined, +): { date: string; minute: number } { + const parts = new Map( + new Intl.DateTimeFormat("en-US", { + timeZone: knownTimeZone(timeZone) ?? "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }) + .formatToParts(now) + .map((part) => [part.type, part.value]), + ); + return { + date: `${parts.get("year")}-${parts.get("month")}-${parts.get("day")}`, + minute: Number(parts.get("hour")) * 60 + Number(parts.get("minute")), + }; +} + +/** True when `minute` lies in the range; the start is inclusive, the end exclusive. */ +export function isQuietMinute(range: MinuteRange | null, minute: number): boolean { + if (!range || range.start === range.end) return false; + return range.start < range.end + ? minute >= range.start && minute < range.end + : minute >= range.start || minute < range.end; +} + +/** True when `now` falls in the quiet hours of a user in `timeZone`. */ +export function isQuietTime( + range: MinuteRange | null, + timeZone: string | null | undefined, + now: Date, +): boolean { + return isQuietMinute(range, localDateAndMinute(now, timeZone).minute); +} 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/migrations/20261003122000_coach_goals_and_habits/migration.sql b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql new file mode 100644 index 0000000..4be9942 --- /dev/null +++ b/packages/db/prisma/migrations/20261003122000_coach_goals_and_habits/migration.sql @@ -0,0 +1,144 @@ +-- The main bot coaches the user: goals, habits, daily check-ins and a short brief per day. Dates +-- are the user's local calendar dates. Like the other provenance ids, createdByBotId and botId +-- have no foreign key. + +-- AlterTable +ALTER TABLE "routines" ADD COLUMN "coachKey" TEXT; + +-- CreateTable +CREATE TABLE "user_goals" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "detail" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'active', + "targetDate" DATE, + "spaceId" TEXT, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "completedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_goals_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "habits" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "cadence" TEXT NOT NULL DEFAULT 'daily', + "goalId" TEXT, + "active" BOOLEAN NOT NULL DEFAULT true, + "source" TEXT NOT NULL, + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "habits_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "check_ins" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "habitId" TEXT, + "goalId" TEXT, + "date" DATE NOT NULL, + "done" BOOLEAN, + "mood" INTEGER, + "energy" INTEGER, + "note" TEXT NOT NULL DEFAULT '', + "createdByBotId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "check_ins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "daily_briefs" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "date" DATE NOT NULL, + "text" TEXT NOT NULL, + "botId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "daily_briefs_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "routines_botId_coachKey_key" ON "routines"("botId", "coachKey"); + +-- CreateIndex +CREATE INDEX "user_goals_userId_status_createdAt_idx" ON "user_goals"("userId", "status", "createdAt"); + +-- CreateIndex +CREATE INDEX "user_goals_spaceId_idx" ON "user_goals"("spaceId"); + +-- CreateIndex +CREATE INDEX "habits_userId_active_createdAt_idx" ON "habits"("userId", "active", "createdAt"); + +-- CreateIndex +CREATE INDEX "habits_goalId_idx" ON "habits"("goalId"); + +-- CreateIndex +CREATE INDEX "check_ins_userId_date_idx" ON "check_ins"("userId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_habitId_date_key" ON "check_ins"("habitId", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "check_ins_goalId_date_key" ON "check_ins"("goalId", "date"); + +-- One daily check-in per user and date. +CREATE UNIQUE INDEX "check_ins_userId_date_daily_key" ON "check_ins"("userId", "date") WHERE "kind" = 'daily'; + +-- CreateIndex +CREATE UNIQUE INDEX "daily_briefs_userId_date_key" ON "daily_briefs"("userId", "date"); + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "habits" ADD CONSTRAINT "habits_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_habitId_fkey" FOREIGN KEY ("habitId") REFERENCES "habits"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_goalId_fkey" FOREIGN KEY ("goalId") REFERENCES "user_goals"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "daily_briefs" ADD CONSTRAINT "daily_briefs_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- The CHECK constraint on the existing routines table is added NOT VALID and validated in the +-- next migration. +ALTER TABLE "routines" ADD CONSTRAINT "routines_coachKey_chk" CHECK ("coachKey" IS NULL OR "coachKey" IN ('morning', 'evening', 'weekly')) NOT VALID; + +-- New tables are empty, so their constraints apply immediately. +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_status_chk" CHECK ("status" IN ('active', 'done', 'dropped')); +ALTER TABLE "user_goals" ADD CONSTRAINT "user_goals_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_cadence_chk" CHECK ("cadence" IN ('daily', 'weekdays', 'weekly')); +ALTER TABLE "habits" ADD CONSTRAINT "habits_source_chk" CHECK ("source" IN ('user', 'onboarding', 'chat')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_kind_chk" CHECK ("kind" IN ('habit', 'goal', 'daily')); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_target_chk" CHECK ( + ("kind" = 'habit' AND "habitId" IS NOT NULL AND "goalId" IS NULL) OR + ("kind" = 'goal' AND "goalId" IS NOT NULL AND "habitId" IS NULL) OR + ("kind" = 'daily' AND "habitId" IS NULL AND "goalId" IS NULL) +); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_mood_chk" CHECK ("mood" IS NULL OR ("mood" >= 1 AND "mood" <= 5)); +ALTER TABLE "check_ins" ADD CONSTRAINT "check_ins_energy_chk" CHECK ("energy" IS NULL OR ("energy" >= 1 AND "energy" <= 5)); diff --git a/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql new file mode 100644 index 0000000..dbadb5b --- /dev/null +++ b/packages/db/prisma/migrations/20261003122001_validate_coach_goals_and_habits_constraints/migration.sql @@ -0,0 +1,3 @@ +-- Kept separate from creation so PostgreSQL validates existing rows with a SHARE UPDATE +-- EXCLUSIVE lock (reads and writes continue) after the NOT VALID constraint has committed. +ALTER TABLE "routines" VALIDATE CONSTRAINT "routines_coachKey_chk"; diff --git a/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql new file mode 100644 index 0000000..cd83c49 --- /dev/null +++ b/packages/db/prisma/migrations/20261003123000_push_subscriptions_and_notification_settings/migration.sql @@ -0,0 +1,54 @@ +-- CreateTable +CREATE TABLE "push_subscriptions" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT, + "auth" TEXT, + "userAgent" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3), + + CONSTRAINT "push_subscriptions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_notification_settings" ( + "userId" TEXT NOT NULL, + "quietHoursStart" INTEGER, + "quietHoursEnd" INTEGER, + "nudges" BOOLEAN NOT NULL DEFAULT true, + "nudgeDate" DATE, + "nudgeCount" INTEGER NOT NULL DEFAULT 0, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_notification_settings_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "push_subscriptions_endpoint_key" ON "push_subscriptions"("endpoint"); + +-- CreateIndex +CREATE INDEX "push_subscriptions_userId_idx" ON "push_subscriptions"("userId"); + +-- AddForeignKey +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Both tables are new and empty, so their constraints are valid from the start. +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_kind_chk" CHECK ("kind" IN ('webpush', 'expo')); +ALTER TABLE "push_subscriptions" ADD CONSTRAINT "push_subscriptions_webpush_keys_chk" CHECK ("kind" <> 'webpush' OR ("p256dh" IS NOT NULL AND "auth" IS NOT NULL)); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_quiet_hours_chk" CHECK ( + ("quietHoursStart" IS NULL AND "quietHoursEnd" IS NULL) + OR ( + "quietHoursStart" IS NOT NULL + AND "quietHoursEnd" IS NOT NULL + AND "quietHoursStart" BETWEEN 0 AND 1439 + AND "quietHoursEnd" BETWEEN 0 AND 1439 + AND "quietHoursStart" <> "quietHoursEnd" + ) +); +ALTER TABLE "user_notification_settings" ADD CONSTRAINT "user_notification_settings_nudge_count_chk" CHECK ("nudgeCount" >= 0); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 902d684..aca4c83 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -46,7 +46,14 @@ model User { botSecrets BotSecret[] agentSecrets AgentSecret[] profile UserProfile? + onboarding UserOnboarding? facts UserFact[] + goals UserGoal[] + habits Habit[] + checkIns CheckIn[] + dailyBriefs DailyBrief[] + pushSubscriptions PushSubscription[] + notificationSettings UserNotificationSettings? @@unique([email]) @@map("user") @@ -214,6 +221,7 @@ model Space { externalConversations ExternalConversation[] agentSecrets AgentSecret[] userFactVisibility UserFactSpace[] + userGoals UserGoal[] delegatedTasks DelegatedTask[] semanticMemoryItems SemanticMemoryItem[] @@ -766,15 +774,19 @@ model Routine { webhookEnabled Boolean @default(false) githubEnabled Boolean @default(false) messageProvider String? + /// One of the user's coach check-ins (morning, evening, weekly) on the main bot; null otherwise. + coachKey String? lastRunAt DateTime? nextRunAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt runs Run[] + @@unique([botId, coachKey]) @@index([active, nextRunAt]) @@index([spaceId, botId]) @@index([createdByBotId]) + // CHECK constraint routines_coachKey_chk is created in SQL. @@map("routines") } @@ -1111,6 +1123,43 @@ model NotificationPreference { @@map("notification_preferences") } +/// One device that receives the user's push notifications: a browser (web push) or the mobile +/// app (an Expo push token). An endpoint belongs to the account that registered it last. +model PushSubscription { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + /// The web push endpoint URL or the Expo push token. + endpoint String @unique + p256dh String? + auth String? + userAgent String @default("") + createdAt DateTime @default(now()) + lastUsedAt DateTime? + + @@index([userId]) + // CHECK constraints push_subscriptions_kind_chk and push_subscriptions_webpush_keys_chk are created in SQL. + @@map("push_subscriptions") +} + +/// When the user may be interrupted. No row means no quiet hours and nudges on. +model UserNotificationSettings { + userId String @id + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// Minutes after local midnight in the profile time zone; both set or both null. + quietHoursStart Int? + quietHoursEnd Int? + nudges Boolean @default(true) + /// The user's local date of the last nudge and how many were sent that day (daily cap). + nudgeDate DateTime? @db.Date + nudgeCount Int @default(0) + updatedAt DateTime @updatedAt + + // CHECK constraints user_notification_settings_quiet_hours_chk and _nudge_count_chk are created in SQL. + @@map("user_notification_settings") +} + model SpaceMemoryConfig { id String @id @default(cuid()) spaceId String @unique @@ -1429,6 +1478,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()) @@ -1468,6 +1532,100 @@ model UserFactSpace { @@map("user_fact_spaces") } +/// Something the user wants to get done; the main bot coaches them toward it. +model UserGoal { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + detail String @default("") + status String @default("active") + /// The user's local date the goal is due, if any. + targetDate DateTime? @db.Date + spaceId String? + space Space? @relation(fields: [spaceId], references: [id], onDelete: SetNull) + source String + /// Like the other provenance ids it has no foreign key. + createdByBotId String? + completedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + habits Habit[] + checkIns CheckIn[] + + @@index([userId, status, createdAt]) + @@index([spaceId]) + // CHECK constraints user_goals_status_chk and user_goals_source_chk are created in SQL. + @@map("user_goals") +} + +/// A recurring practice the user wants to keep, checked off per day or week. +model Habit { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + title String + cadence String @default("daily") + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: SetNull) + active Boolean @default(true) + source String + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + checkIns CheckIn[] + + @@index([userId, active, createdAt]) + @@index([goalId]) + // CHECK constraints habits_cadence_chk and habits_source_chk are created in SQL. + @@map("habits") +} + +/// What the user reported for one day: a habit kept or missed, goal progress, or how the day went. +model CheckIn { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + kind String + habitId String? + habit Habit? @relation(fields: [habitId], references: [id], onDelete: Cascade) + goalId String? + goal UserGoal? @relation(fields: [goalId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + done Boolean? + mood Int? + energy Int? + note String @default("") + createdByBotId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([habitId, date]) + @@unique([goalId, date]) + @@index([userId, date]) + // CHECK constraints check_ins_kind_chk, check_ins_target_chk, check_ins_mood_chk and + // check_ins_energy_chk and the partial unique index check_ins_userId_date_daily_key are + // created in SQL. + @@map("check_ins") +} + +/// The main bot's short brief for one of the user's days. +model DailyBrief { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + /// The user's local date. + date DateTime @db.Date + text String + botId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, date]) + @@map("daily_briefs") +} + /// Work the main bot hands to a specialist, reported back in the main conversation. model DelegatedTask { id String @id @default(cuid()) diff --git a/packages/db/src/coach.postgres.test.ts b/packages/db/src/coach.postgres.test.ts new file mode 100644 index 0000000..b4bb7a1 --- /dev/null +++ b/packages/db/src/coach.postgres.test.ts @@ -0,0 +1,327 @@ +import { randomUUID } from "node:crypto"; +import { COACH_ACTIVE_GOAL_LIMIT, COACH_ACTIVE_HABIT_LIMIT } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { bootstrapUserSpace } from "./bootstrap-user.js"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + CoachNotFoundError, + createGoal, + createHabit, + InvalidCoachInputError, + listCheckIns, + listGoals, + listHabits, + loadCoachSummary, + loadCoachToday, + logCheckIn, + removeGoal, + removeHabit, + updateGoal, + updateHabit, + upsertDailyBrief, +} from "./coach.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; +/** Saturday 2026-10-03, 23:30 UTC: already Sunday in Vienna. */ +const NOW = new Date("2026-10-03T23:30:00Z"); + +describePostgres("coach repository (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(timeZone: string | null = "Europe/Vienna") { + const id = `co${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + if (timeZone) await prisma.userProfile.create({ data: { userId: id, timeZone } }); + return { userId: id, spaceId }; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + const memberships = await prisma.member.findMany({ + where: { userId: { in: userIds } }, + select: { organizationId: true }, + }); + await prisma.organization.deleteMany({ + where: { id: { in: memberships.map((member) => member.organizationId) } }, + }); + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("creates, completes, reopens and removes goals, scoped to their owner", async () => { + const { userId, spaceId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { + title: " Ship the beta ", + detail: "Invite ten testers", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + }); + expect(goal).toMatchObject({ + title: "Ship the beta", + detail: "Invite ten testers", + status: "active", + targetDate: "2026-11-01", + spaceId, + source: "onboarding", + createdByBotId: null, + completedAt: null, + }); + + const done = await updateGoal(prisma, userId, goal.id, { status: "done" }); + expect(done.status).toBe("done"); + expect(done.completedAt).not.toBeNull(); + const reopened = await updateGoal(prisma, userId, goal.id, { + status: "active", + targetDate: null, + }); + expect(reopened).toMatchObject({ status: "active", completedAt: null, targetDate: null }); + + await expect(updateGoal(prisma, other.userId, goal.id, { title: "Mine" })).rejects.toThrow( + CoachNotFoundError, + ); + await expect(removeGoal(prisma, other.userId, goal.id)).rejects.toThrow(CoachNotFoundError); + await expect( + createGoal(prisma, other.userId, { title: "Theirs", spaceId, source: "user" }), + ).rejects.toThrow("Space not found"); + expect(await listGoals(prisma, other.userId)).toEqual([]); + expect((await listGoals(prisma, userId, { status: "active" })).map((g) => g.id)).toEqual([ + goal.id, + ]); + + await removeGoal(prisma, userId, goal.id); + expect(await listGoals(prisma, userId)).toEqual([]); + }); + + it("validates titles, dates and the active limits", async () => { + const { userId } = await newUser(); + await expect(createGoal(prisma, userId, { title: " ", source: "user" })).rejects.toThrow( + InvalidCoachInputError, + ); + await expect( + createGoal(prisma, userId, { title: "x".repeat(201), source: "user" }), + ).rejects.toThrow("longer than 200"); + await expect( + createGoal(prisma, userId, { title: "Bad date", targetDate: "2026-02-30", source: "user" }), + ).rejects.toThrow("YYYY-MM-DD"); + + await prisma.userGoal.createMany({ + data: Array.from({ length: COACH_ACTIVE_GOAL_LIMIT }, (_, index) => ({ + userId, + title: `Goal ${index}`, + source: "user", + })), + }); + await expect(createGoal(prisma, userId, { title: "One more", source: "user" })).rejects.toThrow( + `at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`, + ); + const [first] = await listGoals(prisma, userId); + await updateGoal(prisma, userId, first!.id, { status: "dropped" }); + await createGoal(prisma, userId, { title: "One more", source: "user" }); + await expect(updateGoal(prisma, userId, first!.id, { status: "active" })).rejects.toThrow( + "active goals", + ); + + await prisma.habit.createMany({ + data: Array.from({ length: COACH_ACTIVE_HABIT_LIMIT }, (_, index) => ({ + userId, + title: `Habit ${index}`, + source: "user", + })), + }); + await expect( + createHabit(prisma, userId, { title: "One more", source: "user" }), + ).rejects.toThrow(`at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + }); + + it("links habits to goals, pauses them and keeps their history", async () => { + const { userId } = await newUser(); + const other = await newUser(); + const goal = await createGoal(prisma, userId, { title: "Run a 10k", source: "chat" }); + const habit = await createHabit(prisma, userId, { + title: "Run", + cadence: "weekdays", + goalId: goal.id, + source: "chat", + createdByBotId: "bot-1", + }); + expect(habit).toMatchObject({ cadence: "weekdays", goalId: goal.id, active: true }); + await expect( + createHabit(prisma, other.userId, { title: "Steal", goalId: goal.id, source: "user" }), + ).rejects.toThrow(CoachNotFoundError); + + const paused = await updateHabit(prisma, userId, habit.id, { active: false, title: "Jog" }); + expect(paused).toMatchObject({ active: false, title: "Jog" }); + expect(await listHabits(prisma, userId)).toEqual([]); + expect((await listHabits(prisma, userId, { includeInactive: true })).length).toBe(1); + + // Removing the goal keeps the habit, unlinked. + await removeGoal(prisma, userId, goal.id); + expect((await listHabits(prisma, userId, { includeInactive: true }))[0]?.goalId).toBeNull(); + await expect(removeHabit(prisma, other.userId, habit.id)).rejects.toThrow(CoachNotFoundError); + await removeHabit(prisma, userId, habit.id); + expect(await listHabits(prisma, userId, { includeInactive: true })).toEqual([]); + }); + + it("keeps one check-in per habit, goal and day, dated in the user's time zone", async () => { + const { userId } = await newUser("Europe/Vienna"); + const other = await newUser(); + const habit = await createHabit(prisma, userId, { title: "Stretch", source: "user" }); + const goal = await createGoal(prisma, userId, { title: "Learn Spanish", source: "user" }); + + const first = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, done: true }, + NOW, + ); + // 23:30 UTC on Saturday is Sunday in Vienna. + expect(first).toMatchObject({ kind: "habit", date: "2026-10-04", done: true, note: "" }); + const again = await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: habit.id, note: "10 minutes", createdByBotId: "bot-1" }, + NOW, + ); + expect(again).toMatchObject({ id: first.id, done: true, note: "10 minutes" }); + // Who created the row stays as it was. + expect(again.createdByBotId).toBeNull(); + + const daily = await logCheckIn(prisma, userId, { kind: "daily", mood: 4 }, NOW); + const dailyAgain = await logCheckIn(prisma, userId, { kind: "daily", energy: 2 }, NOW); + expect(dailyAgain).toMatchObject({ id: daily.id, mood: 4, energy: 2 }); + const goalCheckIn = await logCheckIn( + prisma, + userId, + { kind: "goal", goalId: goal.id, note: "Finished unit 3", date: "2026-10-02" }, + NOW, + ); + expect(goalCheckIn.date).toBe("2026-10-02"); + + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2026-10-05" }, NOW), + ).rejects.toThrow("future"); + await expect( + logCheckIn(prisma, userId, { kind: "daily", date: "2025-01-01" }, NOW), + ).rejects.toThrow("go back at most"); + await expect(logCheckIn(prisma, userId, { kind: "daily", mood: 6 }, NOW)).rejects.toThrow( + "1 to 5", + ); + await expect( + logCheckIn(prisma, userId, { kind: "habit", goalId: goal.id }, NOW), + ).rejects.toThrow(InvalidCoachInputError); + await expect( + logCheckIn(prisma, other.userId, { kind: "habit", habitId: habit.id, done: true }, NOW), + ).rejects.toThrow(CoachNotFoundError); + + expect((await listCheckIns(prisma, userId)).map((row) => row.kind).sort()).toEqual([ + "daily", + "goal", + "habit", + ]); + expect(await listCheckIns(prisma, userId, { from: "2026-10-03" })).toHaveLength(2); + expect(await listCheckIns(prisma, userId, { goalId: goal.id })).toHaveLength(1); + expect(await listCheckIns(prisma, other.userId)).toEqual([]); + }); + + it("reports today's brief, goals, habits with streaks and the daily check-in", async () => { + const { userId } = await newUser("Europe/Vienna"); + const daily = await createHabit(prisma, userId, { title: "Walk", source: "user" }); + const weekly = await createHabit(prisma, userId, { + title: "Long run", + cadence: "weekly", + source: "user", + }); + const later = await createGoal(prisma, userId, { title: "Later", source: "user" }); + const soon = await createGoal(prisma, userId, { + title: "Soon", + targetDate: "2026-10-10", + source: "user", + }); + await createGoal(prisma, userId, { title: "Old", source: "user" }).then((goal) => + updateGoal(prisma, userId, goal.id, { status: "done" }), + ); + for (const date of ["2026-10-01", "2026-10-02", "2026-10-03"]) { + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, date, done: true }, NOW); + } + await logCheckIn( + prisma, + userId, + { kind: "habit", habitId: weekly.id, date: "2026-09-30", done: true }, + NOW, + ); + + let today = await loadCoachToday(prisma, userId, NOW); + expect(today).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna", brief: null }); + expect(today.checkIn).toBeNull(); + expect(today.goals.map((goal) => goal.id)).toEqual([soon.id, later.id]); + expect( + today.habits.map(({ title, doneToday, streak }) => ({ title, doneToday, streak })), + ).toEqual([ + { title: "Walk", doneToday: false, streak: 3 }, + { title: "Long run", doneToday: true, streak: 1 }, + ]); + + const brief = await upsertDailyBrief( + prisma, + userId, + { text: "Rest day. Plan the week.", botId: "bot-1" }, + NOW, + ); + expect(brief).toMatchObject({ date: "2026-10-04", botId: "bot-1" }); + const rewritten = await upsertDailyBrief(prisma, userId, { text: "Rest day." }, NOW); + expect(rewritten).toMatchObject({ id: brief.id, text: "Rest day.", botId: null }); + await expect( + upsertDailyBrief(prisma, userId, { text: "Far ahead", date: "2026-10-20" }, NOW), + ).rejects.toThrow("today or tomorrow"); + await expect(upsertDailyBrief(prisma, userId, { text: " " }, NOW)).rejects.toThrow("empty"); + + await logCheckIn(prisma, userId, { kind: "habit", habitId: daily.id, done: true }, NOW); + await logCheckIn(prisma, userId, { kind: "daily", mood: 5 }, NOW); + // Unchecking a weekly habit clears it for the whole week. + await logCheckIn(prisma, userId, { kind: "habit", habitId: weekly.id, done: false }, NOW); + today = await loadCoachToday(prisma, userId, NOW); + expect(today.brief?.text).toBe("Rest day."); + expect(today.checkIn).toMatchObject({ kind: "daily", mood: 5 }); + expect(today.habits.map(({ doneToday, streak }) => ({ doneToday, streak }))).toEqual([ + { doneToday: true, streak: 4 }, + { doneToday: false, streak: 0 }, + ]); + + const summary = await loadCoachSummary(prisma, userId, NOW); + expect(summary).toMatchObject({ date: "2026-10-04", timeZone: "Europe/Vienna" }); + expect(summary.habits).toEqual(today.habits); + expect(summary.goals).toEqual(today.goals); + }); + + it("uses UTC dates when the user has no time zone", async () => { + const { userId } = await newUser(null); + expect(await loadCoachToday(prisma, userId, NOW)).toMatchObject({ + date: "2026-10-03", + timeZone: "UTC", + goals: [], + habits: [], + }); + }); +}); diff --git a/packages/db/src/coach.ts b/packages/db/src/coach.ts new file mode 100644 index 0000000..69438d0 --- /dev/null +++ b/packages/db/src/coach.ts @@ -0,0 +1,634 @@ +import type { + CheckIn, + CheckInKind, + CoachSource, + CoachToday, + DailyBrief, + Goal, + GoalStatus, + Habit, + HabitCadence, + HabitToday, +} from "@rakazo/contracts"; +import { + CHECK_IN_NOTE_MAX_LENGTH, + COACH_ACTIVE_GOAL_LIMIT, + COACH_ACTIVE_HABIT_LIMIT, + COACH_TITLE_MAX_LENGTH, + DAILY_BRIEF_MAX_LENGTH, + GOAL_DETAIL_MAX_LENGTH, + LocalDateSchema, +} from "@rakazo/contracts"; +import { + addDays, + habitDoneInPeriod, + habitStreak, + knownTimeZone, + localDateIn, + weekStartOf, +} from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; +import { Prisma } from "./client.js"; + +/** How far back a check-in may be logged, and how far back streaks are counted. */ +export const CHECK_IN_MAX_AGE_DAYS = 366; + +export class CoachNotFoundError extends Error { + constructor(what: "Goal" | "Habit") { + super(`${what} not found`); + this.name = "CoachNotFoundError"; + } +} + +export class InvalidCoachInputError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidCoachInputError"; + } +} + +export interface NewGoal { + title: string; + detail?: string; + /** The user's local date, "YYYY-MM-DD". */ + targetDate?: string | null; + spaceId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface GoalPatch { + title?: string; + detail?: string; + status?: GoalStatus; + targetDate?: string | null; + spaceId?: string | null; +} + +export interface NewHabit { + title: string; + cadence?: HabitCadence; + goalId?: string | null; + source: CoachSource; + createdByBotId?: string | null; +} + +export interface HabitPatch { + title?: string; + cadence?: HabitCadence; + goalId?: string | null; + active?: boolean; +} + +export interface CheckInEntry { + kind: CheckInKind; + habitId?: string; + goalId?: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + done?: boolean | null; + mood?: number | null; + energy?: number | null; + note?: string; + createdByBotId?: string | null; +} + +export interface CheckInFilter { + from?: string; + to?: string; + kind?: CheckInKind; + habitId?: string; + goalId?: string; +} + +export interface DailyBriefEntry { + text: string; + /** The user's local date; defaults to today in their time zone. */ + date?: string; + botId?: string | null; +} + +/** Active goals and habits as the main bot sees them in its prompt. */ +export interface CoachSummary { + date: string; + timeZone: string; + goals: Goal[]; + habits: HabitToday[]; +} + +const CHECK_IN_LIST_LIMIT = 500; + +type Tx = Prisma.TransactionClient; +type GoalRow = Prisma.UserGoalGetPayload; +type HabitRow = Prisma.HabitGetPayload; +type CheckInRow = Prisma.CheckInGetPayload; +type BriefRow = Prisma.DailyBriefGetPayload; + +function dateString(value: Date): string { + return value.toISOString().slice(0, 10); +} + +function dateValue(value: string): Date { + return new Date(`${value}T00:00:00Z`); +} + +function goalDto(row: GoalRow): Goal { + return { + id: row.id, + title: row.title, + detail: row.detail, + status: row.status as GoalStatus, + targetDate: row.targetDate ? dateString(row.targetDate) : null, + spaceId: row.spaceId, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + completedAt: row.completedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function habitDto(row: HabitRow): Habit { + return { + id: row.id, + title: row.title, + cadence: row.cadence as HabitCadence, + goalId: row.goalId, + active: row.active, + source: row.source as CoachSource, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function checkInDto(row: CheckInRow): CheckIn { + return { + id: row.id, + kind: row.kind as CheckInKind, + habitId: row.habitId, + goalId: row.goalId, + date: dateString(row.date), + done: row.done, + mood: row.mood, + energy: row.energy, + note: row.note, + createdByBotId: row.createdByBotId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function briefDto(row: BriefRow): DailyBrief { + return { + id: row.id, + date: dateString(row.date), + text: row.text, + botId: row.botId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function title(value: string): string { + const text = value.trim(); + if (!text) throw new InvalidCoachInputError("Title is required"); + if (text.length > COACH_TITLE_MAX_LENGTH) { + throw new InvalidCoachInputError(`Title is longer than ${COACH_TITLE_MAX_LENGTH} characters`); + } + return text; +} + +function boundedText(value: string, max: number, label: string): string { + const text = value.trim(); + if (text.length > max) + throw new InvalidCoachInputError(`${label} is longer than ${max} characters`); + return text; +} + +function localDate(value: string): string { + if (!LocalDateSchema.safeParse(value).success) { + throw new InvalidCoachInputError("Dates use YYYY-MM-DD"); + } + return value; +} + +function rating(value: number | null | undefined, label: string): number | null | undefined { + if (value === null || value === undefined) return value; + if (!Number.isInteger(value) || value < 1 || value > 5) { + throw new InvalidCoachInputError(`${label} is a whole number from 1 to 5`); + } + return value; +} + +/** + * Serializes one user's coach writes so the active limits and the one-check-in-per-day rule hold + * under concurrency. + */ +async function lockCoach(tx: Tx, userId: string): Promise { + const key = `coach:${userId}`; + await tx.$queryRaw(Prisma.sql` + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" + `); +} + +/** The user's time zone from their profile, UTC when unknown. Every coach date uses it. */ +export async function coachTimeZone( + prisma: Pick, + userId: string, +): Promise { + const profile = await prisma.userProfile.findUnique({ + where: { userId }, + select: { timeZone: true }, + }); + return knownTimeZone(profile?.timeZone) ?? "UTC"; +} + +async function assertMemberSpace(tx: Tx, userId: string, spaceId: string | null | undefined) { + if (!spaceId) return; + const member = await tx.spaceMember.findFirst({ + where: { userId, spaceId }, + select: { id: true }, + }); + if (!member) throw new InvalidCoachInputError("Space not found"); +} + +async function ownedGoal(tx: Tx, userId: string, goalId: string): Promise { + const goal = await tx.userGoal.findFirst({ where: { id: goalId, userId } }); + if (!goal) throw new CoachNotFoundError("Goal"); + return goal; +} + +async function ownedHabit(tx: Tx, userId: string, habitId: string): Promise { + const habit = await tx.habit.findFirst({ where: { id: habitId, userId } }); + if (!habit) throw new CoachNotFoundError("Habit"); + return habit; +} + +async function assertActiveGoalRoom(tx: Tx, userId: string): Promise { + const active = await tx.userGoal.count({ where: { userId, status: "active" } }); + if (active >= COACH_ACTIVE_GOAL_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_GOAL_LIMIT} active goals`); + } +} + +async function assertActiveHabitRoom(tx: Tx, userId: string): Promise { + const active = await tx.habit.count({ where: { userId, active: true } }); + if (active >= COACH_ACTIVE_HABIT_LIMIT) { + throw new InvalidCoachInputError(`Keep at most ${COACH_ACTIVE_HABIT_LIMIT} active habits`); + } +} + +export async function createGoal( + prisma: PrismaClient, + userId: string, + input: NewGoal, +): Promise { + const data = { + title: title(input.title), + detail: boundedText(input.detail ?? "", GOAL_DETAIL_MAX_LENGTH, "Detail"), + targetDate: input.targetDate ? dateValue(localDate(input.targetDate)) : null, + }; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveGoalRoom(tx, userId); + await assertMemberSpace(tx, userId, input.spaceId); + const row = await tx.userGoal.create({ + data: { + ...data, + userId, + spaceId: input.spaceId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return goalDto(row); + }); +} + +/** Changes a goal; marking it done stamps completedAt, any other status clears it. */ +export async function updateGoal( + prisma: PrismaClient, + userId: string, + goalId: string, + patch: GoalPatch, +): Promise { + const data: Prisma.UserGoalUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.detail !== undefined) { + data.detail = boundedText(patch.detail, GOAL_DETAIL_MAX_LENGTH, "Detail"); + } + if (patch.targetDate !== undefined) { + data.targetDate = patch.targetDate ? dateValue(localDate(patch.targetDate)) : null; + } + if (patch.spaceId !== undefined) data.spaceId = patch.spaceId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const goal = await ownedGoal(tx, userId, goalId); + if (patch.spaceId !== undefined) await assertMemberSpace(tx, userId, patch.spaceId); + if (patch.status !== undefined && patch.status !== goal.status) { + if (patch.status === "active") await assertActiveGoalRoom(tx, userId); + data.status = patch.status; + data.completedAt = patch.status === "done" ? new Date() : null; + } + const row = await tx.userGoal.update({ where: { id: goal.id }, data }); + return goalDto(row); + }); +} + +/** Deletes a goal with its check-ins; habits that served it stay, unlinked. */ +export async function removeGoal(prisma: PrismaClient, userId: string, goalId: string) { + const { count } = await prisma.userGoal.deleteMany({ where: { id: goalId, userId } }); + if (count === 0) throw new CoachNotFoundError("Goal"); +} + +export async function listGoals( + prisma: PrismaClient, + userId: string, + filter: { status?: GoalStatus } = {}, +): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, ...(filter.status ? { status: filter.status } : {}) }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +export async function createHabit( + prisma: PrismaClient, + userId: string, + input: NewHabit, +): Promise { + const habitTitle = title(input.title); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + await assertActiveHabitRoom(tx, userId); + if (input.goalId) await ownedGoal(tx, userId, input.goalId); + const row = await tx.habit.create({ + data: { + userId, + title: habitTitle, + cadence: input.cadence ?? "daily", + goalId: input.goalId ?? null, + source: input.source, + createdByBotId: input.createdByBotId ?? null, + }, + }); + return habitDto(row); + }); +} + +/** Changes a habit; `active: false` stops tracking it but keeps its history. */ +export async function updateHabit( + prisma: PrismaClient, + userId: string, + habitId: string, + patch: HabitPatch, +): Promise { + const data: Prisma.HabitUncheckedUpdateInput = {}; + if (patch.title !== undefined) data.title = title(patch.title); + if (patch.cadence !== undefined) data.cadence = patch.cadence; + if (patch.goalId !== undefined) data.goalId = patch.goalId; + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const habit = await ownedHabit(tx, userId, habitId); + if (patch.goalId) await ownedGoal(tx, userId, patch.goalId); + if (patch.active !== undefined && patch.active !== habit.active) { + if (patch.active) await assertActiveHabitRoom(tx, userId); + data.active = patch.active; + } + const row = await tx.habit.update({ where: { id: habit.id }, data }); + return habitDto(row); + }); +} + +export async function removeHabit(prisma: PrismaClient, userId: string, habitId: string) { + const { count } = await prisma.habit.deleteMany({ where: { id: habitId, userId } }); + if (count === 0) throw new CoachNotFoundError("Habit"); +} + +export async function listHabits( + prisma: PrismaClient, + userId: string, + filter: { includeInactive?: boolean } = {}, +): Promise { + const rows = await prisma.habit.findMany({ + where: { userId, ...(filter.includeInactive ? {} : { active: true }) }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(habitDto); +} + +/** + * Logs or updates one day's check-in: one per habit, one per goal and one daily check-in per + * date. Fields left out keep their stored value. Unchecking a weekly habit clears the whole + * week, so the habit reads as not done this week. + */ +export async function logCheckIn( + prisma: PrismaClient, + userId: string, + entry: CheckInEntry, + now: Date = new Date(), +): Promise { + if ((entry.kind === "habit") !== Boolean(entry.habitId)) { + throw new InvalidCoachInputError("A habit check-in needs habitId, and only it"); + } + if ((entry.kind === "goal") !== Boolean(entry.goalId)) { + throw new InvalidCoachInputError("A goal check-in needs goalId, and only it"); + } + const fields: Prisma.CheckInUncheckedUpdateInput = {}; + if (entry.done !== undefined) fields.done = entry.done; + const mood = rating(entry.mood, "Mood"); + if (mood !== undefined) fields.mood = mood; + const energy = rating(entry.energy, "Energy"); + if (energy !== undefined) fields.energy = energy; + if (entry.note !== undefined) { + fields.note = boundedText(entry.note, CHECK_IN_NOTE_MAX_LENGTH, "Note"); + } + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > today) throw new InvalidCoachInputError("Check-ins cannot be in the future"); + if (date < addDays(today, -CHECK_IN_MAX_AGE_DAYS)) { + throw new InvalidCoachInputError(`Check-ins go back at most ${CHECK_IN_MAX_AGE_DAYS} days`); + } + const habit = entry.habitId ? await ownedHabit(tx, userId, entry.habitId) : null; + if (entry.goalId) await ownedGoal(tx, userId, entry.goalId); + const day = dateValue(date); + const existing = habit + ? await tx.checkIn.findUnique({ where: { habitId_date: { habitId: habit.id, date: day } } }) + : entry.goalId + ? await tx.checkIn.findUnique({ + where: { goalId_date: { goalId: entry.goalId, date: day } }, + }) + : await tx.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }); + const row = existing + ? await tx.checkIn.update({ where: { id: existing.id }, data: fields }) + : await tx.checkIn.create({ + data: { + userId, + kind: entry.kind, + habitId: habit?.id ?? null, + goalId: entry.goalId ?? null, + date: day, + done: entry.done ?? null, + mood: mood ?? null, + energy: energy ?? null, + note: typeof fields.note === "string" ? fields.note : "", + createdByBotId: entry.createdByBotId ?? null, + }, + }); + if (habit && entry.done === false && habit.cadence === "weekly") { + await tx.checkIn.updateMany({ + where: { + habitId: habit.id, + done: true, + date: { + gte: dateValue(weekStartOf(date)), + lte: dateValue(addDays(weekStartOf(date), 6)), + }, + }, + data: { done: false }, + }); + } + return checkInDto(row); + }); +} + +export async function listCheckIns( + prisma: PrismaClient, + userId: string, + filter: CheckInFilter = {}, +): Promise { + const rows = await prisma.checkIn.findMany({ + where: { + userId, + ...(filter.kind ? { kind: filter.kind } : {}), + ...(filter.habitId ? { habitId: filter.habitId } : {}), + ...(filter.goalId ? { goalId: filter.goalId } : {}), + ...(filter.from || filter.to + ? { + date: { + ...(filter.from ? { gte: dateValue(localDate(filter.from)) } : {}), + ...(filter.to ? { lte: dateValue(localDate(filter.to)) } : {}), + }, + } + : {}), + }, + orderBy: [{ date: "desc" }, { createdAt: "desc" }, { id: "asc" }], + take: CHECK_IN_LIST_LIMIT, + }); + return rows.map(checkInDto); +} + +/** Saves the brief for one of the user's days, replacing an earlier one for that date. */ +export async function upsertDailyBrief( + prisma: PrismaClient, + userId: string, + entry: DailyBriefEntry, + now: Date = new Date(), +): Promise { + const text = boundedText(entry.text, DAILY_BRIEF_MAX_LENGTH, "Brief"); + if (!text) throw new InvalidCoachInputError("Brief is empty"); + return prisma.$transaction(async (tx) => { + await lockCoach(tx, userId); + const today = localDateIn(await coachTimeZone(tx, userId), now); + const date = entry.date ? localDate(entry.date) : today; + if (date > addDays(today, 1)) { + throw new InvalidCoachInputError("A brief is for today or tomorrow at the latest"); + } + const row = await tx.dailyBrief.upsert({ + where: { userId_date: { userId, date: dateValue(date) } }, + create: { userId, date: dateValue(date), text, botId: entry.botId ?? null }, + update: { text, botId: entry.botId ?? null }, + }); + return briefDto(row); + }); +} + +async function activeGoals(prisma: PrismaClient, userId: string): Promise { + const rows = await prisma.userGoal.findMany({ + where: { userId, status: "active" }, + orderBy: [{ targetDate: { sort: "asc", nulls: "last" } }, { createdAt: "asc" }, { id: "asc" }], + }); + return rows.map(goalDto); +} + +async function activeHabitsOn( + prisma: PrismaClient, + userId: string, + today: string, +): Promise { + const habits = await prisma.habit.findMany({ + where: { userId, active: true }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }); + if (habits.length === 0) return []; + const done = await prisma.checkIn.findMany({ + where: { + userId, + kind: "habit", + done: true, + habitId: { in: habits.map((habit) => habit.id) }, + date: { gte: dateValue(addDays(today, -CHECK_IN_MAX_AGE_DAYS)), lte: dateValue(today) }, + }, + select: { habitId: true, date: true }, + }); + const doneDates = new Map>(); + for (const row of done) { + if (!row.habitId) continue; + const dates = doneDates.get(row.habitId) ?? new Set(); + dates.add(dateString(row.date)); + doneDates.set(row.habitId, dates); + } + return habits.map((row) => { + const habit = habitDto(row); + const dates = doneDates.get(row.id) ?? new Set(); + return { + ...habit, + doneToday: habitDoneInPeriod(habit.cadence, dates, today), + streak: habitStreak(habit.cadence, dates, today), + }; + }); +} + +/** Active goals and habits with streaks, for the main bot's prompt. */ +export async function loadCoachSummary( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const [goals, habits] = await Promise.all([ + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + ]); + return { date, timeZone, goals, habits }; +} + +/** The user's day: today's brief, active goals, habits with streaks and the daily check-in. */ +export async function loadCoachToday( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const timeZone = await coachTimeZone(prisma, userId); + const date = localDateIn(timeZone, now); + const day = dateValue(date); + const [brief, goals, habits, checkIn] = await Promise.all([ + prisma.dailyBrief.findUnique({ where: { userId_date: { userId, date: day } } }), + activeGoals(prisma, userId), + activeHabitsOn(prisma, userId, date), + prisma.checkIn.findFirst({ where: { userId, kind: "daily", date: day } }), + ]); + return { + date, + timeZone, + brief: brief ? briefDto(brief) : null, + goals, + habits, + checkIn: checkIn ? checkInDto(checkIn) : null, + }; +} diff --git a/packages/db/src/groups.test.ts b/packages/db/src/groups.test.ts index d8c42e2..7b1ff8c 100644 --- a/packages/db/src/groups.test.ts +++ b/packages/db/src/groups.test.ts @@ -63,6 +63,77 @@ describe("listSpaceGroupsForSpaces", () => { }); }); +describe("group previews", () => { + const actor = { + spaceId: "workspace-1", + userId: "user-1", + email: "user@example.test", + isDeploymentOwner: false, + }; + const card = { + kind: "choice", + step: "rhythm", + question: "When should I check in with you?", + options: [{ id: "morning", letter: "A", label: "Every morning" }], + }; + const note = { + kind: "intake_note", + note: "done", + nextCheckInAt: "2026-10-05T06:00:00.000Z", + text: "All set. I’ll check in tomorrow at 8:00 AM.", + }; + const latest: Record = { + card: [card], + note: [note], + text: [{ kind: "text", text: "Hello" }], + malformed: [{ kind: "choice", question: 1 }], + empty: undefined, + }; + const records = Object.entries(latest).map(([id, blocks]) => ({ + id, + spaceId: actor.spaceId, + userId: actor.userId, + kind: "main", + name: "Chief", + leadBotId: "bot-1", + pinned: false, + sectionId: null, + archivedAt: null, + createdAt: new Date("2026-10-04T00:00:00.000Z"), + updatedAt: new Date("2026-10-04T00:00:00.000Z"), + thread: { id: `thread-${id}`, unread: false, messages: blocks ? [{ blocks }] : [] }, + members: [{ bot: { id: "bot-1", name: "Chief", color: "#111", runs: [] } }], + })); + + it.each(["listGroups", "listSpaceGroupsForSpaces"] as const)( + "%s keeps the latest onboarding card or note next to the preview", + async (list) => { + const repos = createGroupRepos({ + chatGroup: { findMany: vi.fn(async () => records) }, + } as unknown as PrismaClient); + const groups = + list === "listGroups" + ? await repos.listGroups(actor) + : await repos.listSpaceGroupsForSpaces(actor, [actor.spaceId]); + + expect( + groups.map(({ id, preview, previewBlock }) => ({ id, preview, previewBlock })), + ).toEqual([ + { id: "card", preview: "", previewBlock: card }, + { id: "note", preview: note.text, previewBlock: note }, + { id: "text", preview: "Hello", previewBlock: undefined }, + { id: "malformed", preview: "", previewBlock: undefined }, + { id: "empty", preview: "", previewBlock: undefined }, + ]); + // Everything else leaves the field out, so the wire carries nothing extra. + expect(groups.filter((group) => "previewBlock" in group).map(({ id }) => id)).toEqual([ + "card", + "note", + ]); + }, + ); +}); + describe("archiveGroup", () => { const actor = { spaceId: "workspace-1", diff --git a/packages/db/src/groups.ts b/packages/db/src/groups.ts index 36f0332..9f99e33 100644 --- a/packages/db/src/groups.ts +++ b/packages/db/src/groups.ts @@ -15,7 +15,12 @@ import type { Prisma, PrismaClient } from "./client.js"; import { expireComputerExecutionLeases } from "./computers.js"; import { IsolationError } from "./scope.js"; import { lockSpaceForContentCreation } from "./spaces.js"; -import { activeRunSelection, activeRunStatuses, previewFromBlocks } from "./thread-listing.js"; +import { + activeRunSelection, + activeRunStatuses, + groupPreview, + previewFromBlocks, +} from "./thread-listing.js"; type GroupRecord = { id: string; @@ -65,7 +70,6 @@ function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] { function mapGroup(group: GroupRecord): Group { if (!group.thread) throw new IsolationError("Group is missing its thread"); - const preview = previewFromBlocks(group.thread.messages[0]?.blocks); return { id: group.id, spaceId: group.spaceId, @@ -77,7 +81,7 @@ function mapGroup(group: GroupRecord): Group { archivedAt: group.archivedAt?.toISOString() ?? null, members: mapGroupMembers(group.members), threadId: group.thread.id, - preview, + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), createdAt: group.createdAt.toISOString(), @@ -112,7 +116,7 @@ function mapSpaceGroup(group: SpaceGroupRecord): SpaceGroup { pinned: group.pinned, sectionId: group.sectionId, members: mapGroupMembers(group.members), - preview: previewFromBlocks(group.thread.messages[0]?.blocks), + ...groupPreview(group.thread.messages[0]?.blocks), unread: group.thread.unread, updatedAt: group.updatedAt.toISOString(), }; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3a251fa..f6fcc65 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,6 +1,7 @@ export * from "./bootstrap-user.js"; export * from "./cancel-runs.js"; export * from "./client.js"; +export * from "./coach.js"; export * from "./computers.js"; export * from "./credential-secrets.js"; export * from "./events.js"; @@ -12,8 +13,10 @@ export * from "./messages.js"; export * from "./messaging.js"; export * from "./model-credentials.js"; export * from "./model-tiers.js"; +export * from "./notification-settings.js"; export * from "./project-goals.js"; export * from "./project-workspace.js"; +export * from "./push-subscriptions.js"; export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; diff --git a/packages/db/src/main-agent.ts b/packages/db/src/main-agent.ts index b90104e..2f7d9ab 100644 --- a/packages/db/src/main-agent.ts +++ b/packages/db/src/main-agent.ts @@ -49,6 +49,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 { @@ -85,6 +87,22 @@ export async function findMainBotId( return bot?.id ?? null; } +/** + * Whether a bot is the user's personal main agent, the main bot of their home space. Goals, + * habits, check-ins and nudges belong to it alone; a project's main bot leads only that project. + */ +export async function isPersonalMainBot( + prisma: Pick, + bot: { kind: string; spaceId: string }, +): Promise { + if (bot.kind !== "main") return false; + const space = await prisma.space.findUnique({ + where: { id: bot.spaceId }, + select: { isDefault: true }, + }); + return space?.isDefault === true; +} + /** * 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 @@ -295,7 +313,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: { @@ -320,5 +341,6 @@ export async function loadUserContext( value: fact.value, confidence: fact.confidence, })), + onboardingOpen: Boolean(user?.onboarding && !user.onboarding.completedAt), }; } diff --git a/packages/db/src/notification-settings.postgres.test.ts b/packages/db/src/notification-settings.postgres.test.ts new file mode 100644 index 0000000..624f9dc --- /dev/null +++ b/packages/db/src/notification-settings.postgres.test.ts @@ -0,0 +1,184 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { PrismaClient } from "./client.js"; +import { createDb } from "./client.js"; +import { + claimDailyNudge, + getNotificationSettings, + loadNotificationPolicy, + updateNotificationSettings, +} from "./notification-settings.js"; +import { + listPushSubscriptions, + PUSH_SUBSCRIPTION_LIMIT, + removePushSubscription, + removePushSubscriptionsOfKind, + savePushSubscription, +} from "./push-subscriptions.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describePostgres("notification settings and push subscriptions (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser() { + const id = `ns${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name: "Ada", email: `${id}@rakazo.test` } }); + return id; + } + + beforeAll(() => { + const db = createDb(databaseUrl!); + prisma = db.prisma; + close = async () => { + await db.prisma.$disconnect(); + await db.pool.end(); + }; + }); + + afterAll(async () => { + if (!prisma) return; + await prisma.user.deleteMany({ where: { id: { in: userIds } } }); + await close(); + }); + + it("starts with no quiet hours and nudges on, and applies partial updates", async () => { + const userId = await newUser(); + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "22:30", end: "07:00" } }), + ).resolves.toEqual({ quietHours: { start: "22:30", end: "07:00" }, nudges: true }); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect([row.quietHoursStart, row.quietHoursEnd]).toEqual([22 * 60 + 30, 7 * 60]); + + await expect(updateNotificationSettings(prisma, userId, { nudges: false })).resolves.toEqual({ + quietHours: { start: "22:30", end: "07:00" }, + nudges: false, + }); + await expect(updateNotificationSettings(prisma, userId, { quietHours: null })).resolves.toEqual( + { quietHours: null, nudges: false }, + ); + }); + + it("rejects an empty quiet-hours range in the repository and in the database", async () => { + const userId = await newUser(); + await expect( + updateNotificationSettings(prisma, userId, { quietHours: { start: "08:00", end: "08:00" } }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 60, quietHoursEnd: null }, + }), + ).rejects.toThrow(); + await expect( + prisma.userNotificationSettings.create({ + data: { userId, quietHoursStart: 1440, quietHoursEnd: 60 }, + }), + ).rejects.toThrow(); + }); + + it("judges quiet hours in the profile time zone", async () => { + const userId = await newUser(); + await updateNotificationSettings(prisma, userId, { + quietHours: { start: "22:00", end: "07:00" }, + }); + const now = new Date("2026-07-01T21:30:00Z"); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toMatchObject({ + quiet: false, + localDate: "2026-07-01", + }); + await prisma.userProfile.create({ data: { userId, timeZone: "Europe/Vienna" } }); + await expect(loadNotificationPolicy(prisma, userId, now)).resolves.toEqual({ + quiet: true, + nudges: true, + localDate: "2026-07-01", + }); + }); + + it("caps nudges per local day, also under concurrency", async () => { + const userId = await newUser(); + const claims = await Promise.all( + Array.from({ length: 6 }, () => claimDailyNudge(prisma, userId, "2026-07-01")), + ); + expect(claims.filter(Boolean)).toHaveLength(3); + await expect(claimDailyNudge(prisma, userId, "2026-07-01")).resolves.toBe(false); + await expect(claimDailyNudge(prisma, userId, "2026-07-02")).resolves.toBe(true); + const row = await prisma.userNotificationSettings.findUniqueOrThrow({ where: { userId } }); + expect(row.nudgeCount).toBe(1); + // Claiming never changes the user's settings. + await expect(getNotificationSettings(prisma, userId)).resolves.toEqual({ + quietHours: null, + nudges: true, + }); + }); + + it("keeps devices per user, moves a re-registered endpoint and caps the list", async () => { + const owner = await newUser(); + const other = await newUser(); + const endpoint = `https://push.example.test/${randomUUID()}`; + await savePushSubscription(prisma, owner, { kind: "webpush", endpoint, ...keys }); + await savePushSubscription(prisma, owner, { kind: "expo", endpoint: "ExponentPushToken[a]" }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual([ + "webpush", + "expo", + ]); + + // Another user cannot remove the owner's device by naming its endpoint. + await removePushSubscription(prisma, other, endpoint); + expect(await listPushSubscriptions(prisma, owner)).toHaveLength(2); + + // The same browser signed into another account now delivers to that account. + await savePushSubscription(prisma, other, { kind: "webpush", endpoint, ...keys }); + expect((await listPushSubscriptions(prisma, owner)).map((row) => row.kind)).toEqual(["expo"]); + expect((await listPushSubscriptions(prisma, other)).map((row) => row.endpoint)).toEqual([ + endpoint, + ]); + + await removePushSubscriptionsOfKind(prisma, owner, "expo"); + expect(await listPushSubscriptions(prisma, owner)).toEqual([]); + + for (let index = 0; index <= PUSH_SUBSCRIPTION_LIMIT; index += 1) { + await savePushSubscription(prisma, owner, { + kind: "expo", + endpoint: `ExponentPushToken[${owner}-${index}]`, + }); + } + const kept = await listPushSubscriptions(prisma, owner); + expect(kept).toHaveLength(PUSH_SUBSCRIPTION_LIMIT); + expect(kept.map((row) => row.endpoint)).not.toContain(`ExponentPushToken[${owner}-0]`); + }); + + it("requires keys for web push and removes everything with the account", async () => { + const userId = await newUser(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "webpush", endpoint: `https://push.example.test/${userId}` }, + }), + ).rejects.toThrow(); + await expect( + prisma.pushSubscription.create({ + data: { userId, kind: "sms", endpoint: `sms-${userId}` }, + }), + ).rejects.toThrow(); + await savePushSubscription(prisma, userId, { + kind: "expo", + endpoint: `ExponentPushToken[${userId}]`, + }); + await updateNotificationSettings(prisma, userId, { nudges: false }); + + await prisma.user.delete({ where: { id: userId } }); + expect(await prisma.pushSubscription.count({ where: { userId } })).toBe(0); + expect(await prisma.userNotificationSettings.count({ where: { userId } })).toBe(0); + }); +}); diff --git a/packages/db/src/notification-settings.ts b/packages/db/src/notification-settings.ts new file mode 100644 index 0000000..050f61e --- /dev/null +++ b/packages/db/src/notification-settings.ts @@ -0,0 +1,121 @@ +import type { NotificationSettings, NotificationSettingsPatch } from "@rakazo/contracts"; +import { + clockTimeToMinutes, + minutesToClockTime, + NUDGES_PER_DAY, + UpdateNotificationSettingsInput, +} from "@rakazo/contracts"; +import { isQuietMinute, localDateAndMinute } from "@rakazo/core"; +import type { PrismaClient } from "./client.js"; + +const settingsSelect = { quietHoursStart: true, quietHoursEnd: true, nudges: true } as const; + +type SettingsRow = { + quietHoursStart: number | null; + quietHoursEnd: number | null; + nudges: boolean; +}; + +function quietRange(row: SettingsRow | null) { + return row && row.quietHoursStart !== null && row.quietHoursEnd !== null + ? { start: row.quietHoursStart, end: row.quietHoursEnd } + : null; +} + +function toSettings(row: SettingsRow | null): NotificationSettings { + const range = quietRange(row); + return { + quietHours: range + ? { start: minutesToClockTime(range.start), end: minutesToClockTime(range.end) } + : null, + nudges: row?.nudges ?? true, + }; +} + +/** The user's notification settings; without a stored row: no quiet hours, nudges on. */ +export async function getNotificationSettings( + prisma: PrismaClient, + userId: string, +): Promise { + return toSettings( + await prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + ); +} + +/** Applies the fields present in `patch`; `quietHours: null` turns quiet hours off. */ +export async function updateNotificationSettings( + prisma: PrismaClient, + userId: string, + patch: NotificationSettingsPatch, +): Promise { + const valid = UpdateNotificationSettingsInput.parse(patch); + const data: { quietHoursStart?: number | null; quietHoursEnd?: number | null; nudges?: boolean } = + {}; + if (valid.quietHours !== undefined) { + data.quietHoursStart = valid.quietHours ? clockTimeToMinutes(valid.quietHours.start) : null; + data.quietHoursEnd = valid.quietHours ? clockTimeToMinutes(valid.quietHours.end) : null; + } + if (valid.nudges !== undefined) data.nudges = valid.nudges; + return toSettings( + await prisma.userNotificationSettings.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: settingsSelect, + }), + ); +} + +export interface NotificationPolicy { + /** True during the user's quiet hours: no push may go out. */ + quiet: boolean; + nudges: boolean; + /** The user's local date ("YYYY-MM-DD"), the day the nudge cap counts against. */ + localDate: string; +} + +/** What may reach the user at `now`, judged in their profile time zone (UTC when unknown). */ +export async function loadNotificationPolicy( + prisma: PrismaClient, + userId: string, + now: Date = new Date(), +): Promise { + const [row, profile] = await Promise.all([ + prisma.userNotificationSettings.findUnique({ where: { userId }, select: settingsSelect }), + prisma.userProfile.findUnique({ where: { userId }, select: { timeZone: true } }), + ]); + const local = localDateAndMinute(now, profile?.timeZone); + return { + quiet: isQuietMinute(quietRange(row), local.minute), + nudges: row?.nudges ?? true, + localDate: local.date, + }; +} + +/** + * Counts one nudge against the user's daily cap and returns whether it may go out. Each step is + * a single conditional UPDATE, so concurrent nudges cannot exceed the cap. + */ +export async function claimDailyNudge( + prisma: PrismaClient, + userId: string, + localDate: string, + limit: number = NUDGES_PER_DAY, +): Promise { + const day = new Date(`${localDate}T00:00:00.000Z`); + await prisma.userNotificationSettings.createMany({ data: [{ userId }], skipDuplicates: true }); + // Two rounds: a concurrent first nudge of the day can move the date between the statements. + for (let round = 0; round < 2; round += 1) { + const sameDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, nudgeDate: day, nudgeCount: { lt: limit } }, + data: { nudgeCount: { increment: 1 } }, + }); + if (sameDay.count === 1) return true; + const newDay = await prisma.userNotificationSettings.updateMany({ + where: { userId, OR: [{ nudgeDate: null }, { nudgeDate: { not: day } }] }, + data: { nudgeDate: day, nudgeCount: 1 }, + }); + if (newDay.count === 1) return true; + } + return false; +} diff --git a/packages/db/src/push-subscriptions.ts b/packages/db/src/push-subscriptions.ts new file mode 100644 index 0000000..879f705 --- /dev/null +++ b/packages/db/src/push-subscriptions.ts @@ -0,0 +1,109 @@ +import type { PrismaClient } from "./client.js"; + +export type PushSubscriptionKind = "webpush" | "expo"; + +/** Devices one user keeps registered; the least recently registered ones are dropped first. */ +export const PUSH_SUBSCRIPTION_LIMIT = 20; + +export interface StoredPushSubscription { + id: string; + kind: PushSubscriptionKind; + endpoint: string; + p256dh: string | null; + auth: string | null; +} + +export interface NewPushSubscription { + kind: PushSubscriptionKind; + /** Web push endpoint URL or Expo push token. */ + endpoint: string; + p256dh?: string | null; + auth?: string | null; + userAgent?: string; +} + +/** + * Registers a device for the user. An endpoint registered by another account moves to this one: + * a device delivers to whoever signed in on it last. + */ +export async function savePushSubscription( + prisma: PrismaClient, + userId: string, + input: NewPushSubscription, +): Promise { + const data = { + userId, + kind: input.kind, + p256dh: input.p256dh ?? null, + auth: input.auth ?? null, + userAgent: (input.userAgent ?? "").slice(0, 256), + }; + await prisma.pushSubscription.upsert({ + where: { endpoint: input.endpoint }, + create: { endpoint: input.endpoint, ...data }, + // A renewed registration counts as the newest device for the per-user limit. + update: { ...data, createdAt: new Date() }, + }); + const stale = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: [{ createdAt: "desc" }, { id: "desc" }], + skip: PUSH_SUBSCRIPTION_LIMIT, + select: { id: true }, + }); + if (stale.length > 0) { + await prisma.pushSubscription.deleteMany({ + where: { id: { in: stale.map((row) => row.id) } }, + }); + } +} + +/** Removes one of the user's devices; another user's endpoint is left alone. */ +export async function removePushSubscription( + prisma: PrismaClient, + userId: string, + endpoint: string, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, endpoint } }); +} + +/** Removes every device of one kind, e.g. all mobile app tokens on sign-out. */ +export async function removePushSubscriptionsOfKind( + prisma: PrismaClient, + userId: string, + kind: PushSubscriptionKind, +): Promise { + await prisma.pushSubscription.deleteMany({ where: { userId, kind } }); +} + +export async function listPushSubscriptions( + prisma: PrismaClient, + userId: string, +): Promise { + const rows = await prisma.pushSubscription.findMany({ + where: { userId }, + orderBy: { createdAt: "asc" }, + select: { id: true, kind: true, endpoint: true, p256dh: true, auth: true }, + }); + return rows.map((row) => ({ ...row, kind: row.kind as PushSubscriptionKind })); +} + +export async function countPushSubscriptions( + prisma: PrismaClient, + userId: string, +): Promise { + return prisma.pushSubscription.count({ where: { userId } }); +} + +/** Records a successful delivery to a device. */ +export async function markPushSubscriptionUsed( + prisma: PrismaClient, + id: string, + at: Date, +): Promise { + await prisma.pushSubscription.updateMany({ where: { id }, data: { lastUsedAt: at } }); +} + +/** Drops a device its push service no longer knows. */ +export async function forgetPushSubscription(prisma: PrismaClient, id: string): Promise { + await prisma.pushSubscription.deleteMany({ where: { id } }); +} diff --git a/packages/db/src/thread-listing.ts b/packages/db/src/thread-listing.ts index 853eb6d..726456a 100644 --- a/packages/db/src/thread-listing.ts +++ b/packages/db/src/thread-listing.ts @@ -1,3 +1,5 @@ +import type { Group } from "@rakazo/contracts"; +import { IntakeBlockSchema } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; export const activeRunStatuses = [...ACTIVE_RUN_STATUSES]; @@ -23,3 +25,15 @@ export function previewFromBlocks(blocks: unknown): string { } return ""; } + +/** + * A group's preview of its latest message. An onboarding card or note comes along as + * `previewBlock`, so the web can word the preview in the user's language. + */ +export function groupPreview(blocks: unknown): Pick { + const intake = IntakeBlockSchema.safeParse(Array.isArray(blocks) ? blocks[0] : undefined); + return { + preview: previewFromBlocks(blocks), + ...(intake.success ? { previewBlock: intake.data } : {}), + }; +} diff --git a/packages/db/src/user-profile.postgres.test.ts b/packages/db/src/user-profile.postgres.test.ts index 03e10b0..d1c218c 100644 --- a/packages/db/src/user-profile.postgres.test.ts +++ b/packages/db/src/user-profile.postgres.test.ts @@ -179,6 +179,66 @@ describePostgres("user profile (PostgreSQL)", () => { }); }); + it("edits a fact in place, refusing a wording another fact already has", async () => { + const { userId, homeSpaceId } = await newUser(); + const work = await createSpaceForMember(prisma, { + currentSpaceId: homeSpaceId, + userId, + name: "Work", + }); + const [tea, coffee] = await updateUserProfile(prisma, userId, { + addFacts: [ + { category: "preferences", value: "Prefers tea", source: "user" }, + { category: "preferences", value: "Drinks coffee at work", source: "conversation" }, + ], + }); + await prisma.userFact.update({ + where: { id: tea!.id }, + data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, + }); + + // Same wording, new category and visibility: the embedding still fits. + await updateUserProfile(prisma, userId, { + editFacts: [{ id: tea!.id, value: " Prefers tea ", category: "habits", spaceIds: [work.id] }], + }); + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: tea!.id } })).toMatchObject({ + value: "Prefers tea", + category: "habits", + allSpaces: false, + embedding: [0.1, 0.2], + embeddingModel: "test-embedding", + }); + expect( + (await getUserProfileState(prisma, userId)).facts.find((fact) => fact.id === tea!.id), + ).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); + + for (const value of ["“prefers TEA!”", "..."]) { + await expect( + updateUserProfile(prisma, userId, { + editFacts: [ + { id: coffee!.id, category: "work" }, + { id: coffee!.id, value }, + ], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + } + // The whole update failed, the category change included. + expect(await prisma.userFact.findUniqueOrThrow({ where: { id: coffee!.id } })).toMatchObject({ + value: "Drinks coffee at work", + category: "preferences", + }); + + // A fact may take a wording that an earlier edit in the same update gave up. + await updateUserProfile(prisma, userId, { + editFacts: [ + { id: tea!.id, value: "Prefers green tea" }, + { id: coffee!.id, value: "prefers tea" }, + ], + }); + const values = (await getUserProfileState(prisma, userId)).facts.map((fact) => fact.value); + expect(values.sort()).toEqual(["Prefers green tea", "prefers tea"]); + }); + it("limits a fact to the user's own spaces", async () => { const owner = await newUser(); const other = await newUser("Grace"); diff --git a/packages/db/src/user-profile.ts b/packages/db/src/user-profile.ts index c8f80f3..da24418 100644 --- a/packages/db/src/user-profile.ts +++ b/packages/db/src/user-profile.ts @@ -250,36 +250,54 @@ async function addFacts( return results; } -async function editFact( +/** + * Applies the user's fact edits in order. A new wording must still say something and must not + * repeat another fact, compared the way new facts are. + */ +async function editFacts( tx: Prisma.TransactionClient, userId: string, - edit: UserFactEdit, + edits: UserFactEdit[], ): Promise { - const fact = await tx.userFact.findFirst({ - where: { id: edit.id, userId }, - select: { id: true }, + if (edits.length === 0) return; + const facts = await tx.userFact.findMany({ + where: { userId }, + select: { id: true, value: true }, }); - if (!fact) throw new UserFactNotFoundError(); - const data: Prisma.UserFactUpdateInput = {}; - if (edit.category !== undefined) { - data.category = edit.category; - data.sensitive = isSensitiveUserFactCategory(edit.category); + const values = new Map(facts.map((fact) => [fact.id, fact.value])); + for (const edit of edits) { + const current = values.get(edit.id); + if (current === undefined) throw new UserFactNotFoundError(); + const data: Prisma.UserFactUpdateInput = {}; + if (edit.category !== undefined) { + data.category = edit.category; + data.sensitive = isSensitiveUserFactCategory(edit.category); + } + if (edit.value !== undefined && edit.value.trim() !== current) { + const value = edit.value.trim(); + const key = normalizeUserFactValue(value); + if (!key) throw new InvalidUserFactError("Fact is empty"); + for (const [id, other] of values) { + if (id !== edit.id && normalizeUserFactValue(other) === key) { + throw new InvalidUserFactError("This fact already exists"); + } + } + values.set(edit.id, value); + data.value = value; + // The stored embedding described the old wording. + data.embedding = []; + data.embeddingModel = null; + } + if (edit.spaceIds !== undefined) { + const spaces = visibleSpacesOf(edit.spaceIds); + data.allSpaces = spaces === null; + data.visibleSpaces = { + deleteMany: {}, + ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), + }; + } + if (Object.keys(data).length > 0) await tx.userFact.update({ where: { id: edit.id }, data }); } - if (edit.value !== undefined) { - data.value = edit.value.trim(); - // The stored embedding described the old wording. - data.embedding = []; - data.embeddingModel = null; - } - if (edit.spaceIds !== undefined) { - const spaces = visibleSpacesOf(edit.spaceIds); - data.allSpaces = spaces === null; - data.visibleSpaces = { - deleteMany: {}, - ...(spaces ? { create: spaces.map((spaceId) => ({ spaceId })) } : {}), - }; - } - await tx.userFact.update({ where: { id: fact.id }, data }); } async function confirmFacts( @@ -341,7 +359,7 @@ export async function updateUserProfile( }); } const added = await addFacts(tx, userId, update.addFacts ?? []); - for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); + await editFacts(tx, userId, update.editFacts ?? []); await confirmFacts(tx, userId, update.confirmFactIds ?? []); return added; }); diff --git a/packages/memory/src/commit.postgres.test.ts b/packages/memory/src/commit.postgres.test.ts index 9fdecfc..c6f07d1 100644 --- a/packages/memory/src/commit.postgres.test.ts +++ b/packages/memory/src/commit.postgres.test.ts @@ -1,4 +1,5 @@ import type { AdapterContext, MemoryCommitRequest } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { createDb, type PrismaClient } from "@rakazo/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { MarkdownMemoryStore } from "./index.js"; @@ -193,6 +194,84 @@ describePostgres("memory commits (PostgreSQL)", () => { }, ); + it("appends to the current content with a revision per note, creating a missing document", async () => { + const path = "appended.md"; + const append = (content: string): MemoryCommitRequest => ({ + ...request(path, content), + mode: "append", + }); + const first = await store.commit(append("- first"), context); + expect(first).toMatchObject({ revision: 1, content: "- first\n" }); + await store.commit(append("- second"), context); + const current = await document(path); + expect(current).toMatchObject({ revision: 2, content: "- first\n- second\n" }); + expect(current.revisions.map(({ revision, content }) => ({ revision, content }))).toEqual([ + { revision: 1, content: "- first\n" }, + { revision: 2, content: "- first\n- second\n" }, + ]); + + // A replace still rewrites the whole document. + await store.commit(request(path, "# Condensed\n"), context); + await store.commit(append("- third"), context); + expect((await document(path)).content).toBe("# Condensed\n\n- third\n"); + }); + + it("keeps both notes when two appends overlap", async () => { + const path = "concurrent-append.md"; + await store.commit(request(path, "# Notes\n"), context); + let reads = 0; + let release!: () => void; + const bothRead = new Promise((resolve) => { + release = resolve; + }); + const concurrentStore = new MarkdownMemoryStore( + prisma.$extends({ + query: { + memoryDocument: { + async findFirst({ args, query }) { + const result = await query(args); + reads += 1; + if (reads === 2) release(); + if (reads <= 2) await bothRead; + return result; + }, + }, + }, + }) as PrismaClient, + ); + + const results = await Promise.allSettled([ + concurrentStore.commit({ ...request(path, "- a"), mode: "append" }, context), + concurrentStore.commit({ ...request(path, "- b"), mode: "append" }, context), + ]); + + expect(results.map((result) => result.status)).toEqual(["fulfilled", "fulfilled"]); + const current = await document(path); + expect(current.revision).toBe(3); + expect(current.content).toMatch(/^# Notes\n\n- [ab]\n- [ab]\n$/); + expect(current.content).toContain("- a\n"); + expect(current.content).toContain("- b\n"); + }); + + it("refuses a document past the size limit without writing anything", async () => { + const path = "full.md"; + const nearlyFull = `${"x".repeat(MEMORY_DOCUMENT_MAX_BYTES - 10)}\n`; + await store.commit(request(path, nearlyFull), context); + + await expect( + store.commit({ ...request(path, "- one note too many"), mode: "append" }, context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + await expect( + store.commit(request(path, "é".repeat(MEMORY_DOCUMENT_MAX_BYTES / 2 + 1)), context), + ).rejects.toBeInstanceOf(MemoryDocumentTooLargeError); + const current = await document(path); + expect(current).toMatchObject({ revision: 1, content: nearlyFull }); + expect(current.revisions).toHaveLength(1); + + await store.commit({ ...request(path, "- ok"), mode: "append" }, context); + expect((await document(path)).revision).toBe(2); + }); + it("bounds conflict retries, preserves only committed saves, and allows a later retry", async () => { const path = "exhausted.md"; await store.commit(request(path, "initial"), context); diff --git a/packages/memory/src/index.test.ts b/packages/memory/src/index.test.ts index 92f77fe..7cc98fc 100644 --- a/packages/memory/src/index.test.ts +++ b/packages/memory/src/index.test.ts @@ -1,6 +1,6 @@ import type { AdapterContext } from "@rakazo/adapter-kit"; import { describe, expect, it, vi } from "vitest"; -import { MarkdownMemoryStore } from "./index.js"; +import { appendMarkdown, MarkdownMemoryStore } from "./index.js"; const context: AdapterContext = { operationId: "read-memory", @@ -47,3 +47,18 @@ describe("memory store contract shape", () => { }); }); }); + +describe("appendMarkdown", () => { + it("adds the note below a heading or paragraph, and right below a list", () => { + expect(appendMarkdown("# Chief\n\n", "- 2026-10-03: Prefers tea")).toBe( + "# Chief\n\n- 2026-10-03: Prefers tea\n", + ); + expect(appendMarkdown("- a\n- b", "- c")).toBe("- a\n- b\n- c\n"); + expect(appendMarkdown("- a\n more about a\n", "- c")).toBe("- a\n more about a\n- c\n"); + expect(appendMarkdown("", "- first")).toBe("- first\n"); + }); + + it("leaves the document unchanged for an empty note", () => { + expect(appendMarkdown("# Chief\n", " ")).toBe("# Chief\n"); + }); +}); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 937c00e..14931c1 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -10,6 +10,7 @@ import type { MemoryStore, PortableFile, } from "@rakazo/adapter-kit"; +import { MEMORY_DOCUMENT_MAX_BYTES, MemoryDocumentTooLargeError } from "@rakazo/adapter-kit"; import { Prisma, type PrismaClient, withTransactionRetry } from "@rakazo/db"; export class MarkdownMemoryStore implements MemoryStore { @@ -69,8 +70,8 @@ export class MarkdownMemoryStore implements MemoryStore { } async commit(request: MemoryCommitRequest, context: AdapterContext): Promise { - // A save replaces the current content and appends its history atomically. - // Conflicts retry the whole transaction so revision numbers use fresh state. + // A save replaces (or appends to) the current content and appends its history atomically. + // Conflicts retry the whole transaction so revision numbers and appends use fresh state. return withTransactionRetry(() => this.prisma.$transaction( async (tx) => { @@ -83,10 +84,17 @@ export class MarkdownMemoryStore implements MemoryStore { path: request.path, }, }); + const content = + request.mode === "append" + ? appendMarkdown(existing?.content ?? "", request.content) + : request.content; + if (Buffer.byteLength(content, "utf8") > MEMORY_DOCUMENT_MAX_BYTES) { + throw new MemoryDocumentTooLargeError(request.path); + } const doc = existing ? await tx.memoryDocument.update({ where: { id: existing.id }, - data: { content: request.content, revision: existing.revision + 1 }, + data: { content, revision: existing.revision + 1 }, }) : await tx.memoryDocument.create({ data: { @@ -95,14 +103,14 @@ export class MarkdownMemoryStore implements MemoryStore { botId: request.botId, scope: request.scope, path: request.path, - content: request.content, + content, }, }); await tx.memoryRevision.create({ data: { documentId: doc.id, revision: doc.revision, - content: request.content, + content, sourceRunId: request.sourceRunId, sourceThreadId: request.sourceThreadId, }, @@ -147,6 +155,20 @@ export class MarkdownMemoryStore implements MemoryStore { } } +/** + * `addition` after the existing content, ending with a newline: right below a list, so notes stay + * one list, and after a blank line below anything else, such as a heading. + */ +export function appendMarkdown(existing: string, addition: string): string { + const base = existing.trimEnd(); + const next = addition.trim(); + if (!next) return existing; + if (!base) return `${next}\n`; + const lastLine = base.slice(base.lastIndexOf("\n") + 1); + const inList = /^\s*(?:[-*+]|\d+\.)\s|^\s{2,}\S/.test(lastLine); + return `${base}${inList ? "\n" : "\n\n"}${next}\n`; +} + function snippet(content: string, q: string): string { const idx = content.toLowerCase().indexOf(q); if (idx < 0) return content.slice(0, 140); diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 10b9b9a..2ee29ff 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -64,6 +64,19 @@ describeWithDatabase("API authorization and resource isolation", () => { ["profile/get"], ["profile/update", { timeZone: "UTC" }], ["profile/forget", { all: true }], + ["coach/today"], + ["coach/goals/list", {}], + ["coach/goals/create", { title: "Nope" }], + ["coach/goals/update", { goalId: "missing-goal", title: "Nope" }], + ["coach/goals/remove", { goalId: "missing-goal" }], + ["coach/habits/list", {}], + ["coach/habits/create", { title: "Nope" }], + ["coach/habits/update", { habitId: "missing-habit", title: "Nope" }], + ["coach/habits/remove", { habitId: "missing-habit" }], + ["coach/checkIns/log", { kind: "daily", mood: 3 }], + ["coach/checkIns/list", {}], + ["coach/rhythm/get"], + ["coach/rhythm/set", { morning: null, evening: null, weekly: null }], ["deployment/get"], ["deployment/update", { signupsEnabled: true }], ["updater/status"], @@ -192,6 +205,12 @@ describeWithDatabase("API authorization and resource isolation", () => { ["usage/summary"], ["export/bot", { botId: "missing-bot" }], ["notifications/registerPush", { token: "ExponentPushToken[not-real]" }], + ["notifications/unregisterPush"], + ["notifications/settings/get"], + ["notifications/settings/update", { nudges: false }], + ["notifications/webPush/config"], + ["notifications/webPush/subscribe", webPushSubscriptionInput("unauthenticated")], + ["notifications/webPush/unsubscribe", { endpoint: "https://push.example.test/none" }], ["search/query", { q: "anything" }], ["tasks/list", {}], ["inbox/list"], @@ -556,19 +575,18 @@ describeWithDatabase("API authorization and resource isolation", () => { ).toMatchObject({ content: "owner-only-memory" }); // The user profile is per user: another user's fact ids and spaces are out of reach. - const ownerProfile = await rpc<{ facts: Array<{ id: string; status: string }> }>( - app, - owner, - "profile/update", - { - addFacts: [ - { category: "work", value: "Owner works nights" }, - { category: "health", value: "Owner health note", source: "onboarding" }, - ], - }, - ); - const ownerFactIds = ownerProfile.facts.map((fact) => fact.id); - expect(ownerProfile.facts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); + const ownerProfile = await rpc<{ + facts: Array<{ id: string; status: string; source: string }>; + }>(app, owner, "profile/update", { + addFacts: [ + { category: "work", value: "Owner works nights" }, + { category: "health", value: "Owner health note", source: "onboarding" }, + ], + }); + // Connecting an app above already remembered it; this check is about the facts added here. + const ownerFacts = ownerProfile.facts.filter((fact) => fact.source !== "connector"); + const ownerFactIds = ownerFacts.map((fact) => fact.id); + expect(ownerFacts.map((fact) => fact.status).sort()).toEqual(["active", "pending"]); const profileCalls = [ ["profile/update", { confirmFactIds: ownerFactIds }], ["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }], @@ -712,6 +730,16 @@ describeWithDatabase("API authorization and resource isolation", () => { .items, ).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id })); expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] }); + // From another of the member's spaces the inbox spans the shared one too, still without + // the owner's runs. + const memberSpace = await rpc<{ id: string }>( + app, + member, + "spaces/create", + { name: "Member side" }, + sharedSpaceId, + ); + expect(await rpc(app, member, "inbox/list", {}, memberSpace.id)).toEqual({ items: [] }); }); it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => { @@ -2484,6 +2512,13 @@ interface Bot { id: string; } +function webPushSubscriptionInput(device: string) { + return { + endpoint: `https://push.example.test/${device}`, + keys: { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }, + }; +} + function exhaustiveProtectedCalls< const Calls extends ReadonlyArray, >(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls { diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 03067b7..44896a0 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -1,9 +1,11 @@ import { execSync } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { loadRootEnv } from "@rakazo/core/node/load-root-env"; import { PostgreSqlContainer } from "@testcontainers/postgresql"; import type { createApp } from "../../../../apps/api/src/app.ts"; +import { writeOnboardingScanFixture } from "../onboarding-scan-fixture.js"; import { runProcess } from "./process.js"; loadRootEnv(); @@ -106,9 +108,15 @@ async function main() { "packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/user-profile.postgres.test.ts", + "packages/testkit/src/onboarding-intake.postgres.test.ts", + "packages/testkit/src/onboarding-discovery.postgres.test.ts", + "packages/testkit/src/notifications.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts", "packages/db/src/main-agent.postgres.test.ts", "packages/db/src/user-profile.postgres.test.ts", + "packages/db/src/notification-settings.postgres.test.ts", + "packages/testkit/src/coach.postgres.test.ts", + "packages/db/src/coach.postgres.test.ts", "packages/db/src/messaging.postgres.test.ts", "apps/api/src/thread-message-pages.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", @@ -118,6 +126,8 @@ async function main() { "packages/adapters/src/cloud-agent.postgres.test.ts", "packages/adapters/src/builtin-memory-provider.postgres.test.ts", "packages/adapters/src/profile-consolidation.postgres.test.ts", + "packages/adapters/src/notify-user.postgres.test.ts", + "packages/adapters/src/coach-rhythm.postgres.test.ts", ]; // Each app reconciles all durable work in its database, including intentionally // unfinished fixture runs. Clone the pristine migrated schema so one suite @@ -181,6 +191,10 @@ async function main() { { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, ); const email = new EmailEmulator(); + // A fixture home folder, so the deployment owner's onboarding offers the real project scan. + const scanHome = await mkdtemp(path.join(tmpdir(), "rakazo-e2e-scan-home-")); + await writeOnboardingScanFixture(scanHome); + process.env.ONBOARDING_SCAN_HOME = scanHome; const handles = await createApp({ databaseUrl, prisma: undefined, @@ -193,13 +207,30 @@ async function main() { }, integrationsCatalogUrl: "https://catalog.example.test/", }); + // No spec user owns the deployment unless it asks (POST /__e2e/deployment-owner), so + // owner-only onboarding steps appear only in the specs that test them, whatever runs first. + await handles.prisma.deploymentSettings.upsert({ + where: { id: "default" }, + create: { + id: "default", + ownerUserId: E2E_NO_OWNER, + signupsEnabled: true, + signupAllowlist: "", + signupPolicyInitialized: true, + }, + update: { ownerUserId: E2E_NO_OWNER }, + }); let activeRequests = 0; const requestWaiters = new Set<() => void>(); const server = serve({ fetch: async (request) => { - if (new URL(request.url).pathname === "/__e2e/emails") { + const { pathname } = new URL(request.url); + if (pathname === "/__e2e/emails") { return Response.json(email.sent, { headers: { "cache-control": "no-store" } }); } + if (pathname === "/__e2e/deployment-owner" && request.method === "POST") { + return Response.json(await setDeploymentOwner(handles, await request.json())); + } activeRequests += 1; try { return await handles.app.fetch(request); @@ -267,6 +298,7 @@ async function main() { await new Promise((resolve) => requestWaiters.add(resolve)); } await handles.stop().catch(() => undefined); + await rm(scanHome, { recursive: true, force: true }).catch(() => undefined); for (let index = 0; index < computers.length; index += 4) { const results = await Promise.allSettled( computers.slice(index, index + 4).map((computer) => @@ -305,6 +337,9 @@ async function main() { type AppHandles = Awaited>; +/** Matches no user, so the first signup does not claim the deployment. */ +const E2E_NO_OWNER = "e2e-no-owner"; + async function managedComputers(handles: AppHandles) { if (!["e2b", "daytona", "box"].includes(sandboxProvider)) return []; return handles.prisma.computer.findMany({ @@ -313,6 +348,33 @@ async function managedComputers(handles: AppHandles) { }); } +/** + * Lets a spec act as the deployment owner (by email) for owner-only steps, then restore the + * previous owner (by id). Returns the owner it replaced. + */ +async function setDeploymentOwner( + handles: AppHandles, + input: { email?: string; userId?: string | null }, +): Promise<{ previousOwnerUserId: string | null }> { + const previous = await handles.prisma.deploymentSettings.findUnique({ + where: { id: "default" }, + select: { ownerUserId: true }, + }); + const ownerUserId = input.email + ? ( + await handles.prisma.user.findUniqueOrThrow({ + where: { email: input.email }, + select: { id: true }, + }) + ).id + : (input.userId ?? E2E_NO_OWNER); + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId }, + }); + return { previousOwnerUserId: previous?.ownerUserId ?? null }; +} + async function writeSummary(reportDir: string, summary: Record) { await writeFile( path.join(reportDir, "summary.json"), diff --git a/packages/testkit/src/coach.postgres.test.ts b/packages/testkit/src/coach.postgres.test.ts new file mode 100644 index 0000000..386250d --- /dev/null +++ b/packages/testkit/src/coach.postgres.test.ts @@ -0,0 +1,310 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { COACH_ROUTINES, ComposioEmulator, SCRIPTED_MORNING_BRIEF } from "@rakazo/adapters"; +import type { + CheckIn, + CoachRhythm, + CoachToday, + Goal, + Habit, + Routine, + SpaceNavigation, +} from "@rakazo/contracts"; +import { ensureMainBot, ensureMainConversation } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +describeWithDatabase("coach through the API and the main bot", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-coach-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function waitForRun(runId: string) { + const start = Date.now(); + while (Date.now() - start < 20_000) { + const run = await handles.prisma.run.findUnique({ + where: { id: runId }, + select: { id: true, status: true, error: true, threadId: true }, + }); + if (run && ["completed", "failed", "cancelled"].includes(run.status)) { + expect(run, run.error ?? "").toMatchObject({ status: "completed" }); + return run; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`run ${runId} did not finish`); + } + + async function sendAndWait(cookie: string, botId: string, text: string) { + const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); + return waitForRun(runId); + } + + async function toolOutcome(runId: string) { + const event = await handles.prisma.event.findFirstOrThrow({ + where: { runId, type: "agent.tool.completed" }, + select: { payload: true }, + }); + return event.payload as { name: string; outcome: string; error?: string }; + } + + it("keeps each user's goals, habits and check-ins to that user", async () => { + const ada = await signup(app, `coach-ada-${stamp}@rakazo.test`, "Ada"); + const bob = await signup(app, `coach-bob-${stamp}@rakazo.test`, "Bob"); + const bobSpaces = await rpc(app, bob, "spaces/list"); + + const goal = await rpc(app, ada, "coach/goals/create", { + title: "Ship the beta", + targetDate: "2026-12-01", + }); + expect(goal).toMatchObject({ title: "Ship the beta", status: "active", source: "user" }); + const habit = await rpc(app, ada, "coach/habits/create", { + title: "Write 300 words", + goalId: goal.id, + }); + expect(habit).toMatchObject({ cadence: "daily", goalId: goal.id, source: "user" }); + const checkIn = await rpc(app, ada, "coach/checkIns/log", { + kind: "habit", + habitId: habit.id, + done: true, + }); + await rpc(app, ada, "coach/checkIns/log", { + kind: "goal", + goalId: goal.id, + note: "Draft done", + }); + await rpc(app, ada, "coach/checkIns/log", { kind: "daily", mood: 4, energy: 3 }); + + const today = await rpc(app, ada, "coach/today"); + expect(today).toMatchObject({ date: checkIn.date, timeZone: "UTC", brief: null }); + expect(today.goals.map((g) => g.id)).toEqual([goal.id]); + expect(today.habits).toMatchObject([{ id: habit.id, doneToday: true, streak: 1 }]); + expect(today.checkIn).toMatchObject({ mood: 4, energy: 3 }); + expect(await rpc(app, ada, "coach/checkIns/list", { goalId: goal.id })).toHaveLength( + 1, + ); + + // Bob sees none of it and cannot touch it; Ada's ids read as missing to him. + expect(await rpc(app, bob, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/habits/list", {})).toEqual([]); + expect(await rpc(app, bob, "coach/checkIns/list", { habitId: habit.id })).toEqual( + [], + ); + expect((await rpc(app, bob, "coach/today")).habits).toEqual([]); + for (const [procedure, input] of [ + ["coach/goals/update", { goalId: goal.id, status: "dropped" }], + ["coach/goals/remove", { goalId: goal.id }], + ["coach/habits/update", { habitId: habit.id, active: false }], + ["coach/habits/remove", { habitId: habit.id }], + ["coach/checkIns/log", { kind: "habit", habitId: habit.id, done: false }], + ["coach/checkIns/log", { kind: "goal", goalId: goal.id, note: "Mine now" }], + ["coach/habits/create", { title: "Borrowed", goalId: goal.id }], + ] as const) { + expect((await raw(app, bob, procedure, input)).status, procedure).toBe(404); + } + // Ada cannot file a goal under Bob's space. + expect( + ( + await raw(app, ada, "coach/goals/create", { + title: "Elsewhere", + spaceId: bobSpaces.spaces[0]!.id, + }) + ).status, + ).toBe(400); + expect((await raw(app, ada, "coach/goals/create", { title: " " })).status).toBe(400); + expect((await raw(app, ada, "coach/checkIns/log", { kind: "habit" })).status).toBe(400); + expect( + (await raw(app, ada, "coach/checkIns/log", { kind: "daily", date: "2999-01-01" })).status, + ).toBe(400); + expect((await rpc(app, ada, "coach/habits/list", {})).map((h) => h.active)).toEqual([ + true, + ]); + + const closed = await rpc(app, ada, "coach/goals/update", { + goalId: goal.id, + status: "done", + }); + expect(closed.completedAt).not.toBeNull(); + expect((await rpc(app, ada, "coach/today")).goals).toEqual([]); + await rpc(app, ada, "coach/habits/remove", { habitId: habit.id }); + await rpc(app, ada, "coach/goals/remove", { goalId: goal.id }); + expect(await rpc(app, ada, "coach/goals/list", {})).toEqual([]); + expect(await rpc(app, ada, "coach/checkIns/list", { kind: "habit" })).toEqual([]); + }); + + it("lets the main bot coach through its tools, and only the main bot", async () => { + const cookie = await signup(app, `coach-tools-${stamp}@rakazo.test`, "Coach Tools"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + const main = await ensureMainBot(handles.prisma, me.userId); + const specialist = await rpc<{ id: string }>(app, cookie, "bots/create", { + name: "Researcher", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + + await sendAndWait(cookie, main.id, "set a goal: Run a 10k"); + await sendAndWait(cookie, main.id, "track a habit: Walk"); + await sendAndWait(cookie, main.id, "done today: walk"); + const briefRun = await sendAndWait(cookie, main.id, "write today's brief: Rest, then plan."); + expect(await toolOutcome(briefRun.id)).toMatchObject({ + name: "write_daily_brief", + outcome: "succeeded", + }); + + const today = await rpc(app, cookie, "coach/today"); + expect(today.goals).toMatchObject([ + { title: "Run a 10k", source: "chat", createdByBotId: main.id }, + ]); + expect(today.habits).toMatchObject([ + { title: "Walk", source: "chat", doneToday: true, streak: 1 }, + ]); + expect(today.brief).toMatchObject({ text: "Rest, then plan.", botId: main.id }); + const [walked] = await rpc(app, cookie, "coach/checkIns/list", {}); + expect(walked).toMatchObject({ kind: "habit", done: true, createdByBotId: main.id }); + + const refused = await sendAndWait(cookie, specialist.id, "set a goal: Sneak in"); + expect(await toolOutcome(refused.id)).toMatchObject({ + name: "update_goal", + outcome: "error", + error: expect.stringContaining("not available in this run"), + }); + expect(await rpc(app, cookie, "coach/goals/list", {})).toHaveLength(1); + }); + + it("schedules check-ins as main-bot routines, and the morning one writes today's brief", async () => { + const cookie = await signup(app, `coach-rhythm-${stamp}@rakazo.test`, "Coach Rhythm"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + const rhythm = { morning: "07:00", evening: null, weekly: { day: 0, time: "18:00" } }; + expect(await rpc(app, cookie, "coach/rhythm/set", rhythm)).toEqual(rhythm); + expect(await rpc(app, cookie, "coach/rhythm/get")).toEqual(rhythm); + expect((await raw(app, cookie, "coach/rhythm/set", { ...rhythm, morning: "7am" })).status).toBe( + 400, + ); + + const conversation = await ensureMainConversation(handles.prisma, me.userId); + const routines = await rpc(app, cookie, "routines/list", { + botId: conversation.leadBotId, + }); + expect(routines.map((routine) => routine.name).sort()).toEqual( + [COACH_ROUTINES.morning.name, COACH_ROUTINES.weekly.name].sort(), + ); + + const morning = await handles.prisma.routine.findFirstOrThrow({ + where: { botId: conversation.leadBotId, coachKey: "morning" }, + }); + const dueAt = new Date(Date.now() - 1_000); + await handles.prisma.routine.update({ where: { id: morning.id }, data: { nextRunAt: dueAt } }); + await handles.executor.wakeRoutine(morning.id, dueAt.toISOString()); + const run = await handles.prisma.run.findFirstOrThrow({ + where: { routineId: morning.id, trigger: "routine" }, + }); + expect((await waitForRun(run.id)).threadId).toBe(conversation.threadId); + expect((await rpc(app, cookie, "coach/today")).brief).toMatchObject({ + text: SCRIPTED_MORNING_BRIEF, + botId: conversation.leadBotId, + }); + // The routine re-armed for the next morning. + const rearmed = await handles.prisma.routine.findUniqueOrThrow({ where: { id: morning.id } }); + expect(rearmed.active).toBe(true); + expect(rearmed.nextRunAt!.getTime()).toBeGreaterThan(Date.now()); + }); + + it("moves the check-ins when the user's time zone changes or is forgotten", async () => { + const cookie = await signup(app, `coach-zone-${stamp}@rakazo.test`, "Coach Zone"); + const me = await rpc<{ userId: string }>(app, cookie, "me"); + await rpc(app, cookie, "coach/rhythm/set", { morning: "07:00", evening: null, weekly: null }); + const morning = () => + handles.prisma.routine.findFirstOrThrow({ + where: { userId: me.userId, coachKey: "morning" }, + }); + expect(await morning()).toMatchObject({ timezone: "UTC" }); + + await rpc(app, cookie, "profile/update", { timeZone: "Asia/Tokyo" }); + const moved = await morning(); + expect(moved.timezone).toBe("Asia/Tokyo"); + expect( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Asia/Tokyo", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(moved.nextRunAt!), + ).toBe("07:00"); + + await rpc(app, cookie, "profile/forget", { all: true }); + expect(await morning()).toMatchObject({ timezone: "UTC" }); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 2fe3d09..8016419 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -94,7 +94,7 @@ describeInbox("inbox.list", () => { expect(await listInbox(app, cookie)).toEqual([]); }); - it("collects approvals, takeovers and unread threads of this space outside the main conversation", async () => { + it("collects approvals, takeovers and unread threads of every space outside the main conversation", async () => { const cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed"); const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); const scout = await createBot(app, cookie, "Scout"); @@ -187,6 +187,14 @@ describeInbox("inbox.list", () => { text: "Sign in to continue.", createdAt: expect.any(String), }, + expect.objectContaining({ + kind: "ask", + bot: expect.objectContaining({ id: remote.id }), + target: { botId: remote.id }, + space: { id: remoteSpace.id, name: "Remote" }, + runId: remoteAsk.runId, + messageId: remoteAsk.messageId, + }), expect.objectContaining({ kind: "unread", bot: expect.objectContaining({ id: scout.id }), @@ -196,16 +204,80 @@ describeInbox("inbox.list", () => { ]); // The Pilot thread is unread too, but its takeover already covers it. expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1); + // Items of the current space carry no space. + expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]); - expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([ + // From the other space, the same items with the sides swapped, plus the ask in the home + // main conversation: Remote's own main conversation does not show it. + const fromRemote = await listInbox(app, cookie, remoteSpace.id); + expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([ + ["approval", me.spaceId], + ["ask", me.spaceId], + ["takeover", me.spaceId], + ["ask", null], + ["unread", me.spaceId], + ]); + expect(fromRemote[1]).toMatchObject({ + bot: expect.objectContaining({ id: scout.id }), + target: { groupId: main.groupId }, + }); + }); + + it("answers an item from another space only in that space and only for its owner", async () => { + const cookie = await signup(app, `inbox-cross-${stamp}@rakazo.test`, "Inbox Cross"); + const intruder = await signup(app, `inbox-intruder-${stamp}@rakazo.test`, "Inbox Intruder"); + const me = await rpc<{ spaceId: string }>(app, cookie, "me"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const scout = await createBot(app, cookie, "Scout", side.id); + + await rpc( + app, + cookie, + "threads/send", + { botId: scout.id, text: "ask me which city to use" }, + side.id, + ); + await waitForThread( + app, + cookie, + scout.id, + (snapshot) => snapshot.run?.status === "waiting_input", + side.id, + ); + + const [item] = await listInbox(app, cookie, me.spaceId); + if (item?.kind !== "ask") throw new Error("expected an ask item"); + expect(item).toMatchObject({ + space: { id: side.id, name: "Side" }, + target: { botId: scout.id }, + }); + const answer = { + ...item.target, + runId: item.runId, + messageId: item.messageId, + answer: "Paris", + }; + + // The thread is not in the current space, and the intruder belongs to neither space. + await expect(rpc(app, cookie, "threads/answer", answer, me.spaceId)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer, side.id)).rejects.toThrow(); + await expect(rpc(app, intruder, "threads/answer", answer)).rejects.toThrow(); + expect(await listInbox(app, intruder)).toEqual([]); + expect((await listInbox(app, cookie, me.spaceId)).map((entry) => entry.kind)).toEqual(["ask"]); + + await rpc(app, cookie, "threads/answer", answer, item.space?.id); + await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([ expect.objectContaining({ - kind: "ask", - bot: expect.objectContaining({ id: remote.id }), - target: { botId: remote.id }, - runId: remoteAsk.runId, - messageId: remoteAsk.messageId, + kind: "unread", + target: { botId: scout.id }, + space: { id: side.id, name: "Side" }, + text: expect.stringContaining("Paris"), }), ]); + + await rpc(app, cookie, "threads/markRead", { botId: scout.id }, side.id); + expect(await listInbox(app, cookie, me.spaceId)).toEqual([]); }); }); @@ -269,11 +341,12 @@ async function waitForThread( cookie: string, botId: string, done: (snapshot: ThreadSnapshot) => boolean, + spaceId?: string, ): Promise { const start = Date.now(); let last: ThreadSnapshot | null = null; while (Date.now() - start < 20_000) { - last = await rpc(app, cookie, "threads/get", { botId }); + last = await rpc(app, cookie, "threads/get", { botId }, spaceId); if (done(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); } diff --git a/packages/testkit/src/journeys.test.ts b/packages/testkit/src/journeys.test.ts index d6d8d98..3ab5e55 100644 --- a/packages/testkit/src/journeys.test.ts +++ b/packages/testkit/src/journeys.test.ts @@ -382,6 +382,10 @@ describeJourneys("required product journeys", () => { botId: coder.id, }); expect(coderMem.some((m) => m.content.toLowerCase().includes("rust"))).toBe(true); + // Remembering appends a dated note below the seeded heading instead of replacing it. + expect(coderMem.map((m) => m.content)).toContainEqual( + expect.stringMatching(/^# Coder\n\n- \d{4}-\d{2}-\d{2}: remember that coder prefers rust\n$/), + ); const dedicated = await rpc(app, ada, "bots/setComputer", { botId: coder.id, mode: "dedicated", diff --git a/packages/testkit/src/notifications.postgres.test.ts b/packages/testkit/src/notifications.postgres.test.ts new file mode 100644 index 0000000..9668046 --- /dev/null +++ b/packages/testkit/src/notifications.postgres.test.ts @@ -0,0 +1,198 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator } from "@rakazo/adapters"; +import type { NotificationSettings, WebPushConfig } from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +const keys = { p256dh: `B${"A".repeat(86)}`, auth: "A".repeat(22) }; + +describeWithDatabase("notifications through the API", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-notifications-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + async function devices(cookie: string) { + const me = await rpc<{ userId: string }>(app, cookie, "me"); + return handles.prisma.pushSubscription.findMany({ + where: { userId: me.userId }, + orderBy: { createdAt: "asc" }, + select: { kind: true, endpoint: true, userAgent: true }, + }); + } + + it("reads and updates the caller's own settings", async () => { + const owner = await signup(app, `settings-owner-${stamp}@rakazo.test`, "Settings Owner"); + const other = await signup(app, `settings-other-${stamp}@rakazo.test`, "Settings Other"); + + await expect( + rpc(app, owner, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: null, nudges: true }); + await expect( + rpc(app, owner, "notifications/settings/update", { + quietHours: { start: "21:00", end: "06:30" }, + nudges: false, + }), + ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); + await expect( + rpc(app, other, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: null, nudges: true }); + + for (const invalid of [ + { quietHours: { start: "9:00", end: "17:00" } }, + { quietHours: { start: "24:00", end: "06:00" } }, + { quietHours: { start: "08:00", end: "08:00" } }, + ]) { + const response = await raw(app, owner, "notifications/settings/update", invalid); + expect(response.status, JSON.stringify(invalid)).toBe(400); + } + await expect( + rpc(app, owner, "notifications/settings/get"), + ).resolves.toEqual({ quietHours: { start: "21:00", end: "06:30" }, nudges: false }); + }); + + it("hands browsers one stable public key", async () => { + const cookie = await signup(app, `push-config-${stamp}@rakazo.test`, "Push Config"); + const first = await rpc(app, cookie, "notifications/webPush/config"); + expect(first.available).toBe(true); + expect(first.publicKey).toMatch(/^[A-Za-z0-9_-]{87}$/); + await expect(rpc(app, cookie, "notifications/webPush/config")).resolves.toEqual( + first, + ); + const stored = await handles.prisma.integrationProviderConfig.findUniqueOrThrow({ + where: { id: "web-push-vapid" }, + }); + expect(stored.ciphertext).not.toContain(first.publicKey!); + }); + + it("registers browsers and phones per user, and only the owner removes them", async () => { + const owner = await signup(app, `push-owner-${stamp}@rakazo.test`, "Push Owner"); + const intruder = await signup(app, `push-intruder-${stamp}@rakazo.test`, "Push Intruder"); + const endpoint = `https://push.example.test/${stamp}`; + + await rpc(app, owner, "notifications/webPush/subscribe", { endpoint, keys }); + await rpc(app, owner, "notifications/registerPush", { token: `ExponentPushToken[${stamp}]` }); + expect(await devices(owner)).toEqual([ + { kind: "webpush", endpoint, userAgent: "" }, + { kind: "expo", endpoint: `ExponentPushToken[${stamp}]`, userAgent: "" }, + ]); + + await rpc(app, intruder, "notifications/webPush/unsubscribe", { endpoint }); + await rpc(app, intruder, "notifications/unregisterPush"); + expect(await devices(owner)).toHaveLength(2); + + await rpc(app, owner, "notifications/unregisterPush"); + expect(await devices(owner)).toEqual([{ kind: "webpush", endpoint, userAgent: "" }]); + await rpc(app, owner, "notifications/webPush/unsubscribe", { endpoint }); + expect(await devices(owner)).toEqual([]); + }); + + it("rejects endpoints that are not public https push services", async () => { + const cookie = await signup(app, `push-invalid-${stamp}@rakazo.test`, "Push Invalid"); + for (const endpoint of [ + "http://push.example.test/device", + "https://127.0.0.1/device", + "https://localhost/device", + "https://10.0.0.8/device", + "https://user:secret@push.example.test/device", + "not a url", + ]) { + const response = await raw(app, cookie, "notifications/webPush/subscribe", { + endpoint, + keys, + }); + expect(response.status, endpoint).toBe(400); + } + const badKeys = await raw(app, cookie, "notifications/webPush/subscribe", { + endpoint: `https://push.example.test/${stamp}-keys`, + keys: { p256dh: "short", auth: keys.auth }, + }); + expect(badKeys.status).toBe(400); + const badToken = await raw(app, cookie, "notifications/registerPush", { + token: "https://push.example.test/not-an-expo-token", + }); + expect(badToken.status).toBe(400); + expect(await devices(cookie)).toEqual([]); + }); + + it("refuses every notification procedure without a session", async () => { + for (const [procedure, body] of [ + ["notifications/settings/get", {}], + ["notifications/settings/update", { nudges: false }], + ["notifications/webPush/config", {}], + ["notifications/webPush/subscribe", { endpoint: `https://push.example.test/${stamp}`, keys }], + ["notifications/webPush/unsubscribe", { endpoint: `https://push.example.test/${stamp}` }], + ["notifications/registerPush", { token: "ExponentPushToken[anon]" }], + ["notifications/unregisterPush", {}], + ] as const) { + const response = await raw(app, "", procedure, body); + expect(response.status, procedure).toBe(401); + } + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/onboarding-discovery.postgres.test.ts b/packages/testkit/src/onboarding-discovery.postgres.test.ts new file mode 100644 index 0000000..e6d8163 --- /dev/null +++ b/packages/testkit/src/onboarding-discovery.postgres.test.ts @@ -0,0 +1,395 @@ +import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + ComposioEmulator, + PipedreamConnector, + ThirdPartyConnectorEmulator, +} from "@rakazo/adapters"; +import type { + OnboardingScanApplyResult, + OnboardingScanStatus, + SpaceNavigation, + UserProfileState, +} from "@rakazo/contracts"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; +import { writeOnboardingScanFixture } from "./onboarding-scan-fixture.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Actor = { userId: string; spaceId: string }; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; +const TEST_ENCRYPTION_KEY = "offline-discovery-test-encryption-key"; + +describeWithDatabase("onboarding discovery through the API", () => { + let handles: AppHandles; + let app: App; + let composio: ComposioEmulator; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-discovery-")); + const home = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-home-"))); + const outside = realpathSync(mkdtempSync(path.join(tmpdir(), "rakazo-scan-outside-"))); + + beforeAll(async () => { + await writeOnboardingScanFixture(home); + const { createApp } = await import("../../../apps/api/src/app.ts"); + composio = new ComposioEmulator(); + const thirdParties = new ThirdPartyConnectorEmulator(); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + encryptionKey: TEST_ENCRYPTION_KEY, + onboardingScanHome: home, + composio, + pipedream: new PipedreamConnector( + { + clientId: "fake-client-id", + clientSecret: "fake-client-secret", + projectId: "fake-project-id", + environment: "development", + identitySecret: TEST_ENCRYPTION_KEY, + }, + { fetch: thirdParties.fetch, resolveHostname: thirdParties.resolveHostname }, + ), + remoteConnectors: { + fetch: thirdParties.fetch, + resolveHostname: thirdParties.resolveHostname, + }, + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + for (const dir of [dataDir, home, outside]) rmSync(dir, { recursive: true, force: true }); + }); + + async function account(label: string): Promise<{ cookie: string; actor: Actor }> { + const cookie = await signup(app, `${label}-${stamp}@rakazo.test`, label); + return { cookie, actor: await rpc(app, cookie, "me") }; + } + + async function makeDeploymentOwner(actor: Actor): Promise { + await handles.prisma.deploymentSettings.update({ + where: { id: "default" }, + data: { ownerUserId: actor.userId }, + }); + } + + async function finishedScan(cookie: string) { + const { scanId } = await rpc<{ scanId: string }>(app, cookie, "onboarding/scan"); + const status = await vi.waitFor( + async () => { + const current = await rpc(app, cookie, "onboarding/scanStatus", { + scanId, + }); + if (current.status === "scanning") throw new Error("still scanning"); + return current; + }, + { timeout: 10_000, interval: 50 }, + ); + if (status.status !== "complete") throw new Error("scan failed"); + return { scanId, ...status }; + } + + it("offers the scan only to the deployment owner and keeps it with them", async () => { + const owner = await account("discovery-owner"); + const other = await account("discovery-other"); + await makeDeploymentOwner(owner.actor); + + await expect(rpc(app, other.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: null, + }); + expect((await raw(app, other.cookie, "onboarding/scan")).status).toBe(403); + await expect(rpc(app, owner.cookie, "onboarding/scanTarget")).resolves.toEqual({ + folder: home, + }); + + const scan = await finishedScan(owner.cookie); + expect(scan.projects).toEqual([ + { id: "project-0", name: "atlas", path: "code/atlas" }, + { id: "project-1", name: "garden", path: "code/garden" }, + ]); + expect(scan.facts.map((fact) => fact.label)).toEqual([ + "Python", + "TypeScript", + "Docker", + "GitHub Actions", + ]); + expect( + (await raw(app, other.cookie, "onboarding/scanStatus", { scanId: scan.scanId })).status, + ).toBe(404); + expect( + ( + await raw(app, other.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:Python"], + }) + ).status, + ).toBe(404); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("creates the chosen projects in the owner's own spaces and saves only the chosen facts", async () => { + const owner = await account("discovery-creator"); + const intruder = await account("discovery-intruder"); + const atlasFolder = path.join(home, "code/atlas"); + // Another tenant already linked a space with the same name to the same folder, back when + // they held the deployment (only the deployment owner can link host folders). + await makeDeploymentOwner(intruder.actor); + const foreign = await rpc<{ id: string }>(app, intruder.cookie, "spaces/create", { + name: "atlas", + workspacePath: atlasFolder, + }); + await makeDeploymentOwner(owner.actor); + + const scan = await finishedScan(owner.cookie); + const choice = { + scanId: scan.scanId, + projectIds: ["project-0"], + factIds: ["language:TypeScript", "tool:Docker"], + }; + const applied = await rpc( + app, + owner.cookie, + "onboarding/applyScan", + choice, + ); + + expect(applied.factsAdded).toBe(2); + expect(applied.spaces).toEqual([{ id: expect.any(String), name: "atlas" }]); + const created = applied.spaces[0]!.id; + expect(created).not.toBe(foreign.id); + const ownerOrg = await handles.prisma.space.findUniqueOrThrow({ + where: { id: owner.actor.spaceId }, + select: { organizationId: true }, + }); + await expect( + handles.prisma.space.findUniqueOrThrow({ + where: { id: created }, + select: { organizationId: true, workspacePath: true }, + }), + ).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder }); + // Like any new project, it starts with its own main agent and main conversation. + await expect( + handles.prisma.chatGroup.count({ + where: { spaceId: created, userId: owner.actor.userId, kind: "main" }, + }), + ).resolves.toBe(1); + await expect( + handles.prisma.spaceMember.findMany({ + where: { spaceId: { in: [created, foreign.id] } }, + select: { spaceId: true, userId: true, role: true }, + orderBy: { spaceId: "asc" }, + }), + ).resolves.toEqual( + expect.arrayContaining([ + { spaceId: created, userId: owner.actor.userId, role: "owner" }, + { spaceId: foreign.id, userId: intruder.actor.userId, role: "owner" }, + ]), + ); + expect( + await handles.prisma.spaceMember.count({ where: { spaceId: { in: [created, foreign.id] } } }), + ).toBe(2); + await expect( + handles.prisma.memoryDocument.count({ + where: { spaceId: created, userId: owner.actor.userId, scope: "user" }, + }), + ).resolves.toBe(1); + + const ownerSpaces = await rpc(app, owner.cookie, "spaces/list"); + expect(ownerSpaces.spaces).toContainEqual( + expect.objectContaining({ + id: created, + name: "atlas", + workspace: expect.objectContaining({ path: atlasFolder }), + }), + ); + expect(ownerSpaces.spaces.map((space) => space.id)).not.toContain(foreign.id); + const intruderSpaces = await rpc(app, intruder.cookie, "spaces/list"); + expect(intruderSpaces.spaces.map((space) => space.id)).not.toContain(created); + + const profile = await rpc(app, owner.cookie, "profile/get"); + expect( + profile.facts.map(({ category, value, source }) => ({ category, value, source })), + ).toEqual( + expect.arrayContaining([ + { category: "work", value: "Codes in TypeScript", source: "onboarding" }, + { category: "tools", value: "Uses Docker", source: "onboarding" }, + ]), + ); + expect(profile.facts).toHaveLength(2); + await expect(rpc(app, intruder.cookie, "profile/get")).resolves.toMatchObject( + { facts: [] }, + ); + + // Applying again reuses the space and the facts. + await expect( + rpc(app, owner.cookie, "onboarding/applyScan", choice), + ).resolves.toEqual({ spaces: [{ id: created, name: "atlas" }], factsAdded: 0 }); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 2, + ); + }); + + it("refuses a chosen project that left the scan folder and writes nothing", async () => { + const owner = await account("discovery-escape"); + await makeDeploymentOwner(owner.actor); + const scan = await finishedScan(owner.cookie); + await mkdir(path.join(outside, "elsewhere", ".git"), { recursive: true }); + await rm(path.join(home, "code/garden"), { recursive: true, force: true }); + symlinkSync(path.join(outside, "elsewhere"), path.join(home, "code/garden")); + + const response = await raw(app, owner.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-1"], + factIds: ["language:Python"], + }); + expect(response.status).toBe(400); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 1, + ); + await expect(rpc(app, owner.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("refuses a chosen project whose folder became a file and writes nothing", async () => { + const owner = await account("discovery-file"); + await makeDeploymentOwner(owner.actor); + const garden = path.join(home, "code/garden"); + await rm(garden, { recursive: true, force: true }); + await writeOnboardingScanFixture(home); + const scan = await finishedScan(owner.cookie); + await rm(garden, { recursive: true, force: true }); + await writeFile(garden, "not a folder\n"); + + const response = await raw(app, owner.cookie, "onboarding/applyScan", { + scanId: scan.scanId, + projectIds: ["project-0", "project-1"], + factIds: ["language:Python"], + }); + expect(response.status).toBe(400); + expect(await handles.prisma.spaceMember.count({ where: { userId: owner.actor.userId } })).toBe( + 1, + ); + await expect(rpc(app, owner.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); + + it("remembers each connected app once as a tools fact and keeps it after revoking", async () => { + const user = await account("discovery-apps"); + const other = await account("discovery-apps-other"); + // A featured app is named by its label, whatever the account is called. + const gmail = await rpc<{ connectionId: string }>(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GMAIL", + displayName: "Personal", + }); + // Other apps take the first account's name; a second account adds nothing. + for (const displayName of ["GitHub", "GitHub 2"]) { + await rpc(app, user.cookie, "connections/begin", { + connectorId: "composio", + provider: "GITHUB", + displayName, + }); + } + // Connected through the authorization round trip. + const linear = await rpc<{ connectionId: string; authorizationUrl: string | null }>( + app, + user.cookie, + "connections/begin", + { connectorId: "pipedream", provider: "linear", displayName: "Linear" }, + ); + expect(linear.authorizationUrl).not.toBeNull(); + await expect( + rpc<{ status: string }>(app, user.cookie, "connections/complete", { + connectionId: linear.connectionId, + }), + ).resolves.toMatchObject({ status: "connected" }); + // Connected remotely, then noticed when the catalog reconciles a pending row. + await handles.prisma.connection.create({ + data: { + spaceId: user.actor.spaceId, + userId: user.actor.userId, + provider: "SLACK", + displayName: "SLACK", + status: "pending", + }, + }); + await composio.begin( + { provider: "SLACK", redirectUrl: "http://127.0.0.1.invalid/callback" }, + { + operationId: "discovery-test", + traceId: "discovery-test", + spaceId: user.actor.spaceId, + userId: user.actor.userId, + signal: new AbortController().signal, + }, + ); + await rpc(app, user.cookie, "connections/catalog", { connectorId: "composio" }); + await rpc(app, user.cookie, "connections/revoke", { connectionId: gmail.connectionId }); + + const profile = await rpc(app, user.cookie, "profile/get"); + expect( + profile.facts + .map(({ category, value, source }) => ({ category, value, source })) + .sort((left, right) => left.value.localeCompare(right.value)), + ).toEqual([ + { category: "tools", value: "Uses GitHub", source: "connector" }, + { category: "tools", value: "Uses Gmail", source: "connector" }, + { category: "tools", value: "Uses Linear", source: "connector" }, + { category: "tools", value: "Uses Slack", source: "connector" }, + ]); + await expect(rpc(app, other.cookie, "profile/get")).resolves.toMatchObject({ + facts: [], + }); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { "content-type": "application/json", cookie, origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/onboarding-intake.postgres.test.ts b/packages/testkit/src/onboarding-intake.postgres.test.ts new file mode 100644 index 0000000..6825fc2 --- /dev/null +++ b/packages/testkit/src/onboarding-intake.postgres.test.ts @@ -0,0 +1,317 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator, getCoachRhythm } from "@rakazo/adapters"; +import type { MessageBlock, SpaceNavigation } from "@rakazo/contracts"; +import { getNotificationSettings, loadUserContext } from "@rakazo/db"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import type { createApp } from "../../../apps/api/src/app.ts"; +import { sessionCookieHeader } from "./index.js"; + +type App = { request: (input: string, init?: RequestInit) => Response | Promise }; +type AppHandles = Awaited>; +type Choice = Extract; + +process.env.WAKEUP_DRIVER = "memory"; +process.env.SANDBOX_PROVIDER = "fake"; +process.env.AGENT_RUNTIME = "scripted"; + +const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL); +const describeWithDatabase = hasDb ? describe : describe.skip; + +describeWithDatabase("onboarding questions in the main conversation", () => { + let handles: AppHandles; + let app: App; + const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-onboarding-")); + + beforeAll(async () => { + const { createApp } = await import("../../../apps/api/src/app.ts"); + handles = await createApp({ + databaseUrl: process.env.DATABASE_URL!, + dataDir, + sandboxProvider: "fake", + agentRuntime: "scripted", + wakeupDriver: "memory", + signupsEnabled: "true", + composio: new ComposioEmulator(), + }); + app = handles.app; + }); + + afterAll(async () => { + await handles?.stop(); + rmSync(dataDir, { recursive: true, force: true }); + }); + + /** A fresh user with their main bot, as the web wizard leaves them. */ + async function newUser(name: string) { + const cookie = await signup(app, `${name.toLowerCase()}-${stamp}@rakazo.test`, name); + const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me"); + await rpc(app, cookie, "profile/update", { timeZone: "Europe/Vienna", locale: "en-US" }); + const main = await rpc<{ id: string }>(app, cookie, "bots/ensureMain"); + const { main: conversation } = await rpc<{ main: { groupId: string } }>( + app, + cookie, + "spaces/list", + ); + const { id: threadId } = await handles.prisma.thread.findUniqueOrThrow({ + where: { groupId: conversation.groupId }, + select: { id: true }, + }); + return { + cookie, + userId: me.userId, + spaceId: me.spaceId, + botId: main.id, + groupId: conversation.groupId, + threadId, + }; + } + + /** The main conversation's preview as the sidebar lists it, in its space and across spaces. */ + async function listedPreview(user: { cookie: string; groupId: string }) { + const { current, spaces } = await rpc(app, user.cookie, "spaces/list"); + const previews = [current.groups, ...spaces.map((space) => space.groups)] + .flat() + .filter((group) => group.id === user.groupId) + .map(({ preview, previewBlock }) => ({ preview, previewBlock })); + expect(previews).toHaveLength(2); + expect(previews[1]).toEqual(previews[0]); + return previews[0]; + } + + async function messages(threadId: string) { + const rows = await handles.prisma.message.findMany({ + where: { threadId }, + orderBy: { seq: "asc" }, + select: { blocks: true }, + }); + return rows.flatMap((row) => row.blocks as MessageBlock[]); + } + + async function cards(threadId: string): Promise { + return (await messages(threadId)).filter((block): block is Choice => block.kind === "choice"); + } + + async function pending(threadId: string) { + return (await cards(threadId)).filter((card) => !card.answerId); + } + + const onboardingRow = (userId: string) => + handles.prisma.userOnboarding.findUniqueOrThrow({ where: { userId } }); + + it("asks one question after another and records every answer", async () => { + const ada = await newUser("Ada"); + const choose = (body: Record) => + rpc(app, ada.cookie, "onboarding/choose", { botId: ada.botId, ...body }); + + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "focus", completedAt: null }); + // The card has no text of its own; the web words the preview from the card. + expect(await listedPreview(ada)).toEqual({ + preview: "", + previewBlock: (await pending(ada.threadId))[0], + }); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(true); + + await choose({ step: "focus", optionId: "day" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + const facts = await handles.prisma.userFact.findMany({ where: { userId: ada.userId } }); + expect(facts.map(({ category, value, source }) => ({ category, value, source }))).toEqual([ + { category: "goals", value: "Wants help with day-to-day work first", source: "onboarding" }, + ]); + const afterFocus = await messages(ada.threadId); + expect(afterFocus.filter((block) => block.kind === "app_connect").length).toBeGreaterThan(0); + // The cards speak for themselves: onboarding writes no text of its own. + expect(afterFocus.filter((block) => block.kind === "text")).toEqual([]); + // The app suggestions come before the next question. + expect(afterFocus.at(-1)).toMatchObject({ kind: "choice", step: "goal" }); + + // A stale answer to a card that is no longer pending changes nothing. + await choose({ step: "focus", optionId: "inbox" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ step: "goal", focus: "day" }); + await expect(choose({ step: "goal", optionId: "morning" })).rejects.toThrow(/400/); + await expect(choose({ step: "goal", optionId: "_text", text: " " })).rejects.toThrow(/400/); + + // A double submit records the goal once. + await Promise.all([ + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + choose({ step: "goal", optionId: "_text", text: "Run a 10K" }), + ]); + const goals = await handles.prisma.userGoal.findMany({ where: { userId: ada.userId } }); + expect(goals.map(({ title, source }) => ({ title, source }))).toEqual([ + { title: "Run a 10K", source: "onboarding" }, + ]); + expect((await cards(ada.threadId)).find((card) => card.step === "goal")).toMatchObject({ + answerId: "_text", + answerText: "Run a 10K", + }); + + await choose({ step: "rhythm", optionId: "morning" }); + expect(await getCoachRhythm(handles.prisma, ada.userId)).toEqual({ + morning: "08:00", + evening: null, + weekly: null, + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["quiet_hours"]); + expect( + (await pending(ada.threadId))[0]?.options.map((option) => option.label.replace(/\s/g, " ")), + ).toEqual(["10:00 PM – 7:00 AM", "11:00 PM – 8:00 AM", "Skip"]); + + // A browser that can still turn push on gets the notifications question. + await choose({ step: "quiet_hours", optionId: "22-07", canEnablePush: true }); + expect((await getNotificationSettings(handles.prisma, ada.userId)).quietHours).toEqual({ + start: "22:00", + end: "07:00", + }); + expect((await pending(ada.threadId)).map((card) => card.step)).toEqual(["notifications"]); + + await choose({ step: "notifications", optionId: "later" }); + expect(await onboardingRow(ada.userId)).toMatchObject({ + step: null, + completedAt: expect.any(Date), + }); + expect(await pending(ada.threadId)).toEqual([]); + // The closing note carries the first check-in for the web and an English line for others. + const closing = (await messages(ada.threadId)).at(-1); + expect(closing).toMatchObject({ kind: "intake_note", note: "done" }); + if (closing?.kind !== "intake_note") throw new Error("no closing note"); + expect(closing.text.replace(/\s/g, " ")).toMatch( + /^All set\. I’ll check in (today|tomorrow) at 8:00 AM\.$/, + ); + expect(await listedPreview(ada)).toEqual({ preview: closing.text, previewBlock: closing }); + const firstCheckIn = new Date(closing.nextCheckInAt ?? ""); + expect(firstCheckIn.getTime()).toBeGreaterThan(Date.now()); + expect( + new Intl.DateTimeFormat("en-GB", { + timeZone: "Europe/Vienna", + hour: "2-digit", + minute: "2-digit", + }).format(firstCheckIn), + ).toBe("08:00"); + expect( + (await loadUserContext(handles.prisma, { userId: ada.userId, spaceId: ada.spaceId })) + .onboardingOpen, + ).toBe(false); + + // Onboarding runs once. + const count = (await messages(ada.threadId)).length; + await rpc(app, ada.cookie, "onboarding/promptFocus", { botId: ada.botId }); + expect((await messages(ada.threadId)).length).toBe(count); + }); + + it("moves on when a question is skipped or dismissed, and saves nothing for it", async () => { + const bo = await newUser("Bo"); + const choose = (body: Record) => + rpc(app, bo.cookie, "onboarding/choose", { botId: bo.botId, ...body }); + + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: bo.botId }); + await rpc(app, bo.cookie, "onboarding/dismissFocus", { botId: bo.botId }); + expect((await cards(bo.threadId))[0]).toMatchObject({ step: "focus", answerId: "skip" }); + await choose({ step: "goal", optionId: "skip" }); + await choose({ step: "rhythm", optionId: "none" }); + await choose({ step: "quiet_hours", optionId: "skip" }); + + expect(await onboardingRow(bo.userId)).toMatchObject({ + step: null, + focus: null, + completedAt: expect.any(Date), + }); + expect(await handles.prisma.userFact.count({ where: { userId: bo.userId } })).toBe(0); + expect(await handles.prisma.userGoal.count({ where: { userId: bo.userId } })).toBe(0); + expect(await getCoachRhythm(handles.prisma, bo.userId)).toEqual({ + morning: null, + evening: null, + weekly: null, + }); + expect((await getNotificationSettings(handles.prisma, bo.userId)).quietHours).toBeNull(); + expect((await messages(bo.threadId)).at(-1)).toEqual({ + kind: "intake_note", + note: "done", + nextCheckInAt: null, + text: "All set. I’m here whenever you need me.", + }); + + // Another bot still gets only its focus card, outside onboarding. + const helper = await rpc<{ id: string; threadId: string }>(app, bo.cookie, "bots/create", { + name: "Helper", + title: "", + description: "", + instructions: "", + notifyOnFinish: true, + }); + const { thread } = await handles.prisma.bot.findUniqueOrThrow({ + where: { id: helper.id }, + select: { thread: { select: { id: true } } }, + }); + await rpc(app, bo.cookie, "onboarding/promptFocus", { botId: helper.id }); + const [focus] = await pending(thread!.id); + expect(focus?.step).toBeUndefined(); + expect(focus?.options.map((option) => option.id)).toEqual(["day", "inbox", "research", "crew"]); + await rpc(app, bo.cookie, "onboarding/choose", { botId: helper.id, optionId: "research" }); + expect(await pending(thread!.id)).toEqual([]); + expect(await onboardingRow(bo.userId)).toMatchObject({ focus: null }); + }); + + it("keeps each user's onboarding to themselves", async () => { + const owner = await newUser("Owner"); + const other = await newUser("Other"); + await rpc(app, owner.cookie, "onboarding/promptFocus", { botId: owner.botId }); + + for (const [procedure, body] of [ + ["onboarding/promptFocus", { botId: owner.botId }], + ["onboarding/choose", { botId: owner.botId, step: "focus", optionId: "day" }], + ["onboarding/dismissFocus", { botId: owner.botId }], + ["onboarding/start", { botId: owner.botId }], + ] as const) { + const response = await raw(app, other.cookie, procedure, body); + expect(response.status, procedure).toBeGreaterThanOrEqual(400); + } + expect(await onboardingRow(owner.userId)).toMatchObject({ step: "focus", focus: null }); + expect((await pending(owner.threadId)).map((card) => card.step)).toEqual(["focus"]); + expect( + await handles.prisma.userOnboarding.findUnique({ where: { userId: other.userId } }), + ).toBe(null); + expect(await handles.prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); + }); +}); + +async function signup(app: App, email: string, name: string) { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" }, + body: JSON.stringify({ email, password: "password12", name }), + }); + if (response.status >= 400) { + throw new Error(`signup failed ${response.status}: ${await response.text()}`); + } + return sessionCookieHeader(response); +} + +async function raw(app: App, cookie: string, procedure: string, body: unknown = {}) { + return app.request(`/rpc/${procedure}`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie, + origin: "http://127.0.0.1:5173", + }, + body: JSON.stringify({ json: body }), + }); +} + +async function rpc(app: App, cookie: string, procedure: string, body: unknown = {}) { + const response = await raw(app, cookie, procedure, body); + const text = await response.text(); + const payload = JSON.parse(text) as { json?: T; error?: { message?: string } }; + if (response.status >= 400 || payload.error) { + throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`); + } + return payload.json as T; +} diff --git a/packages/testkit/src/onboarding-scan-fixture.ts b/packages/testkit/src/onboarding-scan-fixture.ts new file mode 100644 index 0000000..f79e884 --- /dev/null +++ b/packages/testkit/src/onboarding-scan-fixture.ts @@ -0,0 +1,35 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** An empty but valid git directory, so git commands stop here instead of searching upwards. */ +async function writeGitDirectory(project: string): Promise { + await mkdir(path.join(project, ".git", "objects"), { recursive: true }); + await mkdir(path.join(project, ".git", "refs"), { recursive: true }); + await writeFile(path.join(project, ".git", "HEAD"), "ref: refs/heads/main\n"); +} + +async function writeFiles(root: string, files: Record): Promise { + for (const [relative, content] of Object.entries(files)) { + const file = path.join(root, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, content); + } +} + +/** + * A home folder for the onboarding scan: two projects ("code/atlas" in TypeScript with Docker and + * GitHub Actions, "code/garden" in Python) and a notes folder that is not a project. + */ +export async function writeOnboardingScanFixture(home: string): Promise { + await writeFiles(home, { + "code/atlas/src/index.ts": "export const atlas = 1;\n", + "code/atlas/src/app.tsx": "export const App = () => null;\n", + "code/atlas/Dockerfile": "FROM scratch\n", + "code/atlas/.github/workflows/ci.yml": "on: push\n", + "code/garden/main.py": "print('garden')\n", + "code/garden/util.py": "VALUE = 1\n", + "notes/todo.md": "- water the plants\n", + }); + await writeGitDirectory(path.join(home, "code/atlas")); + await writeGitDirectory(path.join(home, "code/garden")); +} diff --git a/packages/testkit/src/user-profile.postgres.test.ts b/packages/testkit/src/user-profile.postgres.test.ts index 11b3ede..462e461 100644 --- a/packages/testkit/src/user-profile.postgres.test.ts +++ b/packages/testkit/src/user-profile.postgres.test.ts @@ -161,6 +161,47 @@ describeWithDatabase("user profile through the API and the main bot", () => { expect(forgotten.facts).toEqual([]); expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" }); }); + + it("edits a fact's wording, category and spaces for its owner only", async () => { + const cookie = await signup(app, `profile-edit-${stamp}@rakazo.test`, "Profile Edit"); + const intruder = await signup(app, `profile-edit-other-${stamp}@rakazo.test`, "Intruder"); + const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" }); + const elsewhere = await rpc<{ id: string }>(app, intruder, "spaces/create", { name: "Theirs" }); + const added = await rpc(app, cookie, "profile/update", { + addFacts: [ + { category: "preferences", value: "Prefers tea" }, + { category: "work", value: "Works late" }, + ], + }); + const tea = added.facts.find((fact) => fact.value === "Prefers tea")!; + + const edited = await rpc(app, cookie, "profile/update", { + editFacts: [ + { id: tea.id, value: "Prefers green tea", category: "habits", spaceIds: [side.id] }, + ], + }); + expect(edited.facts.find((fact) => fact.id === tea.id)).toMatchObject({ + value: "Prefers green tea", + category: "habits", + allSpaces: false, + spaceIds: [side.id], + }); + + const edit = (body: unknown, who = cookie) => raw(app, who, "profile/update", body); + expect((await edit({ editFacts: [{ id: tea.id, value: "works late!" }] })).status).toBe(400); + expect((await edit({ editFacts: [{ id: tea.id, spaceIds: [elsewhere.id] }] })).status).toBe( + 400, + ); + expect((await edit({ editFacts: [{ id: tea.id, value: "Stolen" }] }, intruder)).status).toBe( + 404, + ); + expect((await rpc(app, intruder, "profile/get")).facts).toEqual([]); + expect( + (await rpc(app, cookie, "profile/get")).facts.find( + (fact) => fact.id === tea.id, + ), + ).toMatchObject({ value: "Prefers green tea", spaceIds: [side.id] }); + }); }); async function signup(app: App, email: string, name: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3101bcf..a5625ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -621,6 +621,9 @@ importers: undici: specifier: 8.10.2 version: 8.10.2 + web-push: + specifier: 3.6.7 + version: 3.6.7 zod: specifier: 4.6.2 version: 4.6.2 @@ -640,6 +643,9 @@ importers: '@types/pg': specifier: 8.23.1 version: 8.23.1 + '@types/web-push': + specifier: 3.6.4 + version: 3.6.4 typescript: specifier: 7.0.2 version: 7.0.2 @@ -4346,6 +4352,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4762,6 +4771,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -5023,6 +5035,9 @@ packages: bluebird@3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + bn.js@4.12.5: + resolution: {integrity: sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==} + body-parser@2.3.0: resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} engines: {node: '>=18'} @@ -6755,6 +6770,10 @@ packages: resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} engines: {node: '>=10.19.0'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -7623,6 +7642,9 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -9762,6 +9784,11 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -14106,6 +14133,10 @@ snapshots: '@types/unist@3.0.3': {} + '@types/web-push@3.6.4': + dependencies: + '@types/node': 24.13.3 + '@types/ws@8.18.1': dependencies: '@types/node': 24.13.3 @@ -14608,6 +14639,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.5 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -14950,6 +14988,8 @@ snapshots: bluebird@3.7.2: {} + bn.js@4.12.5: {} + body-parser@2.3.0: dependencies: bytes: 3.1.2 @@ -17019,6 +17059,8 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + http_ece@1.2.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -18119,6 +18161,8 @@ snapshots: mimic-response@3.1.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -20380,6 +20424,16 @@ snapshots: dependencies: defaults: 1.0.4 + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + web-streams-polyfill@3.3.3: {} web-vitals@6.2.1: {}