Merge user profile: readable, writable and forgettable user model

Both branches gave the main bot its own tools, so the main-only filter now
covers delegate_task and update_user_profile, and the main bot's role test
expects the profile tool instead of remember.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-02 23:05:28 +00:00
commit 728d78001a
No known key found for this signature in database
33 changed files with 2183 additions and 116 deletions

View File

@ -201,6 +201,7 @@ import {
threadHead, threadHead,
threadSnapshot, threadSnapshot,
} from "./thread-target.js"; } from "./thread-target.js";
import { forgetProfile, getProfile, updateProfile } from "./user-profile.js";
import { import {
disconnectVoiceCredential, disconnectVoiceCredential,
listVoiceCatalog, listVoiceCatalog,
@ -540,6 +541,15 @@ export function createRouter(deps: RouterDeps) {
return meDto(deps, context.actor); 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: { spaces: {
list: authed.spaces.list.handler(async ({ context }) => list: authed.spaces.list.handler(async ({ context }) =>
spaceNavigationDto(deps, context.actor, repos, groupRepos), spaceNavigationDto(deps, context.actor, repos, groupRepos),

View File

@ -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<UserProfileState> {
return getUserProfileState(prisma, actor.userId);
}
export async function updateProfile(
prisma: PrismaClient,
actor: Actor,
input: UpdateUserProfile,
): Promise<UserProfileState> {
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<UserProfileState> {
await forgetUserFacts(
prisma,
actor.userId,
input.factIds
? { factIds: input.factIds }
: input.category
? { category: input.category }
: { all: true },
);
return getUserProfileState(prisma, actor.userId);
}

View File

@ -222,6 +222,7 @@ export async function openUserSettings(
| "integrations" | "integrations"
| "models" | "models"
| "memory" | "memory"
| "profile"
| "voice" | "voice"
| "usage" | "usage"
| "computer" | "computer"

View File

@ -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<Array<{ instructions: string }>>(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: [] });
});

View File

@ -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 (
<li data-testid={testId} data-status={status} className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p id={valueId} className="text-ui-body break-words text-foreground/75">
{value}
</p>
<p className="text-ui-body-sm text-muted-foreground/70">{detail}</p>
</div>
<div className="flex shrink-0 items-center gap-1">{actions(valueId)}</div>
</li>
);
}
/** Everything the user's bots know about the user, with consent and forget controls. */
export function UserProfileSettings() {
const { t } = useLingui();
const [state, setState] = useState<UserProfileState | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<UserProfileState>) {
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<UserFactCategory, string> = {
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<UserFactSource, string> = {
onboarding: t`Onboarding`,
user: t`You`,
conversation: t`Chat`,
consolidation: t`Chat history`,
connector: t`Connected app`,
};
const fieldLabels: Record<ProfileField, string> = {
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) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={t`Delete`}
aria-describedby={describedBy}
disabled={busy}
onClick={onClick}
>
<XIcon />
</Button>
);
return (
<div data-testid="user-profile-settings" className="space-y-5">
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
{state && fields.length === 0 && groups.length === 0 ? (
<p className="text-ui-body-sm text-muted-foreground">
<Trans>Nothing yet</Trans>
</p>
) : null}
{fields.length > 0 ? (
<section className="rounded-xl border border-border px-4 py-4">
<ul className="space-y-3">
{fields.map(({ field, value }) => (
<Row
key={field}
testId={`profile-field-${field}`}
value={
field === "language"
? displayLanguageTag(value, uiLocale)
: field === "locale"
? displayRegionTag(value, uiLocale)
: value
}
detail={fieldLabels[field]}
actions={(describedBy) =>
deleteButton(
describedBy,
() => void apply(() => rpc.profile.update(CLEARED_PROFILE_FIELD[field])),
)
}
/>
))}
</ul>
</section>
) : null}
{groups.map((group) => (
<section
key={group.category}
data-testid={`user-facts-${group.category}`}
className="rounded-xl border border-border px-4 py-4"
>
<h3 className="text-ui-body font-medium text-foreground">
{categoryLabels[group.category]}
</h3>
<ul className="mt-3 space-y-3">
{group.facts.map((fact) => (
<Row
key={fact.id}
testId="user-fact"
status={fact.status}
value={fact.value}
detail={sourceLabels[fact.source]}
actions={(describedBy) => (
<>
{fact.status === "pending" ? (
<Button
variant="outline"
size="sm"
aria-describedby={describedBy}
disabled={busy}
onClick={() =>
void apply(() => rpc.profile.update({ confirmFactIds: [fact.id] }))
}
>
<Trans>Confirm</Trans>
</Button>
) : null}
{deleteButton(
describedBy,
() => void apply(() => rpc.profile.forget({ factIds: [fact.id] })),
)}
</>
)}
/>
))}
</ul>
</section>
))}
{fields.length > 0 || groups.length > 0 ? (
<Button variant="destructive" disabled={busy} onClick={() => setForgetOpen(true)}>
<Trans>Forget everything</Trans>
</Button>
) : null}
<AlertDialog open={forgetOpen} onOpenChange={setForgetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Forget everything about you?</Trans>
</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={busy}
onClick={() => {
setForgetOpen(false);
void apply(() => rpc.profile.forget({ all: true }));
}}
>
<Trans>Forget</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -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" });
});
});

View File

@ -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<string, string> = {
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<string, string> = {
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<string, string> = {
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 })) }
: {}),
};
}

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
collectLocalProfileSignals, collectLocalProfileSignals,
mergeUserProfileIntoMemory, mergeUserMemorySections,
persistUserProfileHarvest, persistUserProfileHarvest,
} from "./user-profile-harvest"; } from "./user-profile-harvest";
@ -20,48 +20,13 @@ describe("user-profile-harvest", () => {
expect(signals.harvestedAt).toMatch(/^\d{4}-/); expect(signals.harvestedAt).toMatch(/^\d{4}-/);
}); });
it("merges profile block into MEMORY.md", () => { it("keeps existing notes and leaves profile data to the user profile", () => {
const merged = mergeUserProfileIntoMemory( const merged = mergeUserMemorySections("# Space memory\n\nExisting note.\n");
"# 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");
expect(merged).toContain("Existing note."); expect(merged).toContain("Existing note.");
expect(merged).not.toContain("## User profile (auto-harvested)");
}); });
it("updates an existing profile section in place", () => { it("persists help vibe via memory.update on user scope without profile data", async () => {
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 () => {
const updates: Array<{ documentId: string; content: string }> = []; const updates: Array<{ documentId: string; content: string }> = [];
const hints = await persistUserProfileHarvest( const hints = await persistUserProfileHarvest(
{ {
@ -81,8 +46,9 @@ describe("user-profile-harvest", () => {
}), }),
); );
expect(updates).toHaveLength(1); expect(updates).toHaveLength(1);
expect(updates[0]?.content).toContain("Ingo");
expect(updates[0]?.content).toContain("## Help & vibe (inferred)"); 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"); expect(hints.suggestedLanes).toContain("builder");
}); });
}); });

