From 492de63c77120f0000a2f31c24b544d04880778e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:00:04 +0000 Subject: [PATCH] feat(profile): readable, writable and forgettable user model The main agent is meant to know the user, but nothing could read or change the user model the foundation added. Onboarding pasted the user's answers into the first bot's instructions as a "User Context" block, and the user had no way to see or remove what their bots knew about them. This makes the user model a user-controlled resource: - db: a profile repository scoped by user. One atomic update writes the account name, profile fields, new facts (deduplicated by normalized text, capped per user), fact edits including per-space visibility, and confirmations. Health and values facts wait for confirmation unless the user wrote them. Forgetting hard-deletes facts by id, category or all, clears the summary that may restate them and marks earlier chats as consolidated so they cannot teach them again; forgetting everything also clears the profile fields. Writes take a per-user advisory lock. - api/contracts: profile.get, profile.update and profile.forget. Unknown fact ids are a 404 on update and ignored on forget; spaces outside the user's memberships are rejected. - adapters: an update_user_profile tool for the main bot only, hidden from specialists and shared messaging channels and refused if called anyway. Facts carry source "conversation" and the bot, thread and message they came from. The scripted runtime records "learn about me: [category:] statement" for tests. - web: onboarding writes the name, time zone (browser default, now prefilled and editable), locale, chosen language, communication style and the hobby and work-style answers to the profile instead of overwriting the bot's instructions, and the harvested memory section no longer repeats them. A new "About you" settings section lists profile fields and facts by category with their origin, confirms pending facts, and forgets one fact or everything. Tests cover the contracts, the repository (unit and PostgreSQL), tool selection and refusal, the scripted trigger, the onboarding mapping and view helpers, an API suite that runs the tool through the executor with a real main bot, authorization for the new procedures, and an E2E spec. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/api/src/router.ts | 10 + apps/api/src/user-profile.ts | 60 +++ apps/web/e2e/helpers.ts | 1 + apps/web/e2e/user-profile.spec.ts | 106 +++++ .../src/components/UserProfileSettings.tsx | 245 +++++++++++ apps/web/src/lib/onboarding-profile.test.ts | 56 +++ apps/web/src/lib/onboarding-profile.ts | 73 ++++ apps/web/src/lib/user-profile-harvest.test.ts | 48 +-- apps/web/src/lib/user-profile-harvest.ts | 57 +-- apps/web/src/lib/user-profile-view.test.ts | 76 ++++ apps/web/src/lib/user-profile-view.ts | 77 ++++ .../lib/user-relationship-contract.test.ts | 15 +- apps/web/src/pages/Onboarding.tsx | 26 +- apps/web/src/pages/SettingsOverlay.tsx | 5 + packages/adapters/src/builtin-tools.ts | 35 ++ packages/adapters/src/executor-tools.ts | 15 +- packages/adapters/src/executor.ts | 16 + .../adapters/src/scripted-runtime.test.ts | 22 + packages/adapters/src/scripted-runtime.ts | 29 ++ .../adapters/src/user-profile-tools.test.ts | 110 +++++ packages/adapters/src/user-profile-tools.ts | 82 ++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 11 + packages/contracts/src/user-profile.test.ts | 62 +++ packages/contracts/src/user-profile.ts | 106 +++++ packages/db/src/index.ts | 1 + packages/db/src/user-profile.postgres.test.ts | 304 ++++++++++++++ packages/db/src/user-profile.test.ts | 21 + packages/db/src/user-profile.ts | 383 ++++++++++++++++++ packages/testkit/src/authorization.test.ts | 47 +++ packages/testkit/src/cli/harness.ts | 2 + .../testkit/src/user-profile.postgres.test.ts | 196 +++++++++ 32 files changed, 2183 insertions(+), 115 deletions(-) create mode 100644 apps/api/src/user-profile.ts create mode 100644 apps/web/e2e/user-profile.spec.ts create mode 100644 apps/web/src/components/UserProfileSettings.tsx create mode 100644 apps/web/src/lib/onboarding-profile.test.ts create mode 100644 apps/web/src/lib/onboarding-profile.ts create mode 100644 apps/web/src/lib/user-profile-view.test.ts create mode 100644 apps/web/src/lib/user-profile-view.ts create mode 100644 packages/adapters/src/user-profile-tools.test.ts create mode 100644 packages/adapters/src/user-profile-tools.ts create mode 100644 packages/contracts/src/user-profile.test.ts create mode 100644 packages/contracts/src/user-profile.ts create mode 100644 packages/db/src/user-profile.postgres.test.ts create mode 100644 packages/db/src/user-profile.test.ts create mode 100644 packages/db/src/user-profile.ts create mode 100644 packages/testkit/src/user-profile.postgres.test.ts 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; +}