Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions apps/api/src/handlers/tasks/__tests__/updatePersonalization.test.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/api/src/handlers/tasks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();

Expand All @@ -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);
85 changes: 85 additions & 0 deletions apps/api/src/handlers/tasks/updatePersonalization.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import type { Context } from 'hono';
import { z } from 'zod';

import { db, eq, taskRuns, tasks } from '@roomote/db/server';
import { enqueueUserPersonalizationUpdate } from '@roomote/cloud-agents/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>([
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<Response> {
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 enqueueUserPersonalizationUpdate({
userId: run.actingUserId,
...parsed.data,
taskId: String(runId),
});
return c.json(result, 200);
} catch (error) {
logHandlerError('updatePersonalization', error);
return c.json({ error: 'Failed to update personalization' }, 500);
}
}
29 changes: 29 additions & 0 deletions apps/docs/personal-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { LucideProps } from 'lucide-react';

import {
type LucideIcon,
BookHeart,
BookOpenText,
BrandIcon,
Bot,
Expand Down Expand Up @@ -40,6 +41,7 @@ export function toolIconForKey(key: ToolIconKey): LucideIcon {
if (key === 'task') return Zap;
if (key === 'message') return MessageSquareText;
if (key === 'memory') return BookOpenText;
if (key === 'book-heart') return BookHeart;
if (key === 'artifact') return HardDriveUpload;
if (key === 'widget') return GalleryVerticalEnd;
if (key === 'roomote') return RoomoteR;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export function resolveToolPresentationPolicy(
const consequentialReceipt =
presentation.identity.toolName !== null &&
CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName);
const isPersonalizationReceipt =
presentation.identity.toolName === 'update_personalization';
const keepConsequentialReceiptVisible =
consequentialReceipt &&
(presentation.identity.toolName !== 'send_chat_reply' ||
Expand All @@ -94,8 +96,9 @@ export function resolveToolPresentationPolicy(
rowVisibility = 'hidden';
}

const detailMode: ResolvedToolPolicy['detailMode'] =
isSubagentToolMessage(msg) && hasSubagentSummary(msg)
const detailMode: ResolvedToolPolicy['detailMode'] = isPersonalizationReceipt
? 'none'
: isSubagentToolMessage(msg) && hasSubagentSummary(msg)
? 'expandable'
: hasPreview
? 'preview'
Expand All @@ -113,6 +116,7 @@ export function resolveToolPresentationPolicy(
detailMode,
activityMode:
isRunning ||
isPersonalizationReceipt ||
hasPreview ||
isArtifact ||
renderAs === 'delegated-task-card' ||
Expand All @@ -121,6 +125,7 @@ export function resolveToolPresentationPolicy(
: 'collapsible',
renderAs,
groupingMode:
isPersonalizationReceipt ||
hasPreview ||
isArtifact ||
renderAs === 'delegated-task-card' ||
Expand Down
Loading
Loading