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:
commit
728d78001a
@ -201,6 +201,7 @@ import {
|
||||
threadHead,
|
||||
threadSnapshot,
|
||||
} from "./thread-target.js";
|
||||
import { forgetProfile, getProfile, updateProfile } from "./user-profile.js";
|
||||
import {
|
||||
disconnectVoiceCredential,
|
||||
listVoiceCatalog,
|
||||
@ -540,6 +541,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),
|
||||
|
||||
60
apps/api/src/user-profile.ts
Normal file
60
apps/api/src/user-profile.ts
Normal 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);
|
||||
}
|
||||
@ -222,6 +222,7 @@ export async function openUserSettings(
|
||||
| "integrations"
|
||||
| "models"
|
||||
| "memory"
|
||||
| "profile"
|
||||
| "voice"
|
||||
| "usage"
|
||||
| "computer"
|
||||
|
||||
106
apps/web/e2e/user-profile.spec.ts
Normal file
106
apps/web/e2e/user-profile.spec.ts
Normal 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: [] });
|
||||
});
|
||||
245
apps/web/src/components/UserProfileSettings.tsx
Normal file
245
apps/web/src/components/UserProfileSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
apps/web/src/lib/onboarding-profile.test.ts
Normal file
56
apps/web/src/lib/onboarding-profile.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
73
apps/web/src/lib/onboarding-profile.ts
Normal file
73
apps/web/src/lib/onboarding-profile.ts
Normal 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 })) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
76
apps/web/src/lib/user-profile-view.test.ts
Normal file
76
apps/web/src/lib/user-profile-view.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
77
apps/web/src/lib/user-profile-view.ts
Normal file
77
apps/web/src/lib/user-profile-view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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<UiLocale | null>(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 ${
|
||||
|
||||
@ -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" ? <UserProfileSettings /> : null}
|
||||
{section === "usage" ? (
|
||||
<UsageSettingsPanel usage={usage} panelRef={usageRef} />
|
||||
) : null}
|
||||
|
||||
@ -4,6 +4,7 @@ import {
|
||||
BotSecretName,
|
||||
SecretAskPurpose,
|
||||
SecretHttpRequest,
|
||||
USER_FACT_CATEGORIES,
|
||||
} from "@rakazo/contracts";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -383,6 +384,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:
|
||||
|
||||
@ -316,7 +316,10 @@ describe("run instructions", () => {
|
||||
|
||||
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("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 () => {
|
||||
|
||||
@ -28,7 +28,7 @@ export function selectBuiltinToolsForRun(options: {
|
||||
semanticMemoryEnabled: boolean;
|
||||
cloudAgentEnabled?: 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;
|
||||
}) {
|
||||
return selectCloudAgentTools(
|
||||
@ -48,9 +48,15 @@ export function selectBuiltinToolsForRun(options: {
|
||||
Boolean(options.cloudAgentEnabled),
|
||||
).filter(
|
||||
(tool) =>
|
||||
(options.mainBot || tool.name !== "delegate_task") &&
|
||||
(options.mainBot || !["delegate_task", "update_user_profile"].includes(tool.name)) &&
|
||||
(!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_"))),
|
||||
);
|
||||
}
|
||||
|
||||
@ -373,6 +373,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,
|
||||
@ -2502,6 +2503,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));
|
||||
}
|
||||
|
||||
@ -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", () => {
|
||||
it("gives repeated tools distinct executionIds within a run", async () => {
|
||||
const runtime = new ScriptedAgentRuntime();
|
||||
|
||||
@ -4,10 +4,17 @@ import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeEvent,
|
||||
} from "@rakazo/adapter-kit";
|
||||
import { USER_FACT_CATEGORIES } from "@rakazo/contracts";
|
||||
import { abortableDelay, DELEGATED_TASK_WAKE_CUE, inferHandoffTargetName } from "@rakazo/core";
|
||||
|
||||
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 {
|
||||
describe() {
|
||||
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")) {
|
||||
const name = namedBot(prompt) ?? "Researcher";
|
||||
const brief =
|
||||
|
||||
110
packages/adapters/src/user-profile-tools.test.ts
Normal file
110
packages/adapters/src/user-profile-tools.test.ts
Normal 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 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
82
packages/adapters/src/user-profile-tools.ts
Normal file
82
packages/adapters/src/user-profile-tools.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -90,6 +90,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 });
|
||||
@ -169,6 +174,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
|
||||
|
||||
62
packages/contracts/src/user-profile.test.ts
Normal file
62
packages/contracts/src/user-profile.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
packages/contracts/src/user-profile.ts
Normal file
106
packages/contracts/src/user-profile.ts
Normal 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>;
|
||||
@ -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";
|
||||
|
||||
304
packages/db/src/user-profile.postgres.test.ts
Normal file
304
packages/db/src/user-profile.postgres.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
21
packages/db/src/user-profile.test.ts
Normal file
21
packages/db/src/user-profile.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
383
packages/db/src/user-profile.ts
Normal file
383
packages/db/src/user-profile.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@ -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"],
|
||||
@ -488,6 +491,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,
|
||||
|
||||
@ -104,8 +104,10 @@ async function main() {
|
||||
"packages/testkit/src/connections.test.ts",
|
||||
"packages/testkit/src/bot-secrets.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/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",
|
||||
|
||||
196
packages/testkit/src/user-profile.postgres.test.ts
Normal file
196
packages/testkit/src/user-profile.postgres.test.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user