diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 7067820..52f3f8f 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -200,6 +200,7 @@ import { threadHead, threadSnapshot, } from "./thread-target.js"; +import { forgetProfile, getProfile, updateProfile } from "./user-profile.js"; import { disconnectVoiceCredential, listVoiceCatalog, @@ -539,6 +540,15 @@ export function createRouter(deps: RouterDeps) { return meDto(deps, context.actor); }), }, + 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), + ), + forget: authed.profile.forget.handler(({ context, input }) => + forgetProfile(deps.prisma, context.actor, input), + ), + }, spaces: { list: authed.spaces.list.handler(async ({ context }) => spaceNavigationDto(deps, context.actor, repos, groupRepos), diff --git a/apps/api/src/user-profile.ts b/apps/api/src/user-profile.ts new file mode 100644 index 0000000..4307f1a --- /dev/null +++ b/apps/api/src/user-profile.ts @@ -0,0 +1,60 @@ +import { ORPCError } from "@orpc/server"; +import type { + Actor, + ForgetUserFacts, + UpdateUserProfile, + UserProfileState, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { + forgetUserFacts, + getUserProfileState, + InvalidUserFactError, + UserFactNotFoundError, + updateUserProfile, +} from "@rakazo/db"; + +function toRpcError(error: unknown): unknown { + if (error instanceof UserFactNotFoundError) { + return new ORPCError("NOT_FOUND", { message: error.message }); + } + if (error instanceof InvalidUserFactError) { + return new ORPCError("BAD_REQUEST", { message: error.message }); + } + return error; +} + +export function getProfile(prisma: PrismaClient, actor: Actor): Promise { + return getUserProfileState(prisma, actor.userId); +} + +export async function updateProfile( + prisma: PrismaClient, + actor: Actor, + input: UpdateUserProfile, +): Promise { + try { + await updateUserProfile(prisma, actor.userId, input); + } catch (error) { + throw toRpcError(error); + } + return getUserProfileState(prisma, actor.userId); +} + +/** Unknown ids are ignored, so a caller cannot probe another user's facts. */ +export async function forgetProfile( + prisma: PrismaClient, + 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); +} diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 6729bc8..0eed244 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -222,6 +222,7 @@ export async function openUserSettings( | "integrations" | "models" | "memory" + | "profile" | "voice" | "usage" | "computer" diff --git a/apps/web/e2e/user-profile.spec.ts b/apps/web/e2e/user-profile.spec.ts new file mode 100644 index 0000000..9b57445 --- /dev/null +++ b/apps/web/e2e/user-profile.spec.ts @@ -0,0 +1,106 @@ +/** + * 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. + */ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, openUserSettings, rpc, signup, waitForChatComposer } from "./helpers"; + +test.use({ timezoneId: "Europe/Vienna" }); + +test.beforeEach(async ({ page }) => { + // Walk the full emoji-first flow, including the preferences step. + await page.route("**/rpc/integrationSetup/get", (route) => + route.fulfill({ + json: { + json: { + canConfigure: true, + needsSetup: false, + webUrl: "https://example.test/integrations/setup", + providers: [{ id: "composio", configured: true }], + }, + }, + }), + ); +}); + +test("onboarding feeds the profile; facts can be confirmed and forgotten", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `profile-${stamp}@rakazo.test`, "password12", "Profile Tester"); + + await expect(page.getByRole("heading", { name: /Design your Oo/i })).toBeVisible({ + timeout: 30_000, + }); + 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 waitForChatComposer(page); + + // 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"); + + // Sensitive facts from anywhere but the user wait for confirmation. + await rpc(page, "profile/update", { + addFacts: [{ category: "health", value: "Allergic to peanuts", source: "onboarding" }], + }); + + const settings = await openUserSettings(page, "profile"); + await expect(settings).toHaveAttribute("data-settings-section", "profile"); + const profile = settings.getByTestId("user-profile-settings"); + 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 allergy = profile.getByTestId("user-fact").filter({ hasText: "Allergic to peanuts" }); + await expect(allergy).toHaveAttribute("data-status", "pending"); + await captureScreenshot(page, testInfo, "user-profile-pending"); + await allergy.getByRole("button", { name: "Confirm" }).click(); + 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 captureScreenshot(page, testInfo, "user-profile-settings"); + const afterDelete = await rpc<{ facts: Array<{ value: string; status: string }> }>( + page, + "profile/get", + null, + ); + expect(afterDelete.facts).toEqual( + expect.not.arrayContaining([expect.objectContaining({ value: "Interested in music" })]), + ); + expect(afterDelete.facts).toContainEqual( + expect.objectContaining({ value: "Allergic to peanuts", status: "active" }), + ); + + await profile.getByRole("button", { name: "Forget everything" }).click(); + await page.getByRole("alertdialog").getByRole("button", { name: "Forget", exact: true }).click(); + await expect(profile).toContainText("Nothing yet"); + expect(await rpc<{ facts: unknown[] }>(page, "profile/get", null)).toMatchObject({ facts: [] }); +}); diff --git a/apps/web/src/components/UserProfileSettings.tsx b/apps/web/src/components/UserProfileSettings.tsx new file mode 100644 index 0000000..6a8e2e3 --- /dev/null +++ b/apps/web/src/components/UserProfileSettings.tsx @@ -0,0 +1,245 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import type { UserFactCategory, UserFactSource, UserProfileState } from "@rakazo/contracts"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, +} from "@rakazo/ui-web"; +import { XIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { useEffect, useId, useState } from "react"; +import { getActiveUiLocale } from "../lib/i18n"; +import { rpc } from "../lib/rpc"; +import type { ProfileField } from "../lib/user-profile-view"; +import { + CLEARED_PROFILE_FIELD, + displayLanguageTag, + displayRegionTag, + filledProfileFields, + groupUserFacts, +} from "../lib/user-profile-view"; + +function Row({ + value, + detail, + actions, + testId, + status, +}: { + value: string; + detail: string; + actions: (describedBy: string) => ReactNode; + testId: string; + status?: string; +}) { + const valueId = useId(); + return ( +
  • +
    +

    + {value} +

    +

    {detail}

    +
    +
    {actions(valueId)}
    +
  • + ); +} + +/** Everything the user's bots know about the user, with consent and forget controls. */ +export function UserProfileSettings() { + const { t } = useLingui(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [forgetOpen, setForgetOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + rpc.profile + .get() + .then((next) => { + if (!cancelled) setState(next); + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`); + }); + return () => { + cancelled = true; + }; + }, [t]); + + async function apply(change: () => Promise) { + if (busy) return; + setBusy(true); + setError(null); + try { + setState(await change()); + } catch (err) { + setError(err instanceof Error ? err.message : t`Couldn't save`); + } finally { + setBusy(false); + } + } + + const categoryLabels: Record = { + identity: t`Identity`, + work: t`Work`, + goals: t`Goals`, + people: t`People`, + health: t`Health`, + values: t`Values`, + habits: t`Habits`, + preferences: t`Preferences`, + tools: t`Tools`, + }; + const sourceLabels: Record = { + onboarding: t`Onboarding`, + user: t`You`, + conversation: t`Chat`, + consolidation: t`Chat history`, + connector: t`Connected app`, + }; + const fieldLabels: Record = { + timeZone: t`Time zone`, + language: t`Language`, + locale: t`Region`, + communicationStyle: t`Communication style`, + summary: t`Summary`, + }; + + const fields = state ? filledProfileFields(state.profile) : []; + const groups = state ? groupUserFacts(state.facts) : []; + const uiLocale = getActiveUiLocale(); + + const deleteButton = (describedBy: string, onClick: () => void) => ( + + ); + + return ( +
    + {error ? ( +

    + {error} +

    + ) : null} + {state && fields.length === 0 && groups.length === 0 ? ( +

    + Nothing yet +

    + ) : null} + {fields.length > 0 ? ( +
    +
      + {fields.map(({ field, value }) => ( + + deleteButton( + describedBy, + () => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])), + ) + } + /> + ))} +
    +
    + ) : null} + {groups.map((group) => ( +
    +

    + {categoryLabels[group.category]} +

    +
      + {group.facts.map((fact) => ( + ( + <> + {fact.status === "pending" ? ( + + ) : null} + {deleteButton( + describedBy, + () => void apply(() => rpc.profile.forget({ factIds: [fact.id] })), + )} + + )} + /> + ))} +
    +
    + ))} + {fields.length > 0 || groups.length > 0 ? ( + + ) : null} + + + + + Forget everything about you? + + + + + Cancel + + { + setForgetOpen(false); + void apply(() => rpc.profile.forget({ all: true })); + }} + > + Forget + + + + +
    + ); +} diff --git a/apps/web/src/lib/onboarding-profile.test.ts b/apps/web/src/lib/onboarding-profile.test.ts new file mode 100644 index 0000000..d9ce98d --- /dev/null +++ b/apps/web/src/lib/onboarding-profile.test.ts @@ -0,0 +1,56 @@ +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", () => { + expect( + onboardingProfileUpdate({ + answers: { + name: " Alice ", + timezone: "Europe/Vienna", + hobbies: ["music", "tech", "none"], + workStyle: "deep-dive", + communication: "direct", + }, + accountName: "Alice Example", + browserTimeZone: "UTC", + 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" }, + ], + }); + }); + + it("keeps the account name and falls back to the browser time zone", () => { + 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", + locale: "not a tag", + language: null, + }), + ).toEqual({ timeZone: "UTC" }); + }); +}); diff --git a/apps/web/src/lib/onboarding-profile.ts b/apps/web/src/lib/onboarding-profile.ts new file mode 100644 index 0000000..78d1d58 --- /dev/null +++ b/apps/web/src/lib/onboarding-profile.ts @@ -0,0 +1,73 @@ +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. */ +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, + ...(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/user-profile-harvest.test.ts b/apps/web/src/lib/user-profile-harvest.test.ts index 3c69655..8320c4a 100644 --- a/apps/web/src/lib/user-profile-harvest.test.ts +++ b/apps/web/src/lib/user-profile-harvest.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { collectLocalProfileSignals, - mergeUserProfileIntoMemory, + mergeUserMemorySections, persistUserProfileHarvest, } from "./user-profile-harvest"; @@ -20,48 +20,13 @@ describe("user-profile-harvest", () => { expect(signals.harvestedAt).toMatch(/^\d{4}-/); }); - it("merges profile block into MEMORY.md", () => { - const merged = mergeUserProfileIntoMemory( - "# Space memory\n\nExisting note.\n", - collectLocalProfileSignals({ - name: "Ingo", - email: "a@b.c", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }), - ); - expect(merged).toContain("## User profile (auto-harvested)"); - expect(merged).toContain("**Name:** Ingo"); + it("keeps existing notes and leaves profile data to the user profile", () => { + const merged = mergeUserMemorySections("# Space memory\n\nExisting note.\n"); expect(merged).toContain("Existing note."); + expect(merged).not.toContain("## User profile (auto-harvested)"); }); - it("updates an existing profile section in place", () => { - const base = mergeUserProfileIntoMemory( - "# Space memory\n", - collectLocalProfileSignals({ - name: "First", - email: "a@b.c", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }), - ); - const updated = mergeUserProfileIntoMemory( - base, - collectLocalProfileSignals({ - name: "Second", - email: "a@b.c", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }), - ); - expect(updated).toContain("**Name:** Second"); - expect(updated.match(/## User profile \(auto-harvested\)/g)?.length).toBe(1); - }); - - it("persists profile and help vibe via memory.update on user scope", async () => { + it("persists help vibe via memory.update on user scope without profile data", async () => { const updates: Array<{ documentId: string; content: string }> = []; const hints = await persistUserProfileHarvest( { @@ -81,8 +46,9 @@ describe("user-profile-harvest", () => { }), ); expect(updates).toHaveLength(1); - expect(updates[0]?.content).toContain("Ingo"); expect(updates[0]?.content).toContain("## Help & vibe (inferred)"); + expect(updates[0]?.content).not.toContain("Ingo"); + expect(updates[0]?.content).not.toContain("ingo@example.com"); expect(hints.suggestedLanes).toContain("builder"); }); }); diff --git a/apps/web/src/lib/user-profile-harvest.ts b/apps/web/src/lib/user-profile-harvest.ts index ba445ad..2320fa6 100644 --- a/apps/web/src/lib/user-profile-harvest.ts +++ b/apps/web/src/lib/user-profile-harvest.ts @@ -48,43 +48,6 @@ export function collectLocalProfileSignals( }; } -const PROFILE_SECTION = "## User profile (auto-harvested)"; - -export function mergeUserProfileIntoMemory(existing: string, signals: UserProfileSignals): string { - const block = formatProfileBlock(signals); - const marker = PROFILE_SECTION; - const start = existing.indexOf(marker); - if (start === -1) { - const trimmed = existing.trimEnd(); - const prefix = trimmed.length ? `${trimmed}\n\n` : ""; - return `${prefix}${marker}\n\n${block}\n`; - } - const rest = existing.slice(start + marker.length); - const nextSection = rest.search(/\n## /); - const tail = nextSection === -1 ? "" : rest.slice(nextSection); - return `${existing.slice(0, start).trimEnd()}\n\n${marker}\n\n${block}${tail}`; -} - -function formatProfileBlock(signals: UserProfileSignals): string { - const lines = [ - `- **Updated:** ${signals.harvestedAt}`, - signals.name ? `- **Name:** ${signals.name}` : null, - signals.email ? `- **Email:** ${signals.email}` : null, - `- **Timezone:** ${signals.timezone}`, - `- **Locale:** ${signals.locale}`, - signals.uiLocale ? `- **UI locale:** ${signals.uiLocale}` : null, - signals.languages.length ? `- **Languages:** ${signals.languages.join(", ")}` : null, - signals.platform ? `- **Platform:** ${signals.platform}` : null, - signals.defaultProvider || signals.defaultModel - ? `- **Default model:** ${signals.defaultProvider ?? "—"} / ${signals.defaultModel ?? "—"}` - : null, - signals.computerHost ? `- **Computer host:** ${signals.computerHost}` : null, - "", - "_Seeded during onboarding from local/browser signals. Extend manually in Memory settings._", - ]; - return lines.filter((line) => line !== null).join("\n"); -} - export type MemoryListClient = { memory: { list: (input: { @@ -94,20 +57,16 @@ export type MemoryListClient = { }; }; -/** Profile + optional help/vibe + Oo relationship product contract → user MEMORY.md. */ -export function mergeFullUserProfileMemory( - existing: string, - signals: UserProfileSignals, - helpVibe?: UserHelpVibeHints, -): string { - let content = mergeUserProfileIntoMemory(existing, signals); - if (helpVibe) { - content = mergeHelpVibeIntoMemory(content, helpVibe); - } +/** + * Optional help/vibe + Oo relationship product contract → user MEMORY.md. Name, time zone and + * language live in the user profile instead, so they are not repeated here. + */ +export function mergeUserMemorySections(existing: string, helpVibe?: UserHelpVibeHints): string { + const content = helpVibe ? mergeHelpVibeIntoMemory(existing, helpVibe) : existing; return mergeRelationshipContractIntoMemory(content); } -/** Persist harvest + help/vibe hints into space user `MEMORY.md` (existing memory system). */ +/** Persist help/vibe hints into space user `MEMORY.md` (existing memory system). */ export async function persistUserProfileHarvest( client: MemoryListClient, signals: UserProfileSignals, @@ -117,7 +76,7 @@ export async function persistUserProfileHarvest( const docs = await client.memory.list({ scope: "user" }); const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0]; if (!memoryDoc) return helpVibe; - const content = mergeFullUserProfileMemory(memoryDoc.content, signals, helpVibe); + const content = mergeUserMemorySections(memoryDoc.content, helpVibe); if (content !== memoryDoc.content) { await client.memory.update({ documentId: memoryDoc.id, content }); } diff --git a/apps/web/src/lib/user-profile-view.test.ts b/apps/web/src/lib/user-profile-view.test.ts new file mode 100644 index 0000000..0843590 --- /dev/null +++ b/apps/web/src/lib/user-profile-view.test.ts @@ -0,0 +1,76 @@ +import type { UserFact, UserProfile } from "@rakazo/contracts"; +import { describe, expect, it } from "vitest"; +import { + displayLanguageTag, + displayRegionTag, + filledProfileFields, + groupUserFacts, +} from "./user-profile-view"; + +function fact(overrides: Partial & Pick): UserFact { + return { + category: "work", + value: overrides.id, + confidence: 1, + source: "conversation", + status: "active", + sensitive: false, + allSpaces: true, + spaceIds: [], + sourceBotId: null, + sourceThreadId: null, + createdAt: "2026-10-01T10:00:00.000Z", + updatedAt: "2026-10-01T10:00:00.000Z", + ...overrides, + }; +} + +describe("user profile view", () => { + it("groups facts in category order with pending facts first, then the newest", () => { + const groups = groupUserFacts([ + fact({ id: "old-work", createdAt: "2026-09-01T00:00:00.000Z" }), + fact({ id: "new-work", createdAt: "2026-10-01T00:00:00.000Z" }), + fact({ id: "tool", category: "tools" }), + fact({ id: "pending-health", category: "health", status: "pending" }), + fact({ + id: "pending-work", + status: "pending", + createdAt: "2026-08-01T00:00:00.000Z", + }), + fact({ id: "identity", category: "identity" }), + ]); + expect(groups.map((group) => [group.category, group.facts.map((item) => item.id)])).toEqual([ + ["identity", ["identity"]], + ["work", ["pending-work", "new-work", "old-work"]], + ["health", ["pending-health"]], + ["tools", ["tool"]], + ]); + }); + + it("lists only filled profile fields", () => { + const profile: UserProfile = { + timeZone: "Europe/Vienna", + locale: null, + language: "de", + communicationStyle: "", + summary: "Nurse in Vienna.", + consolidatedAt: null, + updatedAt: null, + }; + expect(filledProfileFields(profile)).toEqual([ + { field: "timeZone", value: "Europe/Vienna" }, + { field: "language", value: "de" }, + { field: "summary", value: "Nurse in Vienna." }, + ]); + }); + + it("names languages and regions in the reader's language and keeps unknown tags", () => { + expect(displayLanguageTag("de", "en")).toBe("German"); + expect(displayLanguageTag("de", "de")).toBe("Deutsch"); + expect(displayLanguageTag("not a tag", "en")).toBe("not a tag"); + expect(displayRegionTag("de-AT", "en")).toBe("Austria"); + expect(displayRegionTag("en-US", "de")).toBe("Vereinigte Staaten"); + expect(displayRegionTag("de", "en")).toBe("German"); + expect(displayRegionTag("not a tag", "en")).toBe("not a tag"); + }); +}); diff --git a/apps/web/src/lib/user-profile-view.ts b/apps/web/src/lib/user-profile-view.ts new file mode 100644 index 0000000..97877dc --- /dev/null +++ b/apps/web/src/lib/user-profile-view.ts @@ -0,0 +1,77 @@ +import type { UserFact, UserFactCategory, UserProfile } from "@rakazo/contracts"; +import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; + +export type ProfileField = "timeZone" | "language" | "locale" | "communicationStyle" | "summary"; + +const PROFILE_FIELDS: ProfileField[] = [ + "timeZone", + "language", + "locale", + "communicationStyle", + "summary", +]; + +type ClearedProfileField = { + timeZone?: null; + language?: null; + locale?: null; + communicationStyle?: ""; + summary?: ""; +}; + +/** The `profile.update` input that removes one profile field. */ +export const CLEARED_PROFILE_FIELD: Record = { + timeZone: { timeZone: null }, + language: { language: null }, + locale: { locale: null }, + communicationStyle: { communicationStyle: "" }, + summary: { summary: "" }, +}; + +/** Profile fields that hold a value, in display order. */ +export function filledProfileFields( + profile: UserProfile, +): Array<{ field: ProfileField; value: string }> { + return PROFILE_FIELDS.flatMap((field) => { + const value = profile[field]; + return value ? [{ field, value }] : []; + }); +} + +/** Facts grouped in category order; pending facts lead each group, then the newest. */ +export function groupUserFacts( + facts: UserFact[], +): Array<{ category: UserFactCategory; facts: UserFact[] }> { + return USER_FACT_CATEGORIES.flatMap((category) => { + const inCategory = facts + .filter((fact) => fact.category === category) + .sort((a, b) => + a.status === b.status + ? b.createdAt.localeCompare(a.createdAt) + : a.status === "pending" + ? -1 + : 1, + ); + return inCategory.length > 0 ? [{ category, facts: inCategory }] : []; + }); +} + +/** A language tag in the reader's language, e.g. "de" → "German"; the tag itself if unknown. */ +export function displayLanguageTag(tag: string, uiLocale: string): string { + try { + return new Intl.DisplayNames([uiLocale], { type: "language" }).of(tag) ?? tag; + } catch { + return tag; + } +} + +/** The region of a locale tag in the reader's language, e.g. "de-AT" → "Austria". */ +export function displayRegionTag(tag: string, uiLocale: string): string { + try { + const region = new Intl.Locale(tag).region; + if (!region) return displayLanguageTag(tag, uiLocale); + return new Intl.DisplayNames([uiLocale], { type: "region" }).of(region) ?? tag; + } catch { + return tag; + } +} diff --git a/apps/web/src/lib/user-relationship-contract.test.ts b/apps/web/src/lib/user-relationship-contract.test.ts index 43aac88..2486f09 100644 --- a/apps/web/src/lib/user-relationship-contract.test.ts +++ b/apps/web/src/lib/user-relationship-contract.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { collectLocalProfileSignals, mergeFullUserProfileMemory } from "./user-profile-harvest"; +import { mergeUserMemorySections } from "./user-profile-harvest"; import { mergeRelationshipContractIntoMemory, RELATIONSHIP_SECTION, @@ -13,17 +13,8 @@ describe("user-relationship-contract", () => { expect(mergeRelationshipContractIntoMemory(once)).toBe(once); }); - it("is included in full profile harvest merge", () => { - const merged = mergeFullUserProfileMemory( - "# Space memory\n", - collectLocalProfileSignals({ - name: "Ingo", - email: "i@example.com", - defaultProvider: null, - defaultModel: null, - computerHost: null, - }), - ); + it("is included in the user memory merge", () => { + const merged = mergeUserMemorySections("# Space memory\n"); expect(merged).toContain(RELATIONSHIP_SECTION); expect(merged).toContain("Never creepy"); }); diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx index 2e6f89b..b3c31c4 100644 --- a/apps/web/src/pages/Onboarding.tsx +++ b/apps/web/src/pages/Onboarding.tsx @@ -14,12 +14,14 @@ import { KOKORO_LOCAL_TOKEN, OnboardingVoiceStep, } from "../components/onboarding/OnboardingVoiceStep"; +import { localTimezone } from "../lib/local-timezone"; import { computeDiscoveryItems, countConfiguredIntegrations, type OnboardingDiscoveryPhase, } from "../lib/onboarding-discovery"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; +import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { rpc } from "../lib/rpc"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; @@ -135,7 +137,7 @@ const COMMUNICATION_OPTIONS = [ function defaultPreferences(): UserPreferences { return { name: "", - timezone: "", + timezone: localTimezone(), hobbies: [], workStyle: "", communication: "", @@ -170,6 +172,7 @@ export function OnboardingPage() { return "en"; } }); + const [languageChoice, setLanguageChoice] = useState(null); useEffect(() => { let cancelled = false; @@ -265,15 +268,17 @@ export function OnboardingPage() { const bot = await ensureFirstBot(emojiConfig.avatarValue); setCrewReady(true); - const ctxParts: string[] = []; - if (prefs.name) ctxParts.push(`User: ${prefs.name}`); - if (prefs.timezone) ctxParts.push(`Timezone: ${prefs.timezone}`); - if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`); - if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`); - if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`); - const userCtx = - ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : ""; - await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null); + await rpc.profile + .update( + onboardingProfileUpdate({ + answers: prefs, + accountName: bootstrapMe?.name, + browserTimeZone: localTimezone(), + locale: typeof navigator !== "undefined" ? navigator.language : undefined, + language: languageChoice, + }), + ) + .catch(() => null); if (selectedVoiceId && !voiceSkipped) { await rpc.voice @@ -496,6 +501,7 @@ export function OnboardingPage() { onClick={() => { persistUiLocale(code as UiLocale); setCurrentLocale(code); + setLanguageChoice(code as UiLocale); setStep("voice"); }} className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${ diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx index 2a7d105..d71d40a 100644 --- a/apps/web/src/pages/SettingsOverlay.tsx +++ b/apps/web/src/pages/SettingsOverlay.tsx @@ -10,11 +10,13 @@ import { Monitor, Settings, ShieldCheck, + UserRound, Volume2, XIcon, } from "lucide-react"; import { type ComponentType, useEffect, useRef, useState } from "react"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; +import { UserProfileSettings } from "../components/UserProfileSettings"; import type { TradingBotUiState } from "../lib/trading-bot-status"; import { ComputerSettingsPanel, @@ -33,6 +35,7 @@ export type SettingsSection = | "integrations" | "models" | "memory" + | "profile" | "voice" | "usage" | "computer" @@ -106,6 +109,7 @@ export function SettingsOverlay({ { id: "integrations", label: t`Integrations`, icon: LayoutGrid }, { id: "models", label: t`Models`, icon: Cpu }, { id: "memory", label: t`Memory`, icon: Brain }, + { id: "profile", label: t`About you`, icon: UserRound }, { id: "voice", label: t`Voice`, icon: Volume2 }, { id: "usage", label: t`Usage`, icon: Gauge }, ...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []), @@ -245,6 +249,7 @@ export function SettingsOverlay({ onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined} /> ) : null} + {section === "profile" ? : null} {section === "usage" ? ( ) : null} diff --git a/packages/adapters/src/builtin-tools.ts b/packages/adapters/src/builtin-tools.ts index cc22529..6949f97 100644 --- a/packages/adapters/src/builtin-tools.ts +++ b/packages/adapters/src/builtin-tools.ts @@ -4,6 +4,7 @@ import { BotSecretName, SecretAskPurpose, SecretHttpRequest, + USER_FACT_CATEGORIES, } from "@rakazo/contracts"; import { z } from "zod"; @@ -382,6 +383,40 @@ export const builtinAgentTools: ConnectorTool[] = [ required: ["content"], }, }, + { + name: "update_user_profile", + description: + "Save what you learned about the user so all of their bots can use it: short facts and profile settings. Save only what the user said or clearly confirmed, never guesses. Health and values facts stay hidden from bots until the user confirms them.", + inputSchema: { + type: "object", + properties: { + facts: { + type: "array", + maxItems: 20, + items: { + type: "object", + properties: { + category: { type: "string", enum: [...USER_FACT_CATEGORIES] }, + value: { + type: "string", + description: 'One short statement, e.g. "Works night shifts as a nurse".', + }, + }, + required: ["category", "value"], + }, + }, + time_zone: { type: "string", description: "IANA time zone, e.g. Europe/Vienna." }, + language: { + type: "string", + description: "Language the user wants replies in, as a BCP-47 tag, e.g. de.", + }, + communication_style: { + type: "string", + description: 'How the user wants to be talked to, e.g. "brief and informal".', + }, + }, + }, + }, { name: "web_search", description: diff --git a/packages/adapters/src/executor-tools.ts b/packages/adapters/src/executor-tools.ts index 88fad49..d94d47f 100644 --- a/packages/adapters/src/executor-tools.ts +++ b/packages/adapters/src/executor-tools.ts @@ -28,6 +28,8 @@ export function selectBuiltinToolsForRun(options: { semanticMemoryEnabled: boolean; cloudAgentEnabled?: boolean; messagingChannelRun: boolean; + /** The user model is kept by the main bot only. */ + mainBot?: boolean; }) { return selectCloudAgentTools( selectMemoryTools( @@ -46,8 +48,15 @@ export function selectBuiltinToolsForRun(options: { Boolean(options.cloudAgentEnabled), ).filter( (tool) => - !options.messagingChannelRun || - (!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && - !tool.name.startsWith("scratchpad_")), + (options.mainBot || tool.name !== "update_user_profile") && + (!options.messagingChannelRun || + (![ + "remember", + "save_memory", + "recall_memory", + "forget_memory", + "update_user_profile", + ].includes(tool.name) && + !tool.name.startsWith("scratchpad_"))), ); } diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 399906e..8c1d098 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -359,6 +359,7 @@ import { } from "./thread-artifacts.js"; import { advanceToolCallLoopGuard } from "./tool-loop.js"; import { textContentArg } from "./tool-text.js"; +import { updateUserProfileFromTool } from "./user-profile-tools.js"; import { botMessageOutcomeFromMidTurn, clampUserProgressMessage, @@ -1409,6 +1410,7 @@ export function createRunExecutor(deps: ExecutorDeps) { semanticMemoryEnabled, cloudAgentEnabled: cloudAgentsEnabled(cloudAgent, run.spaceId), messagingChannelRun, + mainBot: bot.kind === "main", }), // Cross-owner agent connections only exist for chat-linked bots. ...(hasMessagingIdentity ? agentConnectionTools : []), @@ -2483,6 +2485,20 @@ export function createRunExecutor(deps: ExecutorDeps) { ); return finish({ ok: true }); } + if (name === "update_user_profile") { + return finish( + await updateUserProfileFromTool( + deps.prisma, + { + userId: run.userId, + bot, + threadId: thread.id, + sourceMessageId: run.sourceMessageId, + }, + args, + ), + ); + } if (name === "web_search") { return finish(await webSearchFromTool(web, context, args)); } diff --git a/packages/adapters/src/scripted-runtime.test.ts b/packages/adapters/src/scripted-runtime.test.ts index 7046e2b..2868782 100644 --- a/packages/adapters/src/scripted-runtime.test.ts +++ b/packages/adapters/src/scripted-runtime.test.ts @@ -73,6 +73,28 @@ describe("inferScript request_secret", () => { }); }); +describe("inferScript update_user_profile", () => { + const profileScript = (category: string, value: string) => [ + { + assistant: "noted — i will keep that in your profile.", + toolCalls: [{ name: "update_user_profile", args: { facts: [{ category, value }] } }], + complete: true, + }, + ]; + + it("records a categorized fact about the user", () => { + expect(inferScript("learn about me: health: Allergic to peanuts")).toEqual( + profileScript("health", "Allergic to peanuts"), + ); + }); + + it("defaults to preferences and wins over broader intents in the statement", () => { + expect(inferScript("Learn about me: I write every note in a file")).toEqual( + profileScript("preferences", "I write every note in a file"), + ); + }); +}); + describe("ScriptedAgentRuntime executionIds", () => { it("gives repeated tools distinct executionIds within a run", async () => { const runtime = new ScriptedAgentRuntime(); diff --git a/packages/adapters/src/scripted-runtime.ts b/packages/adapters/src/scripted-runtime.ts index c208e0d..30eb77c 100644 --- a/packages/adapters/src/scripted-runtime.ts +++ b/packages/adapters/src/scripted-runtime.ts @@ -4,10 +4,17 @@ import type { AgentRuntime, AgentRuntimeEvent, } from "@rakazo/adapter-kit"; +import { USER_FACT_CATEGORIES } from "@rakazo/contracts"; import { abortableDelay, inferHandoffTargetName } from "@rakazo/core"; const running = new Map(); +/** "learn about me: [category:] statement" records one fact in the user's profile. */ +const LEARN_ABOUT_ME = new RegExp( + `learn about me:\\s*(?:(${USER_FACT_CATEGORIES.join("|")})\\s*:\\s*)?([\\s\\S]+)$`, + "i", +); + export class ScriptedAgentRuntime implements AgentRuntime { describe() { return { @@ -173,6 +180,28 @@ export function inferScript( }, ]; } + const learned = LEARN_ABOUT_ME.exec(prompt); + if (learned?.[2]?.trim()) { + return [ + { + assistant: "noted — i will keep that in your profile.", + toolCalls: [ + { + name: "update_user_profile", + args: { + facts: [ + { + category: (learned[1] ?? "preferences").toLowerCase(), + value: learned[2].trim(), + }, + ], + }, + }, + ], + complete: true, + }, + ]; + } if ( lower.includes("launch a cloud agent") || lower.includes("start a cloud agent") || diff --git a/packages/adapters/src/user-profile-tools.test.ts b/packages/adapters/src/user-profile-tools.test.ts new file mode 100644 index 0000000..ddf953c --- /dev/null +++ b/packages/adapters/src/user-profile-tools.test.ts @@ -0,0 +1,110 @@ +import type { PrismaClient } from "@rakazo/db"; +import { describe, expect, it, vi } from "vitest"; +import { selectBuiltinToolsForRun } from "./executor-tools.js"; +import { updateUserProfileFromTool } from "./user-profile-tools.js"; + +function toolNames(options: { mainBot?: boolean; messagingChannelRun?: boolean }) { + return selectBuiltinToolsForRun({ + graphicalToolsAllowed: false, + groupId: null, + trigger: "user", + semanticMemoryEnabled: false, + messagingChannelRun: options.messagingChannelRun ?? false, + mainBot: options.mainBot, + }).map((tool) => tool.name); +} + +/** Any database access fails the test: refusals and invalid input must not reach it. */ +const untouchedPrisma = new Proxy( + {}, + { + get(_target, property) { + throw new Error(`unexpected database access: ${String(property)}`); + }, + }, +) as PrismaClient; + +const context = (kind: string) => ({ + userId: "user-1", + bot: { id: "bot-1", kind }, + threadId: "thread-1", + sourceMessageId: "message-1", +}); + +describe("update_user_profile", () => { + it("is offered to the main bot only, and never in shared messaging channels", () => { + expect(toolNames({ mainBot: true })).toContain("update_user_profile"); + expect(toolNames({ mainBot: false })).not.toContain("update_user_profile"); + expect(toolNames({})).not.toContain("update_user_profile"); + expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain( + "update_user_profile", + ); + }); + + it("refuses specialists even when they call the tool anyway", async () => { + await expect( + updateUserProfileFromTool(untouchedPrisma, 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"), { + facts: [{ category: "secrets", value: "x" }], + }); + expect(invalidCategory).toEqual({ error: expect.stringContaining("facts.0.category") }); + + const unknownZone = await updateUserProfileFromTool(untouchedPrisma, context("main"), { + time_zone: "Vienna", + }); + expect(unknownZone).toEqual({ error: expect.stringContaining("Unknown time zone") }); + + await expect(updateUserProfileFromTool(untouchedPrisma, context("main"), {})).resolves.toEqual({ + error: "Nothing to save. Pass facts or a profile field.", + }); + }); + + it("records conversation facts with where they were learned", async () => { + const created: Array> = []; + const tx = { + $queryRaw: vi.fn().mockResolvedValue([]), + userProfile: { upsert: vi.fn().mockResolvedValue({}) }, + userFact: { + findMany: vi.fn().mockResolvedValue([]), + create: vi.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: `fact-${created.length}`, value: data.value, status: data.status }; + }), + }, + }; + const prisma = { + $transaction: (run: (client: typeof tx) => Promise) => run(tx), + } as unknown as PrismaClient; + + const result = await updateUserProfileFromTool(prisma, context("main"), { + facts: [ + { category: "work", value: "Nurse" }, + { category: "health", value: "Asthma" }, + ], + time_zone: "europe/vienna", + }); + + expect(result).toMatchObject({ ok: true, saved: 1, pendingConfirmation: 1 }); + expect(tx.userProfile.upsert).toHaveBeenCalledWith( + expect.objectContaining({ update: { timeZone: "Europe/Vienna" } }), + ); + expect(created).toEqual([ + expect.objectContaining({ + category: "work", + value: "Nurse", + source: "conversation", + status: "active", + sourceBotId: "bot-1", + sourceThreadId: "thread-1", + sourceMessageId: "message-1", + }), + expect.objectContaining({ category: "health", status: "pending", sensitive: true }), + ]); + }); +}); diff --git a/packages/adapters/src/user-profile-tools.ts b/packages/adapters/src/user-profile-tools.ts new file mode 100644 index 0000000..19255cb --- /dev/null +++ b/packages/adapters/src/user-profile-tools.ts @@ -0,0 +1,82 @@ +import { + LanguageTagSchema, + TimeZoneSchema, + USER_COMMUNICATION_STYLE_MAX_LENGTH, + UserFactCategorySchema, + UserFactValueSchema, +} from "@rakazo/contracts"; +import type { PrismaClient } from "@rakazo/db"; +import { InvalidUserFactError, updateUserProfile } from "@rakazo/db"; +import { z } from "zod"; + +const UpdateUserProfileArgs = z.object({ + facts: z + .array(z.object({ category: UserFactCategorySchema, value: UserFactValueSchema })) + .max(20) + .optional(), + time_zone: TimeZoneSchema.optional(), + language: LanguageTagSchema.optional(), + communication_style: z.string().trim().min(1).max(USER_COMMUNICATION_STYLE_MAX_LENGTH).optional(), +}); + +export interface UserProfileToolContext { + userId: string; + bot: { id: string; kind: string }; + threadId: string; + /** The message the run answers, so a fact can be traced to where it was learned. */ + sourceMessageId: string | null; +} + +/** Records what the main bot learned about the user, attributed to the conversation. */ +export async function updateUserProfileFromTool( + prisma: PrismaClient, + context: UserProfileToolContext, + rawArgs: Record, +) { + if (context.bot.kind !== "main") { + return { error: "Only the user's main bot can update the user profile." }; + } + const parsed = UpdateUserProfileArgs.safeParse(rawArgs); + if (!parsed.success) { + const issues = parsed.error.issues + .map((issue) => `${issue.path.join(".") || "arguments"}: ${issue.message}`) + .join("; "); + return { error: `Invalid profile update: ${issues}` }; + } + const args = parsed.data; + const facts = args.facts ?? []; + if ( + facts.length === 0 && + args.time_zone === undefined && + args.language === undefined && + args.communication_style === undefined + ) { + return { error: "Nothing to save. Pass facts or a profile field." }; + } + try { + const added = await updateUserProfile(prisma, context.userId, { + timeZone: args.time_zone, + language: args.language, + communicationStyle: args.communication_style, + addFacts: facts.map((fact) => ({ + ...fact, + source: "conversation" as const, + sourceBotId: context.bot.id, + sourceThreadId: context.threadId, + sourceMessageId: context.sourceMessageId, + })), + }); + const pending = added.filter((fact) => fact.status === "pending").length; + return pending > 0 + ? { + ok: true, + saved: added.length - pending, + pendingConfirmation: pending, + note: "Health and values facts stay hidden from bots until the user confirms them in their settings.", + } + : { ok: true, saved: added.length }; + } catch (error) { + if (error instanceof InvalidUserFactError) return { error: error.message }; + throw error; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index bd5ffd8..4fafd50 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -15,3 +15,4 @@ export * from "./reactions.js"; export * from "./rpc.js"; export * from "./runs.js"; export * from "./search.js"; +export * from "./user-profile.js"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index b3a7091..1b07c2c 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -89,6 +89,11 @@ import { ProjectSessionsOutputSchema } from "./project-sessions.js"; import { MessageReactionSchema } from "./reactions.js"; import { RunsListOutputSchema } from "./runs.js"; import { SearchQueryOutputSchema } from "./search.js"; +import { + ForgetUserFactsInput, + UpdateUserProfileInput, + UserProfileStateSchema, +} from "./user-profile.js"; const botId = z.object({ botId: Id }); const groupId = z.object({ groupId: Id }); @@ -168,6 +173,12 @@ export const appContract = { preferences: { update: oc.input(z.object({ avatarStyle: AvatarStyleSchema })).output(MeSchema), }, + /** What the user's bots know about the user, across spaces. */ + profile: { + get: oc.output(UserProfileStateSchema), + update: oc.input(UpdateUserProfileInput).output(UserProfileStateSchema), + forget: oc.input(ForgetUserFactsInput).output(UserProfileStateSchema), + }, spaces: { list: oc.output(SpaceNavigationSchema), create: oc diff --git a/packages/contracts/src/user-profile.test.ts b/packages/contracts/src/user-profile.test.ts new file mode 100644 index 0000000..cc5bbab --- /dev/null +++ b/packages/contracts/src/user-profile.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { + ForgetUserFactsInput, + LanguageTagSchema, + TimeZoneSchema, + UpdateUserProfileInput, +} from "./user-profile.js"; + +describe("user profile contracts", () => { + it("stores IANA time zones in canonical spelling and rejects unknown ones", () => { + expect(TimeZoneSchema.parse(" europe/vienna ")).toBe("Europe/Vienna"); + expect(TimeZoneSchema.parse("UTC")).toBe("UTC"); + expect(TimeZoneSchema.safeParse("Vienna").success).toBe(false); + expect(TimeZoneSchema.safeParse("").success).toBe(false); + }); + + it("canonicalizes language tags and rejects malformed ones", () => { + expect(LanguageTagSchema.parse("de-at")).toBe("de-AT"); + expect(LanguageTagSchema.parse("pt-BR")).toBe("pt-BR"); + expect(LanguageTagSchema.safeParse("not a tag").success).toBe(false); + }); + + it("records user-added facts as the user's own unless they come from onboarding", () => { + const parsed = UpdateUserProfileInput.parse({ + timeZone: null, + addFacts: [ + { category: "work", value: " Nurse " }, + { category: "habits", value: "Runs daily", source: "onboarding", spaceIds: null }, + ], + }); + expect(parsed.timeZone).toBeNull(); + expect(parsed.addFacts).toEqual([ + { category: "work", value: "Nurse", source: "user" }, + { category: "habits", value: "Runs daily", source: "onboarding", spaceIds: null }, + ]); + expect( + UpdateUserProfileInput.safeParse({ + addFacts: [{ category: "work", value: "x", source: "conversation" }], + }).success, + ).toBe(false); + expect( + UpdateUserProfileInput.safeParse({ addFacts: [{ category: "secrets", value: "x" }] }).success, + ).toBe(false); + expect( + UpdateUserProfileInput.safeParse({ + editFacts: [{ id: "fact-1", spaceIds: [] }], + }).success, + ).toBe(false); + }); + + it("forgets by exactly one of ids, category, or everything", () => { + expect(ForgetUserFactsInput.safeParse({ factIds: ["fact-1"] }).success).toBe(true); + expect(ForgetUserFactsInput.safeParse({ category: "health" }).success).toBe(true); + expect(ForgetUserFactsInput.safeParse({ all: true }).success).toBe(true); + expect(ForgetUserFactsInput.safeParse({}).success).toBe(false); + expect(ForgetUserFactsInput.safeParse({ factIds: [] }).success).toBe(false); + expect( + ForgetUserFactsInput.safeParse({ factIds: ["fact-1"], category: "health" }).success, + ).toBe(false); + expect(ForgetUserFactsInput.safeParse({ all: false }).success).toBe(false); + }); +}); diff --git a/packages/contracts/src/user-profile.ts b/packages/contracts/src/user-profile.ts new file mode 100644 index 0000000..ed65de9 --- /dev/null +++ b/packages/contracts/src/user-profile.ts @@ -0,0 +1,106 @@ +import * as z from "zod"; +import { UserFactCategorySchema, UserFactSchema, UserProfileSchema } from "./domain.js"; +import { Id } from "./ids.js"; + +export const USER_FACT_VALUE_MAX_LENGTH = 500; +export const USER_COMMUNICATION_STYLE_MAX_LENGTH = 500; +export const USER_PROFILE_SUMMARY_MAX_LENGTH = 4_000; + +/** True when `value` is an IANA time zone this runtime can format, e.g. "Europe/Vienna". */ +export function isTimeZone(value: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone: value }); + return true; + } catch { + return false; + } +} + +/** True when `value` is a well-formed BCP-47 language tag, e.g. "de" or "pt-BR". */ +export function isLanguageTag(value: string): boolean { + try { + return Intl.getCanonicalLocales(value).length === 1; + } catch { + return false; + } +} + +/** An IANA time zone, stored in its canonical spelling. */ +export const TimeZoneSchema = z + .string() + .trim() + .min(1) + .max(64) + .refine(isTimeZone, "Unknown time zone") + .transform( + (value) => new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions().timeZone, + ); + +/** A BCP-47 language tag, stored in its canonical spelling. */ +export const LanguageTagSchema = z + .string() + .trim() + .min(2) + .max(35) + .refine(isLanguageTag, "Invalid language tag") + .transform((value) => Intl.getCanonicalLocales(value)[0] ?? value); + +export const UserFactValueSchema = z.string().trim().min(1).max(USER_FACT_VALUE_MAX_LENGTH); + +/** null shows a fact in every space; a list limits it to those spaces. */ +const UserFactSpaceIds = z.array(Id).min(1).max(32).nullable(); + +export const UserProfileStateSchema = z.object({ + profile: UserProfileSchema, + facts: z.array(UserFactSchema), +}); +export type UserProfileState = z.infer; + +export const NewUserFactInput = z.object({ + category: UserFactCategorySchema, + value: UserFactValueSchema, + /** Answers given during onboarding keep that origin; anything else the user adds is "user". */ + source: z.enum(["user", "onboarding"]).default("user"), + spaceIds: UserFactSpaceIds.optional(), +}); + +export const EditUserFactInput = z.object({ + id: Id, + category: UserFactCategorySchema.optional(), + value: UserFactValueSchema.optional(), + spaceIds: UserFactSpaceIds.optional(), +}); + +export const UpdateUserProfileInput = z.object({ + /** The account name every bot calls the user by. */ + name: z.string().trim().min(1).max(100).optional(), + timeZone: TimeZoneSchema.nullable().optional(), + locale: LanguageTagSchema.nullable().optional(), + language: LanguageTagSchema.nullable().optional(), + communicationStyle: z.string().trim().max(USER_COMMUNICATION_STYLE_MAX_LENGTH).optional(), + summary: z.string().trim().max(USER_PROFILE_SUMMARY_MAX_LENGTH).optional(), + addFacts: z.array(NewUserFactInput).max(50).optional(), + editFacts: z.array(EditUserFactInput).max(50).optional(), + /** Pending facts the user agrees bots may use. */ + confirmFactIds: z.array(Id).max(500).optional(), +}); +export type UpdateUserProfile = z.infer; + +/** Hard-deletes facts by id, by category, or all of them. */ +export const ForgetUserFactsInput = z + .object({ + factIds: z.array(Id).min(1).max(500).optional(), + category: UserFactCategorySchema.optional(), + all: z.literal(true).optional(), + }) + .superRefine((input, ctx) => { + const targets = [input.factIds !== undefined, input.category !== undefined, input.all === true]; + if (targets.filter(Boolean).length !== 1) { + ctx.addIssue({ + code: "custom", + message: "Provide exactly one of factIds, category, or all", + path: ["factIds"], + }); + } + }); +export type ForgetUserFacts = z.infer; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7a2cf76..eda72de 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -17,4 +17,5 @@ export * from "./repos.js"; export * from "./scope.js"; export * from "./spaces.js"; export * from "./transaction-retry.js"; +export * from "./user-profile.js"; export * from "./voice-credentials.js"; diff --git a/packages/db/src/user-profile.postgres.test.ts b/packages/db/src/user-profile.postgres.test.ts new file mode 100644 index 0000000..03e10b0 --- /dev/null +++ b/packages/db/src/user-profile.postgres.test.ts @@ -0,0 +1,304 @@ +import { randomUUID } from "node:crypto"; +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 { loadUserContext } from "./main-agent.js"; +import { createSpaceForMember } from "./spaces.js"; +import { + forgetUserFacts, + getUserProfileState, + InvalidUserFactError, + USER_FACT_LIMIT, + UserFactNotFoundError, + updateUserProfile, +} from "./user-profile.js"; + +const databaseUrl = process.env.DATABASE_URL; +const describePostgres = + process.env.VERIFY_DATABASE && databaseUrl ? describe.sequential : describe.skip; + +const signupEnv = { signupsEnabled: "true", signupAllowlist: "" }; + +describePostgres("user profile (PostgreSQL)", () => { + let prisma: PrismaClient; + let close: () => Promise; + const userIds: string[] = []; + + async function newUser(name = "Ada") { + // bootstrapUserSpace derives the organization slug from the first 12 characters. + const id = `up${randomUUID().replaceAll("-", "")}`; + userIds.push(id); + await prisma.user.create({ data: { id, name, email: `${id}@rakazo.test` } }); + const { spaceId } = await bootstrapUserSpace(prisma, { id }, signupEnv, { + claimDeploymentOwner: false, + }); + return { userId: id, homeSpaceId: 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("starts empty and upserts profile fields and the account name", async () => { + const { userId } = await newUser(); + expect(await getUserProfileState(prisma, userId)).toEqual({ + profile: { + timeZone: null, + locale: null, + language: null, + communicationStyle: "", + summary: "", + consolidatedAt: null, + updatedAt: null, + }, + facts: [], + }); + + await updateUserProfile(prisma, userId, { + name: "Ada L.", + timeZone: "Europe/Vienna", + language: "de", + communicationStyle: "Direct and concise", + }); + await updateUserProfile(prisma, userId, { locale: "de-AT", language: null }); + + const { profile } = await getUserProfileState(prisma, userId); + expect(profile).toMatchObject({ + timeZone: "Europe/Vienna", + locale: "de-AT", + language: null, + communicationStyle: "Direct and concise", + }); + expect(profile.updatedAt).not.toBeNull(); + expect((await prisma.user.findUniqueOrThrow({ where: { id: userId } })).name).toBe("Ada L."); + }); + + it("holds sensitive facts for confirmation unless the user wrote them, and stores a statement once", async () => { + const { userId } = await newUser(); + const added = await updateUserProfile(prisma, userId, { + addFacts: [ + { category: "work", value: "Works night shifts as a nurse", source: "conversation" }, + { category: "health", value: "Allergic to peanuts", source: "conversation" }, + { category: "values", value: "Vegetarian for ethical reasons", source: "user" }, + { category: "health", value: "Has asthma", source: "onboarding" }, + { category: "work", value: "works night shifts as a nurse.", source: "consolidation" }, + ], + }); + expect(added.map(({ status, created }) => ({ status, created }))).toEqual([ + { status: "active", created: true }, + { status: "pending", created: true }, + { status: "active", created: true }, + { status: "pending", created: true }, + { status: "active", created: false }, + ]); + expect(added[4]?.id).toBe(added[0]?.id); + + const rows = await prisma.userFact.findMany({ where: { userId }, orderBy: { value: "asc" } }); + expect(rows).toHaveLength(4); + const byValue = new Map(rows.map((row) => [row.value, row])); + expect(byValue.get("Allergic to peanuts")).toMatchObject({ + sensitive: true, + status: "pending", + confirmedAt: null, + }); + expect(byValue.get("Vegetarian for ethical reasons")).toMatchObject({ + sensitive: true, + status: "active", + }); + expect(byValue.get("Vegetarian for ethical reasons")?.confirmedAt).toBeInstanceOf(Date); + + // The user saying a pending fact themselves is consent. + const [restated] = await updateUserProfile(prisma, userId, { + addFacts: [{ category: "health", value: "allergic to peanuts", source: "user" }], + }); + expect(restated).toEqual({ id: added[1]?.id, status: "active", created: false }); + + const context = await loadUserContext(prisma, { userId, spaceId: "any-space" }); + expect(context.facts.map((fact) => fact.value).sort()).toEqual([ + "Allergic to peanuts", + "Vegetarian for ethical reasons", + "Works night shifts as a nurse", + ]); + }); + + it("confirms pending facts and edits them, scoped to their owner", async () => { + const owner = await newUser(); + const other = await newUser("Grace"); + const [pending] = await updateUserProfile(prisma, owner.userId, { + addFacts: [{ category: "health", value: "Migraines", source: "conversation" }], + }); + const factId = pending!.id; + + await expect( + updateUserProfile(prisma, other.userId, { confirmFactIds: [factId] }), + ).rejects.toBeInstanceOf(UserFactNotFoundError); + await expect( + updateUserProfile(prisma, other.userId, { editFacts: [{ id: factId, value: "Stolen" }] }), + ).rejects.toBeInstanceOf(UserFactNotFoundError); + expect((await getUserProfileState(prisma, other.userId)).facts).toEqual([]); + expect(await forgetUserFacts(prisma, other.userId, { factIds: [factId] })).toBe(0); + + await updateUserProfile(prisma, owner.userId, { confirmFactIds: [factId] }); + let row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } }); + expect(row.status).toBe("active"); + expect(row.confirmedAt).toBeInstanceOf(Date); + + await prisma.userFact.update({ + where: { id: factId }, + data: { embedding: [0.1, 0.2], embeddingModel: "test-embedding" }, + }); + await updateUserProfile(prisma, owner.userId, { + editFacts: [{ id: factId, category: "habits", value: "Avoids screens late at night" }], + }); + row = await prisma.userFact.findUniqueOrThrow({ where: { id: factId } }); + expect(row).toMatchObject({ + category: "habits", + sensitive: false, + value: "Avoids screens late at night", + embedding: [], + embeddingModel: null, + }); + }); + + it("limits a fact to the user's own spaces", async () => { + const owner = await newUser(); + const other = await newUser("Grace"); + const work = await createSpaceForMember(prisma, { + currentSpaceId: owner.homeSpaceId, + userId: owner.userId, + name: "Work", + }); + + await expect( + updateUserProfile(prisma, owner.userId, { + addFacts: [ + { + category: "people", + value: "Reports to Lin", + source: "user", + spaceIds: [other.homeSpaceId], + }, + ], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + expect(await prisma.userFact.count({ where: { userId: owner.userId } })).toBe(0); + + const [limited] = await updateUserProfile(prisma, owner.userId, { + addFacts: [ + { category: "people", value: "Reports to Lin", source: "user", spaceIds: [work.id] }, + ], + }); + const state = await getUserProfileState(prisma, owner.userId); + expect(state.facts[0]).toMatchObject({ allSpaces: false, spaceIds: [work.id] }); + const inWork = await loadUserContext(prisma, { userId: owner.userId, spaceId: work.id }); + const inHome = await loadUserContext(prisma, { + userId: owner.userId, + spaceId: owner.homeSpaceId, + }); + expect(inWork.facts.map((fact) => fact.value)).toEqual(["Reports to Lin"]); + expect(inHome.facts).toEqual([]); + + await updateUserProfile(prisma, owner.userId, { + editFacts: [{ id: limited!.id, spaceIds: null }], + }); + expect((await getUserProfileState(prisma, owner.userId)).facts[0]).toMatchObject({ + allSpaces: true, + spaceIds: [], + }); + expect(await prisma.userFactSpace.count({ where: { factId: limited!.id } })).toBe(0); + }); + + it("forgets by id, by category, and everything, so nothing can come back", async () => { + const { userId } = await newUser(); + const added = await updateUserProfile(prisma, userId, { + timeZone: "Europe/Vienna", + communicationStyle: "Casual", + summary: "Nurse with peanut allergy who likes chess.", + addFacts: [ + { category: "work", value: "Nurse", source: "user" }, + { category: "health", value: "Peanut allergy", source: "user" }, + { category: "preferences", value: "Likes chess", source: "onboarding" }, + { category: "preferences", value: "Likes jazz", source: "onboarding" }, + ], + }); + + expect(await forgetUserFacts(prisma, userId, { factIds: [added[1]!.id] })).toBe(1); + let state = await getUserProfileState(prisma, userId); + expect(state.facts.map((fact) => fact.value).sort()).toEqual([ + "Likes chess", + "Likes jazz", + "Nurse", + ]); + // The summary may restate what was forgotten; unconsolidated chats must not teach it again. + expect(state.profile.summary).toBe(""); + expect(state.profile.consolidatedAt).not.toBeNull(); + expect(state.profile.timeZone).toBe("Europe/Vienna"); + + expect(await forgetUserFacts(prisma, userId, { category: "preferences" })).toBe(2); + state = await getUserProfileState(prisma, userId); + expect(state.facts.map((fact) => fact.value)).toEqual(["Nurse"]); + + expect(await forgetUserFacts(prisma, userId, { all: true })).toBe(1); + state = await getUserProfileState(prisma, userId); + expect(state.facts).toEqual([]); + expect(state.profile).toMatchObject({ + timeZone: null, + locale: null, + language: null, + communicationStyle: "", + summary: "", + }); + }); + + it("caps the number of facts and serializes concurrent writers", async () => { + const { userId } = await newUser(); + const same = { + category: "goals" as const, + value: "Run a marathon", + source: "conversation" as const, + }; + const results = await Promise.all([ + updateUserProfile(prisma, userId, { addFacts: [same] }), + updateUserProfile(prisma, userId, { addFacts: [same] }), + updateUserProfile(prisma, userId, { addFacts: [same] }), + ]); + expect(new Set(results.map(([fact]) => fact?.id)).size).toBe(1); + expect(await prisma.userFact.count({ where: { userId } })).toBe(1); + + await prisma.userFact.createMany({ + data: Array.from({ length: USER_FACT_LIMIT - 1 }, (_, index) => ({ + userId, + category: "tools", + value: `Uses tool ${index}`, + source: "user", + })), + }); + await expect( + updateUserProfile(prisma, userId, { + addFacts: [{ category: "tools", value: "Uses one more tool", source: "user" }], + }), + ).rejects.toBeInstanceOf(InvalidUserFactError); + // A statement the profile already holds still resolves at the cap. + const [known] = await updateUserProfile(prisma, userId, { addFacts: [same] }); + expect(known?.created).toBe(false); + }); +}); diff --git a/packages/db/src/user-profile.test.ts b/packages/db/src/user-profile.test.ts new file mode 100644 index 0000000..7e84c63 --- /dev/null +++ b/packages/db/src/user-profile.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { isSensitiveUserFactCategory, normalizeUserFactValue } from "./user-profile.js"; + +describe("user facts", () => { + it("treats the same statement with different case, spacing or end punctuation as one", () => { + expect(normalizeUserFactValue(" Works as a Nurse. ")).toBe("works as a nurse"); + expect(normalizeUserFactValue('"Works as a nurse!"')).toBe("works as a nurse"); + expect(normalizeUserFactValue("Works as a nurse")).toBe("works as a nurse"); + }); + + it("keeps symbols that change the meaning", () => { + expect(normalizeUserFactValue("Writes C++")).not.toBe(normalizeUserFactValue("Writes C")); + }); + + it("marks only health and values as sensitive", () => { + expect(isSensitiveUserFactCategory("health")).toBe(true); + expect(isSensitiveUserFactCategory("values")).toBe(true); + expect(isSensitiveUserFactCategory("work")).toBe(false); + expect(isSensitiveUserFactCategory("people")).toBe(false); + }); +}); diff --git a/packages/db/src/user-profile.ts b/packages/db/src/user-profile.ts new file mode 100644 index 0000000..7946257 --- /dev/null +++ b/packages/db/src/user-profile.ts @@ -0,0 +1,383 @@ +import type { + UserFact, + UserFactCategory, + UserFactSource, + UserFactStatus, + UserProfile, + UserProfileState, +} from "@rakazo/contracts"; +import { SENSITIVE_USER_FACT_CATEGORIES } from "@rakazo/contracts"; +import type { PrismaClient } from "./client.js"; +import { Prisma } from "./client.js"; + +/** Upper bound on facts one user keeps, so a runaway writer cannot grow the profile without end. */ +export const USER_FACT_LIMIT = 500; + +export class UserFactNotFoundError extends Error { + constructor() { + super("Fact not found"); + this.name = "UserFactNotFoundError"; + } +} + +export class InvalidUserFactError extends Error { + constructor(message: string) { + super(message); + this.name = "InvalidUserFactError"; + } +} + +export interface NewUserFact { + category: UserFactCategory; + value: string; + source: UserFactSource; + confidence?: number; + /** Omitted or null: visible in every space. */ + spaceIds?: string[] | null; + sourceBotId?: string | null; + sourceThreadId?: string | null; + sourceMessageId?: string | null; +} + +export interface UserFactEdit { + id: string; + category?: UserFactCategory; + value?: string; + /** null: visible in every space. */ + spaceIds?: string[] | null; +} + +export interface UserProfileUpdate { + name?: string; + timeZone?: string | null; + locale?: string | null; + language?: string | null; + communicationStyle?: string; + summary?: string; + addFacts?: NewUserFact[]; + editFacts?: UserFactEdit[]; + confirmFactIds?: string[]; +} + +/** Outcome for one added fact, in input order. */ +export interface AddedUserFact { + id: string; + status: UserFactStatus; + /** False when the statement was already known and the existing fact was kept. */ + created: boolean; +} + +export type UserFactForgetTarget = + | { factIds: string[] } + | { category: UserFactCategory } + | { all: true }; + +const SENSITIVE_CATEGORIES = new Set(SENSITIVE_USER_FACT_CATEGORIES); + +export function isSensitiveUserFactCategory(category: UserFactCategory): boolean { + return SENSITIVE_CATEGORIES.has(category); +} + +/** Comparison key for the same statement written twice: case, spacing, quotes and end punctuation differ. */ +export function normalizeUserFactValue(value: string): string { + return value + .normalize("NFKC") + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/^["'“”„‘’«»\s]+|["'“”„‘’«».!?;:,\s]+$/gu, ""); +} + +const factSelect = { + id: true, + category: true, + value: true, + confidence: true, + source: true, + status: true, + sensitive: true, + allSpaces: true, + sourceBotId: true, + sourceThreadId: true, + createdAt: true, + updatedAt: true, + visibleSpaces: { select: { spaceId: true }, orderBy: { spaceId: "asc" } }, +} as const; + +type FactRow = Prisma.UserFactGetPayload<{ select: typeof factSelect }>; + +type ProfileRow = { + timeZone: string | null; + locale: string | null; + language: string | null; + communicationStyle: string; + summary: string; + consolidatedAt: Date | null; + updatedAt: Date; +}; + +function factDto(row: FactRow): UserFact { + return { + id: row.id, + category: row.category as UserFactCategory, + value: row.value, + confidence: row.confidence, + source: row.source as UserFactSource, + status: row.status as UserFactStatus, + sensitive: row.sensitive, + allSpaces: row.allSpaces, + spaceIds: row.allSpaces ? [] : row.visibleSpaces.map((space) => space.spaceId), + sourceBotId: row.sourceBotId, + sourceThreadId: row.sourceThreadId, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + }; +} + +function profileDto(row: ProfileRow | null): UserProfile { + return { + timeZone: row?.timeZone ?? null, + locale: row?.locale ?? null, + language: row?.language ?? null, + communicationStyle: row?.communicationStyle ?? "", + summary: row?.summary ?? "", + consolidatedAt: row?.consolidatedAt?.toISOString() ?? null, + updatedAt: row?.updatedAt.toISOString() ?? null, + }; +} + +/** The user's profile and every fact, pending ones included, newest first. */ +export async function getUserProfileState( + prisma: PrismaClient, + userId: string, +): Promise { + const [profile, facts] = await Promise.all([ + prisma.userProfile.findUnique({ where: { userId } }), + prisma.userFact.findMany({ + where: { userId }, + orderBy: [{ createdAt: "desc" }, { id: "asc" }], + select: factSelect, + }), + ]); + return { profile: profileDto(profile), facts: facts.map(factDto) }; +} + +/** Serializes one user's profile writes so duplicate checks and the fact limit hold under concurrency. */ +async function lockUserProfile(tx: Prisma.TransactionClient, userId: string): Promise { + const key = `user-profile:${userId}`; + await tx.$queryRaw(Prisma.sql` + SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))::text AS "lock" + `); +} + +async function assertUserSpaces( + tx: Prisma.TransactionClient, + userId: string, + spaceIds: string[], +): Promise { + const unique = [...new Set(spaceIds)]; + if (unique.length === 0) return; + const memberships = await tx.spaceMember.count({ + where: { userId, spaceId: { in: unique } }, + }); + if (memberships !== unique.length) throw new InvalidUserFactError("Space not found"); +} + +function visibleSpacesOf(spaceIds: string[] | null | undefined): string[] | null { + return spaceIds && spaceIds.length > 0 ? [...new Set(spaceIds)] : null; +} + +async function addFacts( + tx: Prisma.TransactionClient, + userId: string, + facts: NewUserFact[], +): Promise { + if (facts.length === 0) return []; + const existing = await tx.userFact.findMany({ + where: { userId }, + select: { id: true, value: true, status: true }, + }); + const known = new Map(existing.map((fact) => [normalizeUserFactValue(fact.value), fact])); + let total = existing.length; + const results: AddedUserFact[] = []; + for (const fact of facts) { + const value = fact.value.trim(); + const key = normalizeUserFactValue(value); + const match = known.get(key); + if (match) { + // The user stating a pending fact themselves is consent. + if (match.status === "pending" && fact.source === "user") { + await tx.userFact.update({ + where: { id: match.id }, + data: { status: "active", confirmedAt: new Date() }, + }); + match.status = "active"; + } + results.push({ id: match.id, status: match.status as UserFactStatus, created: false }); + continue; + } + if (total >= USER_FACT_LIMIT) { + throw new InvalidUserFactError(`A profile keeps at most ${USER_FACT_LIMIT} facts`); + } + const sensitive = isSensitiveUserFactCategory(fact.category); + const userAuthored = fact.source === "user"; + const status: UserFactStatus = sensitive && !userAuthored ? "pending" : "active"; + const spaces = visibleSpacesOf(fact.spaceIds); + const created = await tx.userFact.create({ + data: { + userId, + category: fact.category, + value, + confidence: fact.confidence ?? 1, + source: fact.source, + status, + sensitive, + allSpaces: spaces === null, + sourceBotId: fact.sourceBotId ?? null, + sourceThreadId: fact.sourceThreadId ?? null, + sourceMessageId: fact.sourceMessageId ?? null, + confirmedAt: userAuthored ? new Date() : null, + ...(spaces ? { visibleSpaces: { create: spaces.map((spaceId) => ({ spaceId })) } } : {}), + }, + select: { id: true, value: true, status: true }, + }); + known.set(key, created); + total += 1; + results.push({ id: created.id, status, created: true }); + } + return results; +} + +async function editFact( + tx: Prisma.TransactionClient, + userId: string, + edit: UserFactEdit, +): Promise { + const fact = await tx.userFact.findFirst({ + where: { id: edit.id, userId }, + select: { id: true }, + }); + if (!fact) throw new UserFactNotFoundError(); + const data: Prisma.UserFactUpdateInput = {}; + if (edit.category !== undefined) { + data.category = edit.category; + data.sensitive = isSensitiveUserFactCategory(edit.category); + } + 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( + tx: Prisma.TransactionClient, + userId: string, + factIds: string[], +): Promise { + const unique = [...new Set(factIds)]; + if (unique.length === 0) return; + const owned = await tx.userFact.count({ where: { userId, id: { in: unique } } }); + if (owned !== unique.length) throw new UserFactNotFoundError(); + await tx.userFact.updateMany({ + where: { userId, id: { in: unique }, status: "pending" }, + data: { status: "active", confirmedAt: new Date() }, + }); +} + +function profileFields(update: UserProfileUpdate) { + const fields = { + ...(update.timeZone !== undefined ? { timeZone: update.timeZone } : {}), + ...(update.locale !== undefined ? { locale: update.locale } : {}), + ...(update.language !== undefined ? { language: update.language } : {}), + ...(update.communicationStyle !== undefined + ? { communicationStyle: update.communicationStyle } + : {}), + ...(update.summary !== undefined ? { summary: update.summary } : {}), + }; + return Object.keys(fields).length > 0 ? fields : null; +} + +/** + * Applies one profile change atomically: account name, profile fields, new facts, fact edits + * and confirmations. Facts in sensitive categories wait for the user's confirmation unless the + * user wrote them, and a statement the profile already holds is not stored twice. + */ +export async function updateUserProfile( + prisma: PrismaClient, + userId: string, + update: UserProfileUpdate, +): Promise { + return prisma.$transaction(async (tx) => { + await lockUserProfile(tx, userId); + await assertUserSpaces( + tx, + userId, + [...(update.addFacts ?? []), ...(update.editFacts ?? [])].flatMap( + (fact) => fact.spaceIds ?? [], + ), + ); + if (update.name !== undefined) { + await tx.user.update({ where: { id: userId }, data: { name: update.name } }); + } + const fields = profileFields(update); + if (fields) { + await tx.userProfile.upsert({ + where: { userId }, + create: { userId, ...fields }, + update: fields, + }); + } + const added = await addFacts(tx, userId, update.addFacts ?? []); + for (const edit of update.editFacts ?? []) await editFact(tx, userId, edit); + await confirmFacts(tx, userId, update.confirmFactIds ?? []); + return added; + }); +} + +/** + * Hard-deletes facts. Because the summary may restate them and chats not yet consolidated could + * teach them again, forgetting also clears the summary and marks earlier chats as consolidated. + * Forgetting everything clears the remaining profile fields too. + */ +export async function forgetUserFacts( + prisma: PrismaClient, + userId: string, + target: UserFactForgetTarget, +): Promise { + return prisma.$transaction(async (tx) => { + await lockUserProfile(tx, userId); + const where: Prisma.UserFactWhereInput = + "factIds" in target + ? { userId, id: { in: target.factIds } } + : "category" in target + ? { userId, category: target.category } + : { userId }; + const { count } = await tx.userFact.deleteMany({ where }); + const everything = "all" in target; + if (count > 0 || everything) { + const reset = { + summary: "", + consolidatedAt: new Date(), + ...(everything + ? { timeZone: null, locale: null, language: null, communicationStyle: "" } + : {}), + }; + await tx.userProfile.upsert({ + where: { userId }, + create: { userId, ...reset }, + update: reset, + }); + } + return count; + }); +} diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 6fe0dd9..85dbd78 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -60,6 +60,9 @@ describeWithDatabase("API authorization and resource isolation", () => { it("rejects unauthenticated calls to every protected RPC family", async () => { const calls = exhaustiveProtectedCalls([ ["me"], + ["profile/get"], + ["profile/update", { timeZone: "UTC" }], + ["profile/forget", { all: true }], ["deployment/get"], ["deployment/update", { signupsEnabled: true }], ["updater/status"], @@ -468,6 +471,50 @@ describeWithDatabase("API authorization and resource isolation", () => { await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }), ).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 profileCalls = [ + ["profile/update", { confirmFactIds: ownerFactIds }], + ["profile/update", { editFacts: [{ id: ownerFactIds[0], value: "Stolen" }] }], + [ + "profile/update", + { + addFacts: [{ category: "work", value: "Planted fact", spaceIds: [ownerActor.spaceId] }], + }, + ], + ] satisfies Array<[string, unknown]>; + await Promise.all( + profileCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)), + ); + expect( + await rpc<{ facts: unknown[] }>(app, intruder, "profile/forget", { factIds: ownerFactIds }), + ).toMatchObject({ facts: [] }); + expect(await rpc<{ facts: unknown[] }>(app, intruder, "profile/get")).toMatchObject({ + facts: [], + }); + expect( + await handles.prisma.userFact.findMany({ + where: { id: { in: ownerFactIds } }, + select: { userId: true, value: true, status: true }, + orderBy: { value: "asc" }, + }), + ).toEqual([ + { userId: ownerActor.userId, value: "Owner health note", status: "pending" }, + { userId: ownerActor.userId, value: "Owner works nights", status: "active" }, + ]); + // Destructive bot removal is checked last so an authorization regression is unmistakable. await expectDenied(app, intruder, "bots/remove", { botId: ownerBot.id, diff --git a/packages/testkit/src/cli/harness.ts b/packages/testkit/src/cli/harness.ts index 272712a..6071f0d 100644 --- a/packages/testkit/src/cli/harness.ts +++ b/packages/testkit/src/cli/harness.ts @@ -103,8 +103,10 @@ async function main() { "packages/testkit/src/executor-lifecycle.test.ts", "packages/testkit/src/connections.test.ts", "packages/testkit/src/bot-secrets.test.ts", + "packages/testkit/src/user-profile.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/messaging.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts", diff --git a/packages/testkit/src/user-profile.postgres.test.ts b/packages/testkit/src/user-profile.postgres.test.ts new file mode 100644 index 0000000..c5c422f --- /dev/null +++ b/packages/testkit/src/user-profile.postgres.test.ts @@ -0,0 +1,196 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { ComposioEmulator } from "@rakazo/adapters"; +import type { UserProfileState } from "@rakazo/contracts"; +import { ensureMainBot } 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("user profile 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-profile-")); + + 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 sendAndWait(cookie: string, botId: string, text: string) { + const { runId } = await rpc<{ runId: string }>(app, cookie, "threads/send", { botId, text }); + 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, sourceMessageId: 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`); + } + + it("lets the main bot record what it learns, and only the main bot", async () => { + const cookie = await signup(app, `profile-tool-${stamp}@rakazo.test`, "Profile Tool"); + 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, "learn about me: work: Works night shifts as a nurse"); + const healthRun = await sendAndWait( + cookie, + main.id, + "learn about me: health: Allergic to peanuts", + ); + const specialistRun = await sendAndWait( + cookie, + specialist.id, + "learn about me: tools: Uses a paper planner", + ); + const refusal = await handles.prisma.event.findFirstOrThrow({ + where: { runId: specialistRun.id, type: "agent.tool.completed" }, + select: { payload: true }, + }); + expect(refusal.payload).toMatchObject({ + name: "update_user_profile", + outcome: "error", + error: expect.stringContaining("main bot"), + }); + + let state = await rpc(app, cookie, "profile/get"); + expect( + state.facts.map(({ category, value, source, status, sourceBotId }) => ({ + category, + value, + source, + status, + sourceBotId, + })), + ).toEqual([ + { + category: "health", + value: "Allergic to peanuts", + source: "conversation", + status: "pending", + sourceBotId: main.id, + }, + { + category: "work", + value: "Works night shifts as a nurse", + source: "conversation", + status: "active", + sourceBotId: main.id, + }, + ]); + expect(state.facts[0]?.sourceThreadId).toBe(main.threadId); + expect(healthRun.sourceMessageId).toBeTruthy(); + expect( + await handles.prisma.userFact.findUniqueOrThrow({ + where: { id: state.facts[0]!.id }, + select: { sourceMessageId: true }, + }), + ).toEqual({ sourceMessageId: healthRun.sourceMessageId }); + + state = await rpc(app, cookie, "profile/update", { + confirmFactIds: [state.facts[0]!.id], + }); + expect(state.facts.map((fact) => fact.status)).toEqual(["active", "active"]); + + state = await rpc(app, cookie, "profile/forget", { category: "health" }); + expect(state.facts.map((fact) => fact.value)).toEqual(["Works night shifts as a nurse"]); + }); + + it("validates profile input and reports unknown facts", async () => { + const cookie = await signup(app, `profile-input-${stamp}@rakazo.test`, "Profile Input"); + const state = await rpc(app, cookie, "profile/update", { + name: "Renamed", + timeZone: "europe/vienna", + language: "de", + addFacts: [{ category: "goals", value: "Run a marathon" }], + }); + expect(state.profile).toMatchObject({ timeZone: "Europe/Vienna", language: "de" }); + expect(state.facts).toMatchObject([ + { category: "goals", value: "Run a marathon", source: "user", status: "active" }, + ]); + expect((await rpc<{ name: string }>(app, cookie, "me")).name).toBe("Renamed"); + + expect((await raw(app, cookie, "profile/update", { timeZone: "Vienna" })).status).toBe(400); + expect( + (await raw(app, cookie, "profile/update", { confirmFactIds: ["missing-fact"] })).status, + ).toBe(404); + + const forgotten = await rpc(app, cookie, "profile/forget", { all: true }); + expect(forgotten.facts).toEqual([]); + expect(forgotten.profile).toMatchObject({ timeZone: null, language: null, summary: "" }); + }); +}); + +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; +}