From 9565ffabb55122416f9dcabe1479f356577c8f30 Mon Sep 17 00:00:00 2001 From: Roomote Date: Thu, 10 Sep 2026 19:17:29 +0000 Subject: [PATCH 01/12] feat: add private user personalization --- .../__tests__/updatePersonalization.test.ts | 41 + apps/api/src/handlers/tasks/index.ts | 2 + .../handlers/tasks/updatePersonalization.ts | 89 + apps/docs/personal-settings.mdx | 29 + .../settings/PersonalizationSection.test.tsx | 85 + .../settings/PersonalizationSection.tsx | 145 + apps/web/src/components/settings/index.ts | 1 + .../pages/PersonalSettingsPage.test.tsx | 2 + .../settings/pages/PersonalSettingsPage.tsx | 6 +- .../src/trpc/commands/preferences/index.ts | 49 +- .../preferences/personal-preferences.test.ts | 41 + apps/web/src/trpc/routers/_app.ts | 25 + apps/web/src/types/preferences.ts | 6 + .../__tests__/user-personalization.test.ts | 53 + .../src/mcp/roomote-mcp-server/index.ts | 16 + .../roomote-mcp-server/tasks-api-client.ts | 17 + .../user-personalization.ts | 26 + .../__tests__/fast-agent-service.test.ts | 66 + .../fast-agent-native-tool-bridge.ts | 14 + .../server/fast-agent/fast-agent-prompt.ts | 13 + .../server/fast-agent/fast-agent-service.ts | 49 +- packages/cloud-agents/src/server/index.ts | 1 + .../src/server/user-personalization.test.ts | 53 + .../src/server/user-personalization.ts | 56 + packages/db/drizzle/0082_nebulous_namor.sql | 13 + packages/db/drizzle/meta/0082_snapshot.json | 15315 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + .../db/src/lib/user-personalization.test.ts | 169 + packages/db/src/lib/user-personalization.ts | 223 + packages/db/src/schema.ts | 30 + packages/db/src/server.ts | 3 + .../lib/task-runs/dequeue-resume-task-run.ts | 16 +- .../server/lib/task-runs/dequeue-task-run.ts | 13 + .../record-task-message-envelope.test.ts | 45 + .../task-runs/record-task-message-envelope.ts | 65 +- .../task-runs/task-personalization.test.ts | 78 + .../lib/task-runs/task-personalization.ts | 47 + packages/types/src/fast-agent-tool-catalog.ts | 5 + 38 files changed, 16896 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts create mode 100644 apps/api/src/handlers/tasks/updatePersonalization.ts create mode 100644 apps/web/src/components/settings/PersonalizationSection.test.tsx create mode 100644 apps/web/src/components/settings/PersonalizationSection.tsx create mode 100644 apps/worker/src/mcp/roomote-mcp-server/__tests__/user-personalization.test.ts create mode 100644 apps/worker/src/mcp/roomote-mcp-server/user-personalization.ts create mode 100644 packages/cloud-agents/src/server/user-personalization.test.ts create mode 100644 packages/cloud-agents/src/server/user-personalization.ts create mode 100644 packages/db/drizzle/0082_nebulous_namor.sql create mode 100644 packages/db/drizzle/meta/0082_snapshot.json create mode 100644 packages/db/src/lib/user-personalization.test.ts create mode 100644 packages/db/src/lib/user-personalization.ts create mode 100644 packages/sdk/src/server/lib/task-runs/record-task-message-envelope.test.ts create mode 100644 packages/sdk/src/server/lib/task-runs/task-personalization.test.ts create mode 100644 packages/sdk/src/server/lib/task-runs/task-personalization.ts diff --git a/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts b/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts new file mode 100644 index 0000000000..7562ac17e8 --- /dev/null +++ b/apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts @@ -0,0 +1,41 @@ +import { TaskPayloadKind } from '@roomote/types'; + +import { canLearnPersonalizationForRun } from '../updatePersonalization'; + +describe('personalization learning run authorization', () => { + it.each([ + TaskPayloadKind.StandardTask, + TaskPayloadKind.SlackAppMention, + TaskPayloadKind.LinearAgentSession, + TaskPayloadKind.SnapshotResume, + TaskPayloadKind.GithubPrReview, + ])('allows a trusted user actor on %s', (payloadKind) => { + expect( + canLearnPersonalizationForRun({ + actingUserId: 'current-user', + initiatorKind: 'user', + payloadKind, + }), + ).toBe(true); + }); + + it.each([ + { + actingUserId: 'service-user', + initiatorKind: 'automation', + payloadKind: TaskPayloadKind.StandardTask, + }, + { + actingUserId: 'user-1', + initiatorKind: 'user', + payloadKind: TaskPayloadKind.Scan, + }, + { + actingUserId: null, + initiatorKind: 'user', + payloadKind: TaskPayloadKind.SnapshotResume, + }, + ])('denies automation, maintenance, and actorless runs', (run) => { + expect(canLearnPersonalizationForRun(run)).toBe(false); + }); +}); diff --git a/apps/api/src/handlers/tasks/index.ts b/apps/api/src/handlers/tasks/index.ts index 603ec42d7a..66fd71e500 100644 --- a/apps/api/src/handlers/tasks/index.ts +++ b/apps/api/src/handlers/tasks/index.ts @@ -20,6 +20,7 @@ import { updateTaskModelSelection } from './updateModelSelection'; import { listTaskModels } from './listModels'; import { getGoal, manageGoal } from './manageGoal'; import { saveTaskMemory } from './saveTaskMemory'; +import { updatePersonalization } from './updatePersonalization'; export const tasksRouter = new Hono<{ Variables: Variables }>(); @@ -43,3 +44,4 @@ tasksRouter.post('/:taskId/task_suggestions', submitTaskSuggestions); tasksRouter.post('/:taskId/mcp_recommendations', submitMcpRecommendations); tasksRouter.post('/runs/:runId/goal', manageGoal); tasksRouter.post('/runs/:runId/memory', saveTaskMemory); +tasksRouter.post('/runs/:runId/personalization', updatePersonalization); diff --git a/apps/api/src/handlers/tasks/updatePersonalization.ts b/apps/api/src/handlers/tasks/updatePersonalization.ts new file mode 100644 index 0000000000..a4fc63a42e --- /dev/null +++ b/apps/api/src/handlers/tasks/updatePersonalization.ts @@ -0,0 +1,89 @@ +import type { Context } from 'hono'; +import { z } from 'zod'; + +import { + appendLearnedUserPreference, + db, + eq, + taskRuns, + tasks, +} from '@roomote/db/server'; +import { TaskPayloadKind } from '@roomote/types'; + +import type { Variables } from '../../types'; +import type { McpAuth } from '../mcp/middleware'; +import { isRunTokenContext } from '../mcp/proxy-utils'; +import { logHandlerError } from '../utils'; + +const inputSchema = z.object({ + preference: z.string().trim().min(1).max(500), + confidence: z.enum(['explicit', 'inferred']), +}); + +const EXCLUDED_PERSONALIZATION_PAYLOADS = new Set([ + TaskPayloadKind.Scan, + TaskPayloadKind.McpRecommendations, + TaskPayloadKind.SnapshotEnvironment, +]); + +export function canLearnPersonalizationForRun(run: { + actingUserId: string | null; + initiatorKind: string; + payloadKind: TaskPayloadKind; +}): run is typeof run & { actingUserId: string } { + return ( + Boolean(run.actingUserId) && + run.initiatorKind === 'user' && + !EXCLUDED_PERSONALIZATION_PAYLOADS.has(run.payloadKind) + ); +} + +export async function updatePersonalization( + c: Context<{ Variables: Variables & { mcpAuth: McpAuth } }>, +): Promise { + const auth = c.get('mcpAuth').authContext; + if (!isRunTokenContext(auth)) { + return c.json( + { error: 'Personalization updates require a task run token' }, + 403, + ); + } + + const runId = Number(c.req.param('runId')); + if (!Number.isInteger(runId) || runId <= 0 || auth.runId !== runId) { + return c.json( + { error: 'Task run token does not match requested task run' }, + 403, + ); + } + + const parsed = inputSchema.safeParse(await c.req.json().catch(() => null)); + if (!parsed.success) + return c.json({ error: 'Invalid personalization update' }, 400); + + try { + const [run] = await db + .select({ + actingUserId: taskRuns.actingUserId, + payloadKind: taskRuns.payloadKind, + initiatorKind: tasks.initiatorKind, + }) + .from(taskRuns) + .innerJoin(tasks, eq(tasks.id, taskRuns.taskId)) + .where(eq(taskRuns.id, runId)) + .limit(1); + + if (!run || !canLearnPersonalizationForRun(run)) { + return c.json({ saved: false, reason: 'not_human_initiated' }, 200); + } + + const result = await appendLearnedUserPreference({ + userId: run.actingUserId, + ...parsed.data, + }); + return c.json(result, 200); + } catch (error) { + logHandlerError('updatePersonalization', error); + return c.json({ error: 'Failed to update personalization' }, 500); + } +} diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 9f882f649f..0a35a179aa 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -65,6 +65,8 @@ Personal Settings also include app preferences such as: you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view - **Therapist Mode** to explain which remembered fact informed a Session or task +- **How to work with me** for private personal instructions and conversational + learning Most teammates only need profile, linked accounts, and theme settings. @@ -76,6 +78,33 @@ that remembered fact in human terms and explains how it was used. It does not narrate unrelated retrievals or expose internal memory identifiers, storage paths, metadata, or provenance fields. +## Personalize how Roomote works with you + +Use **How to work with me** for durable preferences such as your preferred +name, language, formality, answer length, use of examples, directness, update +frequency, or accessibility and presentation preferences you choose to share. +These instructions apply to conversations with you; they do not silently +rewrite code or customer-facing deliverables. + +**Learn from conversations** is on by default. Roomote may save a preference +you state explicitly and may form modest, revisable style preferences from +repeated behavior. Turning learning off stops new automatic updates but keeps +the instructions already shown in the text box active. **Reset** clears both +edited and learned instructions. A reset does not rebuild them from older +conversation history. + +Personalization follows the trusted account associated with the current +speaker on supported chat surfaces and with the person who requested a task. +In a shared thread, Roomote adapts its conversation to the current speaker +while preserving the original requester's task requirements when they conflict. + +Personalization is encrypted at rest and is not available to other members or +admins through Roomote's normal UI or API. It is excluded from shared task +transcripts, summaries, memory, and ordinary tool output. It remains subject to +the deployment operator's infrastructure access, backups, and retention +policies. Roomote does not use public-web or LinkedIn enrichment for this +feature. + ## Common issues - **A linked account is missing.** Ask an admin to enable the integration from diff --git a/apps/web/src/components/settings/PersonalizationSection.test.tsx b/apps/web/src/components/settings/PersonalizationSection.test.tsx new file mode 100644 index 0000000000..2bf1fe102b --- /dev/null +++ b/apps/web/src/components/settings/PersonalizationSection.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +const mocks = vi.hoisted(() => ({ + mutate: vi.fn(), + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + settings: { + instructions: 'Be concise.', + learnFromConversations: true, + version: 3, + }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: vi.fn(() => ({ + data: mocks.settings, + isPending: false, + })), + useMutation: vi.fn(() => ({ mutate: mocks.mutate, isPending: false })), + useQueryClient: vi.fn(() => ({ + invalidateQueries: mocks.invalidateQueries, + setQueryData: mocks.setQueryData, + })), +})); + +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + preferences: { + getPersonalization: { + queryKey: () => ['personalization'], + queryOptions: () => ({}), + }, + updatePersonalization: { + mutationOptions: (options: unknown) => options, + }, + }, + }), +})); + +import { PersonalizationSection } from './PersonalizationSection'; + +describe('PersonalizationSection', () => { + beforeEach(() => mocks.mutate.mockClear()); + + it('saves an edited blob with its concurrency version', () => { + render(); + + fireEvent.change(screen.getByLabelText('Personal instructions'), { + target: { value: 'Lead with a recommendation.' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(mocks.mutate).toHaveBeenCalledWith( + { + expectedVersion: 3, + instructions: 'Lead with a recommendation.', + }, + expect.any(Object), + ); + }); + + it('resets every saved preference through the versioned mutation', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + + expect(mocks.mutate).toHaveBeenCalledWith( + { expectedVersion: 3, reset: true }, + expect.any(Object), + ); + }); + + it('updates learning separately and leaves the blob untouched', () => { + render(); + + fireEvent.click( + screen.getByRole('switch', { name: 'Learn from conversations' }), + ); + + expect(mocks.mutate).toHaveBeenCalledWith({ + expectedVersion: 3, + learnFromConversations: false, + }); + }); +}); diff --git a/apps/web/src/components/settings/PersonalizationSection.tsx b/apps/web/src/components/settings/PersonalizationSection.tsx new file mode 100644 index 0000000000..8be64c0b2b --- /dev/null +++ b/apps/web/src/components/settings/PersonalizationSection.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; + +import { + Button, + Label, + RotateCcw, + Sparkles, + Switch, + Textarea, +} from '@/components/system'; +import { useTRPC } from '@/trpc/client'; +import type { UserPersonalizationSettings } from '@/types/preferences'; + +import { Section } from './Section'; + +export function PersonalizationSection() { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const queryKey = trpc.preferences.getPersonalization.queryKey(); + const personalization = useQuery( + trpc.preferences.getPersonalization.queryOptions(), + ); + const [instructions, setInstructions] = useState(''); + + useEffect(() => { + if (personalization.data) { + setInstructions(personalization.data.instructions); + } + }, [personalization.data]); + + const update = useMutation( + trpc.preferences.updatePersonalization.mutationOptions({ + onSuccess: (settings) => { + queryClient.setQueryData( + queryKey, + settings, + ); + setInstructions(settings.instructions); + }, + onError: (error) => { + toast.error(error.message); + void queryClient.invalidateQueries({ queryKey }); + }, + }), + ); + + const settings = personalization.data; + const isBusy = personalization.isPending || update.isPending; + const hasChanges = settings?.instructions !== instructions; + + return ( +
+
+
+ +