View File

@ -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 = { export type MemoryListClient = {
memory: { memory: {
list: (input: { list: (input: {
@ -94,20 +57,16 @@ export type MemoryListClient = {
}; };
}; };
/** Profile + optional help/vibe + Oo relationship product contract → user MEMORY.md. */ /**
export function mergeFullUserProfileMemory( * Optional help/vibe + Oo relationship product contract → user MEMORY.md. Name, time zone and
existing: string, * language live in the user profile instead, so they are not repeated here.
signals: UserProfileSignals, */
helpVibe?: UserHelpVibeHints, export function mergeUserMemorySections(existing: string, helpVibe?: UserHelpVibeHints): string {
): string { const content = helpVibe ? mergeHelpVibeIntoMemory(existing, helpVibe) : existing;
let content = mergeUserProfileIntoMemory(existing, signals);
if (helpVibe) {
content = mergeHelpVibeIntoMemory(content, helpVibe);
}
return mergeRelationshipContractIntoMemory(content); 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( export async function persistUserProfileHarvest(
client: MemoryListClient, client: MemoryListClient,
signals: UserProfileSignals, signals: UserProfileSignals,
@ -117,7 +76,7 @@ export async function persistUserProfileHarvest(
const docs = await client.memory.list({ scope: "user" }); const docs = await client.memory.list({ scope: "user" });
const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0]; const memoryDoc = docs.find((doc) => doc.path === "MEMORY.md") ?? docs[0];
if (!memoryDoc) return helpVibe; if (!memoryDoc) return helpVibe;
const content = mergeFullUserProfileMemory(memoryDoc.content, signals, helpVibe); const content = mergeUserMemorySections(memoryDoc.content, helpVibe);
if (content !== memoryDoc.content) { if (content !== memoryDoc.content) {
await client.memory.update({ documentId: memoryDoc.id, content }); await client.memory.update({ documentId: memoryDoc.id, content });
} }

View File

@ -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<UserFact> & Pick<UserFact, "id">): 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");
});
});

View File

@ -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<ProfileField, ClearedProfileField> = {
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;
}
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { collectLocalProfileSignals, mergeFullUserProfileMemory } from "./user-profile-harvest"; import { mergeUserMemorySections } from "./user-profile-harvest";
import { import {
mergeRelationshipContractIntoMemory, mergeRelationshipContractIntoMemory,
RELATIONSHIP_SECTION, RELATIONSHIP_SECTION,
@ -13,17 +13,8 @@ describe("user-relationship-contract", () => {
expect(mergeRelationshipContractIntoMemory(once)).toBe(once); expect(mergeRelationshipContractIntoMemory(once)).toBe(once);
}); });
it("is included in full profile harvest merge", () => { it("is included in the user memory merge", () => {
const merged = mergeFullUserProfileMemory( const merged = mergeUserMemorySections("# Space memory\n");
"# Space memory\n",
collectLocalProfileSignals({
name: "Ingo",
email: "i@example.com",
defaultProvider: null,
defaultModel: null,
computerHost: null,
}),
);
expect(merged).toContain(RELATIONSHIP_SECTION); expect(merged).toContain(RELATIONSHIP_SECTION);
expect(merged).toContain("Never creepy"); expect(merged).toContain("Never creepy");
}); });

View File

@ -14,12 +14,14 @@ import {
KOKORO_LOCAL_TOKEN, KOKORO_LOCAL_TOKEN,
OnboardingVoiceStep, OnboardingVoiceStep,
} from "../components/onboarding/OnboardingVoiceStep"; } from "../components/onboarding/OnboardingVoiceStep";
import { localTimezone } from "../lib/local-timezone";
import { import {
computeDiscoveryItems, computeDiscoveryItems,
countConfiguredIntegrations, countConfiguredIntegrations,
type OnboardingDiscoveryPhase, type OnboardingDiscoveryPhase,
} from "../lib/onboarding-discovery"; } from "../lib/onboarding-discovery";
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc } from "../lib/rpc"; import { rpc } from "../lib/rpc";
import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale"; import { persistUiLocale, UI_LOCALE_LABELS, type UiLocale } from "../lib/ui-locale";
import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe"; import { inferHelpVibeHints, type UserHelpVibeHints } from "../lib/user-help-vibe";
@ -135,7 +137,7 @@ const COMMUNICATION_OPTIONS = [
function defaultPreferences(): UserPreferences { function defaultPreferences(): UserPreferences {
return { return {
name: "", name: "",
timezone: "", timezone: localTimezone(),
hobbies: [], hobbies: [],
workStyle: "", workStyle: "",
communication: "", communication: "",
@ -170,6 +172,7 @@ export function OnboardingPage() {
return "en"; return "en";
} }
}); });
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -265,15 +268,17 @@ export function OnboardingPage() {
const bot = await ensureFirstBot(emojiConfig.avatarValue); const bot = await ensureFirstBot(emojiConfig.avatarValue);
setCrewReady(true); setCrewReady(true);
const ctxParts: string[] = []; await rpc.profile
if (prefs.name) ctxParts.push(`User: ${prefs.name}`); .update(
if (prefs.timezone) ctxParts.push(`Timezone: ${prefs.timezone}`); onboardingProfileUpdate({
if (prefs.hobbies.length > 0) ctxParts.push(`Hobbies: ${prefs.hobbies.join(", ")}`); answers: prefs,
if (prefs.workStyle) ctxParts.push(`Work style: ${prefs.workStyle}`); accountName: bootstrapMe?.name,
if (prefs.communication) ctxParts.push(`Communication: ${prefs.communication}`); browserTimeZone: localTimezone(),
const userCtx = locale: typeof navigator !== "undefined" ? navigator.language : undefined,
ctxParts.length > 0 ? `## User Context\n${ctxParts.map((p) => `- ${p}`).join("\n")}` : ""; language: languageChoice,
await rpc.bots.update({ botId: bot.id, instructions: userCtx }).catch(() => null); }),
)
.catch(() => null);
if (selectedVoiceId && !voiceSkipped) { if (selectedVoiceId && !voiceSkipped) {
await rpc.voice await rpc.voice
@ -496,6 +501,7 @@ export function OnboardingPage() {
onClick={() => { onClick={() => {
persistUiLocale(code as UiLocale); persistUiLocale(code as UiLocale);
setCurrentLocale(code); setCurrentLocale(code);
setLanguageChoice(code as UiLocale);
setStep("voice"); setStep("voice");
}} }}
className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${ className={`rounded-xl border-2 px-3 py-3 text-sm font-medium text-left transition-all ${

View File

@ -10,11 +10,13 @@ import {
Monitor, Monitor,
Settings, Settings,
ShieldCheck, ShieldCheck,
UserRound,
Volume2, Volume2,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import { type ComponentType, useEffect, useRef, useState } from "react"; import { type ComponentType, useEffect, useRef, useState } from "react";
import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
import { UserProfileSettings } from "../components/UserProfileSettings";
import type { TradingBotUiState } from "../lib/trading-bot-status"; import type { TradingBotUiState } from "../lib/trading-bot-status";
import { import {
ComputerSettingsPanel, ComputerSettingsPanel,
@ -33,6 +35,7 @@ export type SettingsSection =
| "integrations" | "integrations"
| "models" | "models"
| "memory" | "memory"
| "profile"
| "voice" | "voice"
| "usage" | "usage"
| "computer" | "computer"
@ -106,6 +109,7 @@ export function SettingsOverlay({
{ id: "integrations", label: t`Integrations`, icon: LayoutGrid }, { id: "integrations", label: t`Integrations`, icon: LayoutGrid },
{ id: "models", label: t`Models`, icon: Cpu }, { id: "models", label: t`Models`, icon: Cpu },
{ id: "memory", label: t`Memory`, icon: Brain }, { id: "memory", label: t`Memory`, icon: Brain },
{ id: "profile", label: t`About you`, icon: UserRound },
{ id: "voice", label: t`Voice`, icon: Volume2 }, { id: "voice", label: t`Voice`, icon: Volume2 },
{ id: "usage", label: t`Usage`, icon: Gauge }, { id: "usage", label: t`Usage`, icon: Gauge },
...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []), ...(showComputer ? [{ id: "computer" as const, label: t`Computer`, icon: Monitor }] : []),
@ -245,6 +249,7 @@ export function SettingsOverlay({
onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined} onOpenMcp={onOpenMcp ? () => leaveSettings(onOpenMcp) : undefined}
/> />
) : null} ) : null}
{section === "profile" ? <UserProfileSettings /> : null}
{section === "usage" ? ( {section === "usage" ? (
<UsageSettingsPanel usage={usage} panelRef={usageRef} /> <UsageSettingsPanel usage={usage} panelRef={usageRef} />
) : null} ) : null}

View File

@ -4,6 +4,7 @@ import {
BotSecretName, BotSecretName,
SecretAskPurpose, SecretAskPurpose,
SecretHttpRequest, SecretHttpRequest,
USER_FACT_CATEGORIES,
} from "@rakazo/contracts"; } from "@rakazo/contracts";
import { z } from "zod"; import { z } from "zod";
@ -383,6 +384,40 @@ export const builtinAgentTools: ConnectorTool[] = [
required: ["content"], 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", name: "web_search",
description: description:

View File

@ -316,7 +316,10 @@ describe("run instructions", () => {
expect(request.instructions.startsWith("You are Chief — Chief of Staff.")).toBe(true); expect(request.instructions.startsWith("You are Chief — Chief of Staff.")).toBe(true);
expect(request.instructions).toContain("You are the user's main agent"); expect(request.instructions).toContain("You are the user's main agent");
expect(request.instructions).toContain("save lasting facts they share with remember"); // The main bot keeps the user model and delegates; its role names both tools.
expect(request.instructions).toContain("record it with update_user_profile");
expect(request.instructions).toContain("Give a specialist tracked work with delegate_task");
expect(request.instructions).not.toContain("save lasting facts they share with remember");
}); });
it("shows the main bot every specialist it can delegate to, its own space first", async () => { it("shows the main bot every specialist it can delegate to, its own space first", async () => {

View File

@ -28,7 +28,7 @@ export function selectBuiltinToolsForRun(options: {
semanticMemoryEnabled: boolean; semanticMemoryEnabled: boolean;
cloudAgentEnabled?: boolean; cloudAgentEnabled?: boolean;
messagingChannelRun: boolean; messagingChannelRun: boolean;
/** Only the user's main bot may delegate tasks. */ /** Only the user's main bot delegates tasks and keeps the user model. */
mainBot?: boolean; mainBot?: boolean;
}) { }) {
return selectCloudAgentTools( return selectCloudAgentTools(
@ -48,9 +48,15 @@ export function selectBuiltinToolsForRun(options: {
Boolean(options.cloudAgentEnabled), Boolean(options.cloudAgentEnabled),
).filter( ).filter(
(tool) => (tool) =>
(options.mainBot || tool.name !== "delegate_task") && (options.mainBot || !["delegate_task", "update_user_profile"].includes(tool.name)) &&
(!options.messagingChannelRun || (!options.messagingChannelRun ||
(!["remember", "save_memory", "recall_memory", "forget_memory"].includes(tool.name) && (![
"remember",
"save_memory",
"recall_memory",
"forget_memory",
"update_user_profile",
].includes(tool.name) &&
!tool.name.startsWith("scratchpad_"))), !tool.name.startsWith("scratchpad_"))),
); );
} }

View File

@ -373,6 +373,7 @@ import {
} from "./thread-artifacts.js"; } from "./thread-artifacts.js";
import { advanceToolCallLoopGuard } from "./tool-loop.js"; import { advanceToolCallLoopGuard } from "./tool-loop.js";
import { textContentArg } from "./tool-text.js"; import { textContentArg } from "./tool-text.js";
import { updateUserProfileFromTool } from "./user-profile-tools.js";
import { import {
botMessageOutcomeFromMidTurn, botMessageOutcomeFromMidTurn,
clampUserProgressMessage, clampUserProgressMessage,
@ -2502,6 +2503,20 @@ export function createRunExecutor(deps: ExecutorDeps) {
); );
return finish({ ok: true }); 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") { if (name === "web_search") {
return finish(await webSearchFromTool(web, context, args)); return finish(await webSearchFromTool(web, context, args));
} }

View File

@ -102,6 +102,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", () => { describe("ScriptedAgentRuntime executionIds", () => {
it("gives repeated tools distinct executionIds within a run", async () => { it("gives repeated tools distinct executionIds within a run", async () => {
const runtime = new ScriptedAgentRuntime(); const runtime = new ScriptedAgentRuntime();

View File

@ -4,10 +4,17 @@ import type {
AgentRuntime, AgentRuntime,
AgentRuntimeEvent, AgentRuntimeEvent,
} from "@rakazo/adapter-kit"; } from "@rakazo/adapter-kit";
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core"; import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
const running = new Map<string, AbortController>(); const running = new Map<string, AbortController>();
/** "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 { export class ScriptedAgentRuntime implements AgentRuntime {
describe() { describe() {
return { return {
@ -178,6 +185,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("delegate to the bot named")) { if (lower.includes("delegate to the bot named")) {
const name = namedBot(prompt) ?? "Researcher"; const name = namedBot(prompt) ?? "Researcher";
const brief = const brief =

View File

@ -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<Record<string, unknown>> = [];
const tx = {
$queryRaw: vi.fn().mockResolvedValue([]),
userProfile: { upsert: vi.fn().mockResolvedValue({}) },
userFact: {
findMany: vi.fn().mockResolvedValue([]),
create: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
created.push(data);
return { id: `fact-${created.length}`, value: data.value, status: data.status };
}),
},
};
const prisma = {
$transaction: (run: (client: typeof tx) => Promise<unknown>) => 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 }),
]);
});
});

View File

@ -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<string, unknown>,
) {
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;
}
}

View File

@ -15,3 +15,4 @@ export * from "./reactions.js";
export * from "./rpc.js"; export * from "./rpc.js";
export * from "./runs.js"; export * from "./runs.js";
export * from "./search.js"; export * from "./search.js";
export * from "./user-profile.js";

View File

@ -90,6 +90,11 @@ import { ProjectSessionsOutputSchema } from "./project-sessions.js";
import { MessageReactionSchema } from "./reactions.js"; import { MessageReactionSchema } from "./reactions.js";
import { RunsListOutputSchema } from "./runs.js"; import { RunsListOutputSchema } from "./runs.js";
import { SearchQueryOutputSchema } from "./search.js"; import { SearchQueryOutputSchema } from "./search.js";
import {
ForgetUserFactsInput,
UpdateUserProfileInput,
UserProfileStateSchema,
} from "./user-profile.js";
const botId = z.object({ botId: Id }); const botId = z.object({ botId: Id });
const groupId = z.object({ groupId: Id }); const groupId = z.object({ groupId: Id });
@ -169,6 +174,12 @@ export const appContract = {
preferences: { preferences: {
update: oc.input(z.object({ avatarStyle: AvatarStyleSchema })).output(MeSchema), 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: { spaces: {
list: oc.output(SpaceNavigationSchema), list: oc.output(SpaceNavigationSchema),
create: oc create: oc

View File

@ -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);
});
});

View File

@ -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<typeof UserProfileStateSchema>;
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<typeof UpdateUserProfileInput>;
/** 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<typeof ForgetUserFactsInput>;

View File

@ -17,4 +17,5 @@ export * from "./repos.js";
export * from "./scope.js"; export * from "./scope.js";
export * from "./spaces.js"; export * from "./spaces.js";
export * from "./transaction-retry.js"; export * from "./transaction-retry.js";
export * from "./user-profile.js";
export * from "./voice-credentials.js"; export * from "./voice-credentials.js";

View File

@ -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<void>;
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);
});
});

View File

@ -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);
});
});

View File

@ -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<UserFactCategory>(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<UserProfileState> {
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<void> {
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<void> {
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<AddedUserFact[]> {
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<void> {
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<void> {
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<AddedUserFact[]> {
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<number> {
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;
});
}

View File

@ -60,6 +60,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
it("rejects unauthenticated calls to every protected RPC family", async () => { it("rejects unauthenticated calls to every protected RPC family", async () => {
const calls = exhaustiveProtectedCalls([ const calls = exhaustiveProtectedCalls([
["me"], ["me"],
["profile/get"],
["profile/update", { timeZone: "UTC" }],
["profile/forget", { all: true }],
["deployment/get"], ["deployment/get"],
["deployment/update", { signupsEnabled: true }], ["deployment/update", { signupsEnabled: true }],
["updater/status"], ["updater/status"],
@ -488,6 +491,50 @@ describeWithDatabase("API authorization and resource isolation", () => {
await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }), await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }),
).toMatchObject({ content: "owner-only-memory" }); ).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. // Destructive bot removal is checked last so an authorization regression is unmistakable.
await expectDenied(app, intruder, "bots/remove", { await expectDenied(app, intruder, "bots/remove", {
botId: ownerBot.id, botId: ownerBot.id,

View File

@ -104,8 +104,10 @@ async function main() {
"packages/testkit/src/connections.test.ts", "packages/testkit/src/connections.test.ts",
"packages/testkit/src/bot-secrets.test.ts", "packages/testkit/src/bot-secrets.test.ts",
"packages/testkit/src/delegation.postgres.test.ts", "packages/testkit/src/delegation.postgres.test.ts",
"packages/testkit/src/user-profile.postgres.test.ts",
"packages/db/src/space-membership.postgres.test.ts", "packages/db/src/space-membership.postgres.test.ts",
"packages/db/src/main-agent.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/db/src/messaging.postgres.test.ts",
"packages/memory/src/commit.postgres.test.ts", "packages/memory/src/commit.postgres.test.ts",
"packages/adapters/src/wakeup.postgres.test.ts", "packages/adapters/src/wakeup.postgres.test.ts",

View File

@ -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<Response> };
type AppHandles = Awaited<ReturnType<typeof createApp>>;
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<UserProfileState>(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<UserProfileState>(app, cookie, "profile/update", {
confirmFactIds: [state.facts[0]!.id],
});
expect(state.facts.map((fact) => fact.status)).toEqual(["active", "active"]);
state = await rpc<UserProfileState>(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<UserProfileState>(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<UserProfileState>(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<T>(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;
}