From 05019664e2912cf2e4f626c432c8ffcfed3405a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 18:33:19 +0000 Subject: [PATCH 1/3] fix(pulse): make summaries conversational with rich workspace context The Pulse feature was generating robotic, stats-focused summaries like "You've completed 5 tasks this week" which provides no useful context. This change transforms Pulse into a friendly workspace companion that: - Tells users what people are ACTUALLY working on (not just counts) - Includes drive/project context for better awareness - Uses conversational tone like a colleague catching you up - Focuses on interesting/relevant info, not data dumps - Extends activity window to 24h for "what you missed" context - Increases message preview length from 100 to 300 chars Examples of the new style: - "Noah's been working on the Product Roadmap - looks like he added the Q2 timeline" - "Alex shared the Marketing Brief with you, and Sarah was asking about the launch date" Instead of the old style: - "You have 5 tasks and 26 pages were updated this week" https://claude.ai/code/session_01MzgqCneugAdEMYGZcb1iub --- apps/web/src/app/api/pulse/cron/route.ts | 148 +++++++++++++------ apps/web/src/app/api/pulse/generate/route.ts | 148 +++++++++++++------ packages/db/src/schema/dashboard.ts | 6 + 3 files changed, 210 insertions(+), 92 deletions(-) diff --git a/apps/web/src/app/api/pulse/cron/route.ts b/apps/web/src/app/api/pulse/cron/route.ts index cacfeb3cb..ec12cc29d 100644 --- a/apps/web/src/app/api/pulse/cron/route.ts +++ b/apps/web/src/app/api/pulse/cron/route.ts @@ -13,6 +13,7 @@ import { directMessages, dmConversations, pages, + drives, driveMembers, activityLogs, pulseSummaries, @@ -38,35 +39,41 @@ import { loggers } from '@pagespace/lib/server'; const CRON_SECRET = process.env.CRON_SECRET; // System prompt for generating pulse summaries -const PULSE_SYSTEM_PROMPT = `You are a workspace assistant generating a brief, personalized activity summary. - -Create a SHORT summary (2-4 sentences) telling the user what specifically needs attention. - -RULES: -- ALWAYS name specific tasks, pages, or people - never just counts -- If there are overdue tasks, name them: "'Finalize budget' and 'Review proposal' are overdue" -- High-priority overdue tasks should be mentioned first -- For messages, summarize content: "Noah asked about the pricing update" not "Noah messaged you" -- For mentions: "Sarah mentioned you in Q1 Planning" -- For shares: "Noah shared 'Product Roadmap' with you" -- For content changes: Describe WHAT changed: "Sarah updated Product Pricing" with who made the change -- NEVER mention categories with zero items - omit them entirely -- NEVER say "no messages", "nothing new", or similar - just skip empty categories -- Be direct and specific, like a colleague giving a quick heads-up -- Include a brief time-appropriate greeting - -PRIORITY ORDER (mention most important first): -1. Overdue high-priority tasks -2. Pages shared with you / mentions -3. Meaningful content changes by collaborators -4. Unread messages with context - -Do NOT: -- Use excessive exclamation marks or emojis -- Be overly enthusiastic -- List every single activity -- Include generic filler content -- Mention counts without naming the items`; +const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion giving the user a natural, conversational update about their workspace. + +Your job is to tell them something INTERESTING or USEFUL about what's happening - not give them a robotic status report. + +TONE: +- Like a thoughtful colleague catching you up over coffee +- Natural and conversational, not a bullet-point readout +- If it's a quiet day, just say hi warmly - don't manufacture urgency +- If there's interesting activity, share what's actually happening + +WHAT TO FOCUS ON (pick what's most interesting, not everything): +- What are people actually working on? "Noah's been making progress on the Product Roadmap" +- Interesting updates: "Sarah added some new ideas to the Q1 Planning doc" +- Meaningful messages: If someone asked a specific question, mention it +- Recent shares/mentions: "Alex shared the Budget proposal with you" +- If someone left you a message, summarize WHAT they said, not just that they messaged + +WHAT TO AVOID: +- Robotic stat dumps: "You have 5 tasks, 26 pages updated" - USELESS +- Vague summaries: "activity has occurred in your workspace" - BORING +- Task-list mentality: Don't treat this as a to-do reminder +- Counts without context: Never say "X tasks" or "X pages" without specifics +- Filler when there's nothing: If it's quiet, a simple warm greeting is fine + +EXAMPLES OF GOOD SUMMARIES: +- "Hey! Noah's been working on the Product Roadmap this morning - looks like he added the Q2 timeline. Sarah also dropped some comments on your Budget doc." +- "Good afternoon! Alex shared the Marketing Brief with you, and it looks pretty comprehensive. Also, Sarah was asking about the launch date in your DMs." +- "Morning! Things are quiet right now. The team was active yesterday on the Sprint Planning doc if you want to catch up." + +EXAMPLES OF BAD SUMMARIES: +- "Good morning. You have 3 tasks due today and 12 pages were updated this week." (robotic, no context) +- "Evening, Jonathan. Activity has occurred in your drives." (vague, useless) +- "You've completed 5 tasks this week, though no specific details were provided." (never admit lack of context - just omit) + +Keep it to 2-3 natural sentences. Start with a brief, time-appropriate greeting.`; export async function POST(req: Request) { // Require cron secret - fail-closed for security @@ -179,13 +186,26 @@ async function generatePulseForUser(userId: string, now: Date): Promise { const startOfWeek = new Date(startOfToday); startOfWeek.setDate(startOfWeek.getDate() - dayOfWeek); - // Get user's drives + // Get user's drives with full context const userDrives = await db .select({ driveId: driveMembers.driveId }) .from(driveMembers) .where(eq(driveMembers.userId, userId)); const driveIds = userDrives.map(d => d.driveId); + // Get drive details for workspace context + const driveDetails = driveIds.length > 0 ? await db + .select({ + id: drives.id, + name: drives.name, + description: drives.drivePrompt, + }) + .from(drives) + .where(and( + inArray(drives.id, driveIds), + eq(drives.isTrashed, false) + )) : []; + // Gather task data const [tasksOverdue] = await db .select({ count: count() }) @@ -329,10 +349,10 @@ async function generatePulseForUser(userId: string, now: Date): Promise { if (m.senderName) uniqueSenders.add(m.senderName); recentMessages.push({ from: m.senderName || 'Someone', - preview: m.content?.substring(0, 100), + preview: m.content?.substring(0, 300), // Longer preview for context }); }); - recentSenders.push(...Array.from(uniqueSenders).slice(0, 3)); + recentSenders.push(...Array.from(uniqueSenders).slice(0, 5)); } } @@ -453,35 +473,71 @@ async function generatePulseForUser(userId: string, now: Date): Promise { }); } - // Collaborator activity + // Extended time window for "what you missed" context (24 hours) + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Recent activity by collaborators - get more detail about what's happening const collaboratorActivity = await db .select({ actorName: activityLogs.actorDisplayName, operation: activityLogs.operation, + resourceType: activityLogs.resourceType, resourceTitle: activityLogs.resourceTitle, + driveId: activityLogs.driveId, + timestamp: activityLogs.timestamp, }) .from(activityLogs) .where( and( driveIds.length > 0 ? inArray(activityLogs.driveId, driveIds) : sql`false`, ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twoHoursAgo) + gte(activityLogs.timestamp, twentyFourHoursAgo) ) ) .orderBy(desc(activityLogs.timestamp)) - .limit(10); + .limit(20); + // Build rich activity summaries - group by person and what they're working on const collaboratorNames = new Set(); const recentOperations: string[] = []; + const workingOn: { person: string; page: string; driveName?: string; action: string }[] = []; + collaboratorActivity.forEach(a => { if (a.actorName) collaboratorNames.add(a.actorName); - if (a.resourceTitle && recentOperations.length < 3) { + if (a.resourceTitle && a.resourceType === 'page') { + const driveName = driveDetails.find(d => d.id === a.driveId)?.name; + workingOn.push({ + person: a.actorName || 'Someone', + page: a.resourceTitle, + driveName, + action: a.operation, + }); + } + if (a.resourceTitle && recentOperations.length < 5) { recentOperations.push(`${a.actorName || 'Someone'} ${a.operation}d "${a.resourceTitle}"`); } }); - // Build context data + // Dedupe and limit workingOn to most relevant + const uniqueWorkingOn = workingOn.reduce((acc, curr) => { + const key = `${curr.person}-${curr.page}`; + if (!acc.some(x => `${x.person}-${x.page}` === key)) { + acc.push(curr); + } + return acc; + }, [] as typeof workingOn).slice(0, 5); + + // Build context data with rich workspace context const contextData: PulseSummaryContextData = { + // Workspace context - what projects/drives exist + workspace: { + drives: driveDetails.map(d => ({ + name: d.name, + description: d.description?.substring(0, 200) || undefined, + })), + }, + // What people are actively working on (most valuable context) + workingOn: uniqueWorkingOn, tasks: { dueToday: tasksDueToday?.count ?? 0, dueThisWeek: tasksDueThisWeek?.count ?? 0, @@ -512,18 +568,18 @@ async function generatePulseForUser(userId: string, now: Date): Promise { page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', })), - contentChanges: recentlyUpdatedPages.slice(0, 3).map(p => ({ + contentChanges: recentlyUpdatedPages.slice(0, 5).map(p => ({ page: p.title, by: p.updatedBy, })), pages: { updatedToday: pagesUpdatedToday, updatedThisWeek: pagesUpdatedThisWeek, - recentlyUpdated: recentlyUpdatedPages.slice(0, 3), + recentlyUpdated: recentlyUpdatedPages.slice(0, 5), }, activity: { - collaboratorNames: Array.from(collaboratorNames).slice(0, 5), - recentOperations: recentOperations.slice(0, 3), + collaboratorNames: Array.from(collaboratorNames).slice(0, 8), + recentOperations: recentOperations.slice(0, 5), }, }; @@ -534,15 +590,15 @@ async function generatePulseForUser(userId: string, now: Date): Promise { else if (hour < 17) timeOfDay = 'afternoon'; else timeOfDay = 'evening'; - // Build prompt - const userPrompt = `Generate a brief pulse summary for ${userName}. + // Build prompt for AI - focus on what's interesting, not a data dump + const userPrompt = `Generate a friendly workspace update for ${userName}. -Time: ${timeOfDay} +Time of day: ${timeOfDay} -Context data: +Here's what's been happening in their workspace: ${JSON.stringify(contextData, null, 2)} -Create a 2-4 sentence summary that highlights the most important information. Start with a brief, appropriate greeting.`; +Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working on what, any messages that need attention, or things that might be helpful to know. If nothing much is happening, just give a warm greeting. Don't list everything - pick the 1-2 most relevant things.`; // Get AI provider const providerResult = await createAIProvider(userId, { diff --git a/apps/web/src/app/api/pulse/generate/route.ts b/apps/web/src/app/api/pulse/generate/route.ts index b3c16192a..1dadb23eb 100644 --- a/apps/web/src/app/api/pulse/generate/route.ts +++ b/apps/web/src/app/api/pulse/generate/route.ts @@ -12,6 +12,7 @@ import { directMessages, dmConversations, pages, + drives, driveMembers, activityLogs, users, @@ -36,35 +37,41 @@ import { loggers } from '@pagespace/lib/server'; const AUTH_OPTIONS = { allow: ['session'] as const }; // System prompt for generating pulse summaries -const PULSE_SYSTEM_PROMPT = `You are a workspace assistant generating a brief, personalized activity summary. - -Create a SHORT summary (2-4 sentences) telling the user what specifically needs attention. - -RULES: -- ALWAYS name specific tasks, pages, or people - never just counts -- If there are overdue tasks, name them: "'Finalize budget' and 'Review proposal' are overdue" -- High-priority overdue tasks should be mentioned first -- For messages, summarize content: "Noah asked about the pricing update" not "Noah messaged you" -- For mentions: "Sarah mentioned you in Q1 Planning" -- For shares: "Noah shared 'Product Roadmap' with you" -- For content changes: Describe WHAT changed: "Sarah updated Product Pricing" with who made the change -- NEVER mention categories with zero items - omit them entirely -- NEVER say "no messages", "nothing new", or similar - just skip empty categories -- Be direct and specific, like a colleague giving a quick heads-up -- Include a brief time-appropriate greeting - -PRIORITY ORDER (mention most important first): -1. Overdue high-priority tasks -2. Pages shared with you / mentions -3. Meaningful content changes by collaborators -4. Unread messages with context - -Do NOT: -- Use excessive exclamation marks or emojis -- Be overly enthusiastic -- List every single activity -- Include generic filler content -- Mention counts without naming the items`; +const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion giving the user a natural, conversational update about their workspace. + +Your job is to tell them something INTERESTING or USEFUL about what's happening - not give them a robotic status report. + +TONE: +- Like a thoughtful colleague catching you up over coffee +- Natural and conversational, not a bullet-point readout +- If it's a quiet day, just say hi warmly - don't manufacture urgency +- If there's interesting activity, share what's actually happening + +WHAT TO FOCUS ON (pick what's most interesting, not everything): +- What are people actually working on? "Noah's been making progress on the Product Roadmap" +- Interesting updates: "Sarah added some new ideas to the Q1 Planning doc" +- Meaningful messages: If someone asked a specific question, mention it +- Recent shares/mentions: "Alex shared the Budget proposal with you" +- If someone left you a message, summarize WHAT they said, not just that they messaged + +WHAT TO AVOID: +- Robotic stat dumps: "You have 5 tasks, 26 pages updated" - USELESS +- Vague summaries: "activity has occurred in your workspace" - BORING +- Task-list mentality: Don't treat this as a to-do reminder +- Counts without context: Never say "X tasks" or "X pages" without specifics +- Filler when there's nothing: If it's quiet, a simple warm greeting is fine + +EXAMPLES OF GOOD SUMMARIES: +- "Hey! Noah's been working on the Product Roadmap this morning - looks like he added the Q2 timeline. Sarah also dropped some comments on your Budget doc." +- "Good afternoon! Alex shared the Marketing Brief with you, and it looks pretty comprehensive. Also, Sarah was asking about the launch date in your DMs." +- "Morning! Things are quiet right now. The team was active yesterday on the Sprint Planning doc if you want to catch up." + +EXAMPLES OF BAD SUMMARIES: +- "Good morning. You have 3 tasks due today and 12 pages were updated this week." (robotic, no context) +- "Evening, Jonathan. Activity has occurred in your drives." (vague, useless) +- "You've completed 5 tasks this week, though no specific details were provided." (never admit lack of context - just omit) + +Keep it to 2-3 natural sentences. Start with a brief, time-appropriate greeting.`; export async function POST(req: Request) { const auth = await authenticateRequestWithOptions(req, AUTH_OPTIONS); @@ -87,13 +94,26 @@ export async function POST(req: Request) { const startOfWeek = new Date(startOfToday); startOfWeek.setDate(startOfWeek.getDate() - dayOfWeek); - // Get user's drives + // Get user's drives with full context const userDrives = await db .select({ driveId: driveMembers.driveId }) .from(driveMembers) .where(eq(driveMembers.userId, userId)); const driveIds = userDrives.map(d => d.driveId); + // Get drive details for workspace context + const driveDetails = driveIds.length > 0 ? await db + .select({ + id: drives.id, + name: drives.name, + description: drives.drivePrompt, // Can contain project description + }) + .from(drives) + .where(and( + inArray(drives.id, driveIds), + eq(drives.isTrashed, false) + )) : []; + // Task data const [tasksOverdue] = await db .select({ count: count() }) @@ -237,10 +257,10 @@ export async function POST(req: Request) { if (m.senderName) uniqueSenders.add(m.senderName); recentMessages.push({ from: m.senderName || 'Someone', - preview: m.content?.substring(0, 100), + preview: m.content?.substring(0, 300), // Longer preview for context }); }); - recentSenders.push(...Array.from(uniqueSenders).slice(0, 3)); + recentSenders.push(...Array.from(uniqueSenders).slice(0, 5)); } } @@ -361,35 +381,71 @@ export async function POST(req: Request) { }); } - // Recent activity by collaborators + // Extended time window for "what you missed" context (24 hours) + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Recent activity by collaborators - get more detail about what's happening const collaboratorActivity = await db .select({ actorName: activityLogs.actorDisplayName, operation: activityLogs.operation, + resourceType: activityLogs.resourceType, resourceTitle: activityLogs.resourceTitle, + driveId: activityLogs.driveId, + timestamp: activityLogs.timestamp, }) .from(activityLogs) .where( and( driveIds.length > 0 ? inArray(activityLogs.driveId, driveIds) : sql`false`, ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twoHoursAgo) + gte(activityLogs.timestamp, twentyFourHoursAgo) ) ) .orderBy(desc(activityLogs.timestamp)) - .limit(10); + .limit(20); + // Build rich activity summaries - group by person and what they're working on const collaboratorNames = new Set(); const recentOperations: string[] = []; + const workingOn: { person: string; page: string; driveName?: string; action: string }[] = []; + collaboratorActivity.forEach(a => { if (a.actorName) collaboratorNames.add(a.actorName); - if (a.resourceTitle && recentOperations.length < 3) { + if (a.resourceTitle && a.resourceType === 'page') { + const driveName = driveDetails.find(d => d.id === a.driveId)?.name; + workingOn.push({ + person: a.actorName || 'Someone', + page: a.resourceTitle, + driveName, + action: a.operation, + }); + } + if (a.resourceTitle && recentOperations.length < 5) { recentOperations.push(`${a.actorName || 'Someone'} ${a.operation}d "${a.resourceTitle}"`); } }); - // Build context data + // Dedupe and limit workingOn to most relevant + const uniqueWorkingOn = workingOn.reduce((acc, curr) => { + const key = `${curr.person}-${curr.page}`; + if (!acc.some(x => `${x.person}-${x.page}` === key)) { + acc.push(curr); + } + return acc; + }, [] as typeof workingOn).slice(0, 5); + + // Build context data with rich workspace context const contextData: PulseSummaryContextData = { + // Workspace context - what projects/drives exist + workspace: { + drives: driveDetails.map(d => ({ + name: d.name, + description: d.description?.substring(0, 200) || undefined, + })), + }, + // What people are actively working on (most valuable context) + workingOn: uniqueWorkingOn, tasks: { dueToday: tasksDueToday?.count ?? 0, dueThisWeek: tasksDueThisWeek?.count ?? 0, @@ -420,18 +476,18 @@ export async function POST(req: Request) { page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', })), - contentChanges: recentlyUpdatedPages.slice(0, 3).map(p => ({ + contentChanges: recentlyUpdatedPages.slice(0, 5).map(p => ({ page: p.title, by: p.updatedBy, })), pages: { updatedToday: pagesUpdatedToday, updatedThisWeek: pagesUpdatedThisWeek, - recentlyUpdated: recentlyUpdatedPages.slice(0, 3), + recentlyUpdated: recentlyUpdatedPages.slice(0, 5), }, activity: { - collaboratorNames: Array.from(collaboratorNames).slice(0, 5), - recentOperations: recentOperations.slice(0, 3), + collaboratorNames: Array.from(collaboratorNames).slice(0, 8), + recentOperations: recentOperations.slice(0, 5), }, }; @@ -442,15 +498,15 @@ export async function POST(req: Request) { else if (hour < 17) timeOfDay = 'afternoon'; else timeOfDay = 'evening'; - // Build prompt for AI - const userPrompt = `Generate a brief pulse summary for ${userName}. + // Build prompt for AI - focus on what's interesting, not a data dump + const userPrompt = `Generate a friendly workspace update for ${userName}. -Time: ${timeOfDay} +Time of day: ${timeOfDay} -Context data: +Here's what's been happening in their workspace: ${JSON.stringify(contextData, null, 2)} -Create a 2-4 sentence summary that highlights the most important information. Start with a brief, appropriate greeting.`; +Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working on what, any messages that need attention, or things that might be helpful to know. If nothing much is happening, just give a warm greeting. Don't list everything - pick the 1-2 most relevant things.`; // Get AI provider (use standard model) const providerResult = await createAIProvider(userId, { diff --git a/packages/db/src/schema/dashboard.ts b/packages/db/src/schema/dashboard.ts index 5bf4860f3..4f9a8d106 100644 --- a/packages/db/src/schema/dashboard.ts +++ b/packages/db/src/schema/dashboard.ts @@ -41,6 +41,12 @@ export const pulseSummaries = pgTable('pulse_summaries', { // Context data used to generate the summary (for debugging/transparency) contextData: jsonb('contextData').$type<{ + // Workspace context - drives and projects + workspace?: { + drives: { name: string; description?: string }[]; + }; + // What people are actively working on (most valuable context) + workingOn?: { person: string; page: string; driveName?: string; action: string }[]; tasks: { dueToday: number; dueThisWeek: number; From c3287a2549520ed07005385540da542c45806665 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 18:41:00 +0000 Subject: [PATCH 2/3] feat(pulse): add deep workspace context with page content and aggregated activity Major enhancement to Pulse data gathering to give the AI a real understanding of the workspace instead of just stats: **Rich Context Now Includes:** - Actual page content snippets (1500 chars) for recently active pages - Aggregated activity by person+page (shows "Noah made 15 edits" not 15 separate logs) - Full DM message content (no truncation) - Page chat messages from collaborators - Team member lists per drive - 48-hour activity window for better "what you missed" context **Intelligent Aggregation:** - Groups autosave edits into meaningful summaries - Separates own activity from colleagues' activity - Sorts by engagement level (most active pages first) - Dedupes redundant activity entries **New System Prompt:** - Instructs AI to use page content to understand WHAT people are working on - Encourages connecting dots between related activities - Examples show specific, contextual summaries like "Noah added the Q2 timeline" The AI now has 10-20k tokens of real context to work with, enabling summaries that feel like a colleague who's been paying attention. https://claude.ai/code/session_01MzgqCneugAdEMYGZcb1iub --- apps/web/src/app/api/pulse/cron/route.ts | 686 +++++++++--------- apps/web/src/app/api/pulse/generate/route.ts | 718 ++++++++++--------- 2 files changed, 736 insertions(+), 668 deletions(-) diff --git a/apps/web/src/app/api/pulse/cron/route.ts b/apps/web/src/app/api/pulse/cron/route.ts index ec12cc29d..f17c2ee07 100644 --- a/apps/web/src/app/api/pulse/cron/route.ts +++ b/apps/web/src/app/api/pulse/cron/route.ts @@ -18,8 +18,8 @@ import { activityLogs, pulseSummaries, userMentions, - notifications, pagePermissions, + chatMessages, eq, and, or, @@ -27,53 +27,45 @@ import { gte, ne, desc, - sql, - count, inArray, isNull, } from '@pagespace/db'; -import type { PulseSummaryContextData } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; // This endpoint should be protected by a cron secret in production const CRON_SECRET = process.env.CRON_SECRET; // System prompt for generating pulse summaries -const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion giving the user a natural, conversational update about their workspace. +const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion who deeply understands the user's workspace and can give them genuinely useful, contextual updates. -Your job is to tell them something INTERESTING or USEFUL about what's happening - not give them a robotic status report. +You have access to RICH context about what's happening - actual page content, full messages, aggregated activity patterns, and more. Use this to give MEANINGFUL updates, not robotic summaries. -TONE: -- Like a thoughtful colleague catching you up over coffee -- Natural and conversational, not a bullet-point readout -- If it's a quiet day, just say hi warmly - don't manufacture urgency -- If there's interesting activity, share what's actually happening - -WHAT TO FOCUS ON (pick what's most interesting, not everything): -- What are people actually working on? "Noah's been making progress on the Product Roadmap" -- Interesting updates: "Sarah added some new ideas to the Q1 Planning doc" -- Meaningful messages: If someone asked a specific question, mention it -- Recent shares/mentions: "Alex shared the Budget proposal with you" -- If someone left you a message, summarize WHAT they said, not just that they messaged +YOUR JOB: +- Tell them something they'd actually want to know +- Be specific about WHAT people are working on (you can see page content!) +- If someone messaged them, tell them what the message actually says +- Notice interesting patterns: "Noah's been really focused on the roadmap today" +- Connect the dots: "Sarah's updates to the Budget doc might be related to what Alex was asking about" -WHAT TO AVOID: -- Robotic stat dumps: "You have 5 tasks, 26 pages updated" - USELESS -- Vague summaries: "activity has occurred in your workspace" - BORING -- Task-list mentality: Don't treat this as a to-do reminder -- Counts without context: Never say "X tasks" or "X pages" without specifics -- Filler when there's nothing: If it's quiet, a simple warm greeting is fine +TONE: +- Like a thoughtful colleague who's been paying attention +- Natural and conversational +- Warm but not fake-enthusiastic +- If it's quiet, just say hi - don't manufacture activity -EXAMPLES OF GOOD SUMMARIES: -- "Hey! Noah's been working on the Product Roadmap this morning - looks like he added the Q2 timeline. Sarah also dropped some comments on your Budget doc." -- "Good afternoon! Alex shared the Marketing Brief with you, and it looks pretty comprehensive. Also, Sarah was asking about the launch date in your DMs." -- "Morning! Things are quiet right now. The team was active yesterday on the Sprint Planning doc if you want to catch up." +EXAMPLES OF GREAT SUMMARIES: +- "Morning! Noah's been heads-down on the Product Roadmap - he's added a whole new Q2 section with timeline estimates. Also, Sarah left you a DM asking if you've reviewed the pricing changes yet." +- "Hey! Looks like the team's been active on Sprint Planning today. Alex added some notes about the API migration, and there's a thread going in the comments about the timeline." +- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier if you want to take a look." +- "Evening! Quick catch-up: Noah finished that Q4 Projections doc you were both discussing. The final revenue numbers look different from the draft." -EXAMPLES OF BAD SUMMARIES: -- "Good morning. You have 3 tasks due today and 12 pages were updated this week." (robotic, no context) -- "Evening, Jonathan. Activity has occurred in your drives." (vague, useless) -- "You've completed 5 tasks this week, though no specific details were provided." (never admit lack of context - just omit) +WHAT TO AVOID: +- "You have X tasks and Y pages were updated" - useless without specifics +- "Activity occurred in your workspace" - vague nonsense +- Listing every single thing that happened - pick what matters +- Admitting you don't have information - just focus on what you DO know -Keep it to 2-3 natural sentences. Start with a brief, time-appropriate greeting.`; +Keep it to 2-4 natural sentences. Be genuinely helpful.`; export async function POST(req: Request) { // Require cron secret - fail-closed for security @@ -177,23 +169,22 @@ async function generatePulseForUser(userId: string, now: Date): Promise { if (!user) throw new Error('User not found'); const userName = user.name || user.email?.split('@')[0] || 'there'; - const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); - const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); - // Week boundaries - const dayOfWeek = now.getDay(); - const startOfWeek = new Date(startOfToday); - startOfWeek.setDate(startOfWeek.getDate() - dayOfWeek); + // Time windows + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - // Get user's drives with full context + // Get user's drives const userDrives = await db .select({ driveId: driveMembers.driveId }) .from(driveMembers) .where(eq(driveMembers.userId, userId)); const driveIds = userDrives.map(d => d.driveId); - // Get drive details for workspace context + // ======================================== + // 1. WORKSPACE CONTEXT - Drives and team members + // ======================================== const driveDetails = driveIds.length > 0 ? await db .select({ id: drives.id, @@ -206,96 +197,122 @@ async function generatePulseForUser(userId: string, now: Date): Promise { eq(drives.isTrashed, false) )) : []; - // Gather task data - const [tasksOverdue] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - lt(taskItems.dueDate, startOfToday) - ) - ); + // Get team members for each drive + const teamMembers = driveIds.length > 0 ? await db + .select({ + driveId: driveMembers.driveId, + userName: users.name, + userEmail: users.email, + }) + .from(driveMembers) + .leftJoin(users, eq(users.id, driveMembers.userId)) + .where(and( + inArray(driveMembers.driveId, driveIds), + ne(driveMembers.userId, userId) + )) : []; - // Get overdue task details with priority - const overdueTasksList = await db - .select({ title: taskItems.title, priority: taskItems.priority }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - lt(taskItems.dueDate, startOfToday) - ) - ) - .orderBy(desc(taskItems.priority), taskItems.dueDate) - .limit(5); + const teamByDrive = teamMembers.reduce((acc, m) => { + if (!acc[m.driveId]) acc[m.driveId] = []; + acc[m.driveId].push(m.userName || m.userEmail?.split('@')[0] || 'Unknown'); + return acc; + }, {} as Record); - const [tasksDueToday] = await db - .select({ count: count() }) - .from(taskItems) + // ======================================== + // 2. AGGREGATED ACTIVITY + // ======================================== + const rawActivity = driveIds.length > 0 ? await db + .select({ + actorId: activityLogs.userId, + actorName: activityLogs.actorDisplayName, + operation: activityLogs.operation, + resourceType: activityLogs.resourceType, + resourceId: activityLogs.resourceId, + resourceTitle: activityLogs.resourceTitle, + driveId: activityLogs.driveId, + timestamp: activityLogs.timestamp, + }) + .from(activityLogs) .where( and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday), - lt(taskItems.dueDate, endOfToday) + inArray(activityLogs.driveId, driveIds), + gte(activityLogs.timestamp, fortyEightHoursAgo) ) - ); + ) + .orderBy(desc(activityLogs.timestamp)) + .limit(200) : []; + + const activityByPersonPage: Record; + isOwnActivity: boolean; + }> = {}; + + rawActivity.forEach(a => { + if (a.resourceType !== 'page' || !a.resourceId) return; + const key = `${a.actorId}-${a.resourceId}`; + const driveName = driveDetails.find(d => d.id === a.driveId)?.name || 'Unknown'; + + if (!activityByPersonPage[key]) { + activityByPersonPage[key] = { + person: a.actorName || 'Someone', + pageId: a.resourceId, + pageTitle: a.resourceTitle || 'Untitled', + driveName, + editCount: 0, + lastEdit: a.timestamp, + operations: new Set(), + isOwnActivity: a.actorId === userId, + }; + } + activityByPersonPage[key].editCount++; + activityByPersonPage[key].operations.add(a.operation); + if (a.timestamp > activityByPersonPage[key].lastEdit) { + activityByPersonPage[key].lastEdit = a.timestamp; + } + }); - const [tasksDueThisWeek] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday), - lt(taskItems.dueDate, new Date(startOfWeek.getTime() + 7 * 24 * 60 * 60 * 1000)) - ) - ); + const aggregatedActivity = Object.values(activityByPersonPage) + .sort((a, b) => b.editCount - a.editCount) + .slice(0, 15); - const [tasksCompletedThisWeek] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - eq(taskItems.status, 'completed'), - gte(taskItems.completedAt, startOfWeek) - ) - ); + const othersActivity = aggregatedActivity.filter(a => !a.isOwnActivity); + const ownActivity = aggregatedActivity.filter(a => a.isOwnActivity); - // Recently completed tasks - const recentlyCompletedTasks = await db - .select({ title: taskItems.title }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - eq(taskItems.status, 'completed'), - gte(taskItems.completedAt, new Date(now.getTime() - 24 * 60 * 60 * 1000)) - ) - ) - .orderBy(desc(taskItems.completedAt)) - .limit(3); + // ======================================== + // 3. PAGE CONTENT + // ======================================== + const activePageIds = othersActivity.slice(0, 8).map(a => a.pageId); - // Upcoming tasks - const upcomingTasks = await db - .select({ title: taskItems.title }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday) - ) - ) - .orderBy(taskItems.dueDate) - .limit(3); + const pageContents = activePageIds.length > 0 ? await db + .select({ + id: pages.id, + title: pages.title, + content: pages.content, + type: pages.type, + updatedAt: pages.updatedAt, + }) + .from(pages) + .where(and( + inArray(pages.id, activePageIds), + eq(pages.isTrashed, false) + )) : []; - // Unread messages + const pageSnippets = pageContents.map(p => ({ + id: p.id, + title: p.title, + type: p.type, + contentPreview: p.content?.substring(0, 1500) || '', + updatedAt: p.updatedAt, + })); + + // ======================================== + // 4. DIRECT MESSAGES + // ======================================== const userConversations = await db .select({ id: dmConversations.id }) .from(dmConversations) @@ -306,98 +323,107 @@ async function generatePulseForUser(userId: string, now: Date): Promise { ) ); - let unreadCount = 0; - const recentSenders: string[] = []; - const recentMessages: { from: string; preview?: string }[] = []; + let unreadDMs: { from: string; content: string; sentAt: Date }[] = []; if (userConversations.length > 0) { const conversationIds = userConversations.map(c => c.id); - const [unreadResult] = await db - .select({ count: count() }) + const unreadMessagesList = await db + .select({ + senderName: users.name, + senderEmail: users.email, + content: directMessages.content, + createdAt: directMessages.createdAt, + }) .from(directMessages) + .leftJoin(users, eq(users.id, directMessages.senderId)) .where( and( inArray(directMessages.conversationId, conversationIds), ne(directMessages.senderId, userId), eq(directMessages.isRead, false) ) - ); - unreadCount = unreadResult?.count ?? 0; - - // Get recent unread messages with content preview - if (unreadCount > 0) { - const unreadMessagesList = await db - .select({ - senderId: directMessages.senderId, - senderName: users.name, - content: directMessages.content, - }) - .from(directMessages) - .leftJoin(users, eq(users.id, directMessages.senderId)) - .where( - and( - inArray(directMessages.conversationId, conversationIds), - ne(directMessages.senderId, userId), - eq(directMessages.isRead, false) - ) - ) - .orderBy(desc(directMessages.createdAt)) - .limit(3); - - const uniqueSenders = new Set(); - unreadMessagesList.forEach(m => { - if (m.senderName) uniqueSenders.add(m.senderName); - recentMessages.push({ - from: m.senderName || 'Someone', - preview: m.content?.substring(0, 300), // Longer preview for context - }); - }); - recentSenders.push(...Array.from(uniqueSenders).slice(0, 5)); - } + ) + .orderBy(desc(directMessages.createdAt)) + .limit(10); + + unreadDMs = unreadMessagesList.map(m => ({ + from: m.senderName || m.senderEmail?.split('@')[0] || 'Someone', + content: m.content || '', + sentAt: m.createdAt, + })); } - // Get recent @mentions of the user - const recentMentions = await db + // ======================================== + // 5. PAGE CHAT MESSAGES + // ======================================== + const recentPageChats = driveIds.length > 0 ? await db .select({ - mentionedByName: users.name, + pageId: chatMessages.pageId, pageTitle: pages.title, + senderName: users.name, + senderEmail: users.email, + content: chatMessages.content, + role: chatMessages.role, + createdAt: chatMessages.createdAt, }) - .from(userMentions) - .leftJoin(users, eq(users.id, userMentions.mentionedByUserId)) - .leftJoin(pages, eq(pages.id, userMentions.sourcePageId)) + .from(chatMessages) + .leftJoin(pages, eq(pages.id, chatMessages.pageId)) + .leftJoin(users, eq(users.id, chatMessages.userId)) .where( and( - eq(userMentions.targetUserId, userId), - gte(userMentions.createdAt, twoHoursAgo) + inArray(pages.driveId, driveIds), + eq(chatMessages.role, 'user'), + eq(chatMessages.isActive, true), + gte(chatMessages.createdAt, twentyFourHoursAgo), + ne(chatMessages.userId, userId) ) ) - .orderBy(desc(userMentions.createdAt)) - .limit(3); + .orderBy(desc(chatMessages.createdAt)) + .limit(15) : []; + + const chatsByPage = recentPageChats.reduce((acc, chat) => { + const key = chat.pageId; + if (!acc[key]) { + acc[key] = { + pageTitle: chat.pageTitle || 'Untitled', + messages: [], + }; + } + acc[key].messages.push({ + from: chat.senderName || chat.senderEmail?.split('@')[0] || 'Someone', + content: chat.content?.substring(0, 500) || '', + sentAt: chat.createdAt, + }); + return acc; + }, {} as Record); - // Get unread notifications - const unreadNotifications = await db + // ======================================== + // 6. MENTIONS & SHARES + // ======================================== + const recentMentions = await db .select({ - type: notifications.type, - triggeredByName: users.name, + mentionedByName: users.name, pageTitle: pages.title, + createdAt: userMentions.createdAt, }) - .from(notifications) - .leftJoin(users, eq(users.id, notifications.triggeredByUserId)) - .leftJoin(pages, eq(pages.id, notifications.pageId)) + .from(userMentions) + .leftJoin(users, eq(users.id, userMentions.mentionedByUserId)) + .leftJoin(pages, eq(pages.id, userMentions.sourcePageId)) .where( and( - eq(notifications.userId, userId), - eq(notifications.isRead, false) + eq(userMentions.targetUserId, userId), + gte(userMentions.createdAt, fortyEightHoursAgo) ) ) - .orderBy(desc(notifications.createdAt)) + .orderBy(desc(userMentions.createdAt)) .limit(5); - // Get pages recently shared with user const recentShares = await db .select({ pageTitle: pages.title, + pageContent: pages.content, sharedByName: users.name, + grantedAt: pagePermissions.grantedAt, }) .from(pagePermissions) .leftJoin(pages, eq(pages.id, pagePermissions.pageId)) @@ -405,182 +431,133 @@ async function generatePulseForUser(userId: string, now: Date): Promise { .where( and( eq(pagePermissions.userId, userId), - gte(pagePermissions.grantedAt, twoHoursAgo) + gte(pagePermissions.grantedAt, fortyEightHoursAgo) ) ) .orderBy(desc(pagePermissions.grantedAt)) - .limit(3); - - // Pages updated - let pagesUpdatedToday = 0; - let pagesUpdatedThisWeek = 0; - const recentlyUpdatedPages: { title: string; updatedBy: string }[] = []; - - if (driveIds.length > 0) { - const [todayResult] = await db - .select({ count: count() }) - .from(pages) - .where( - and( - inArray(pages.driveId, driveIds), - eq(pages.isTrashed, false), - gte(pages.updatedAt, startOfToday) - ) - ); - pagesUpdatedToday = todayResult?.count ?? 0; + .limit(5); - const [weekResult] = await db - .select({ count: count() }) - .from(pages) - .where( - and( - inArray(pages.driveId, driveIds), - eq(pages.isTrashed, false), - gte(pages.updatedAt, startOfWeek) - ) - ); - pagesUpdatedThisWeek = weekResult?.count ?? 0; + // ======================================== + // 7. TASKS + // ======================================== + const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); - // Recent page updates by others - const recentUpdates = await db - .select({ - pageTitle: pages.title, - actorName: activityLogs.actorDisplayName, - }) - .from(activityLogs) - .leftJoin(pages, eq(pages.id, activityLogs.pageId)) - .where( - and( - inArray(activityLogs.driveId, driveIds), - eq(activityLogs.operation, 'update'), - eq(activityLogs.resourceType, 'page'), - ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twoHoursAgo) - ) + const overdueTasks = await db + .select({ + title: taskItems.title, + priority: taskItems.priority, + dueDate: taskItems.dueDate, + }) + .from(taskItems) + .where( + and( + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + ne(taskItems.status, 'completed'), + lt(taskItems.dueDate, startOfToday) ) - .orderBy(desc(activityLogs.timestamp)) - .limit(5); - - const seenPages = new Set(); - recentUpdates.forEach(u => { - if (u.pageTitle && !seenPages.has(u.pageTitle)) { - seenPages.add(u.pageTitle); - recentlyUpdatedPages.push({ - title: u.pageTitle, - updatedBy: u.actorName || 'Someone', - }); - } - }); - } - - // Extended time window for "what you missed" context (24 hours) - const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + ) + .orderBy(desc(taskItems.priority), taskItems.dueDate) + .limit(10); - // Recent activity by collaborators - get more detail about what's happening - const collaboratorActivity = await db + const todayTasks = await db .select({ - actorName: activityLogs.actorDisplayName, - operation: activityLogs.operation, - resourceType: activityLogs.resourceType, - resourceTitle: activityLogs.resourceTitle, - driveId: activityLogs.driveId, - timestamp: activityLogs.timestamp, + title: taskItems.title, + priority: taskItems.priority, }) - .from(activityLogs) + .from(taskItems) .where( and( - driveIds.length > 0 ? inArray(activityLogs.driveId, driveIds) : sql`false`, - ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twentyFourHoursAgo) + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + ne(taskItems.status, 'completed'), + gte(taskItems.dueDate, startOfToday), + lt(taskItems.dueDate, endOfToday) ) ) - .orderBy(desc(activityLogs.timestamp)) - .limit(20); - - // Build rich activity summaries - group by person and what they're working on - const collaboratorNames = new Set(); - const recentOperations: string[] = []; - const workingOn: { person: string; page: string; driveName?: string; action: string }[] = []; - - collaboratorActivity.forEach(a => { - if (a.actorName) collaboratorNames.add(a.actorName); - if (a.resourceTitle && a.resourceType === 'page') { - const driveName = driveDetails.find(d => d.id === a.driveId)?.name; - workingOn.push({ - person: a.actorName || 'Someone', - page: a.resourceTitle, - driveName, - action: a.operation, - }); - } - if (a.resourceTitle && recentOperations.length < 5) { - recentOperations.push(`${a.actorName || 'Someone'} ${a.operation}d "${a.resourceTitle}"`); - } - }); + .orderBy(desc(taskItems.priority)) + .limit(10); - // Dedupe and limit workingOn to most relevant - const uniqueWorkingOn = workingOn.reduce((acc, curr) => { - const key = `${curr.person}-${curr.page}`; - if (!acc.some(x => `${x.person}-${x.page}` === key)) { - acc.push(curr); - } - return acc; - }, [] as typeof workingOn).slice(0, 5); + const recentlyCompletedTasks = await db + .select({ title: taskItems.title, completedAt: taskItems.completedAt }) + .from(taskItems) + .where( + and( + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + eq(taskItems.status, 'completed'), + gte(taskItems.completedAt, twentyFourHoursAgo) + ) + ) + .orderBy(desc(taskItems.completedAt)) + .limit(5); - // Build context data with rich workspace context - const contextData: PulseSummaryContextData = { - // Workspace context - what projects/drives exist + // ======================================== + // BUILD THE RICH CONTEXT OBJECT + // ======================================== + const contextData = { + userName, workspace: { drives: driveDetails.map(d => ({ name: d.name, - description: d.description?.substring(0, 200) || undefined, + description: d.description || undefined, + teamMembers: teamByDrive[d.id] || [], })), }, - // What people are actively working on (most valuable context) - workingOn: uniqueWorkingOn, - tasks: { - dueToday: tasksDueToday?.count ?? 0, - dueThisWeek: tasksDueThisWeek?.count ?? 0, - overdue: tasksOverdue?.count ?? 0, - completedThisWeek: tasksCompletedThisWeek?.count ?? 0, - recentlyCompleted: recentlyCompletedTasks.map(t => t.title).filter((t): t is string => !!t), - upcoming: upcomingTasks.map(t => t.title).filter((t): t is string => !!t), - overdueItems: overdueTasksList.map(t => ({ - title: t.title ?? '', - priority: t.priority, - })).filter(t => t.title), - }, - messages: { - unreadCount, - recentSenders, - recentMessages, - }, + colleagueActivity: othersActivity.map(a => ({ + person: a.person, + page: a.pageTitle, + drive: a.driveName, + editCount: a.editCount, + actions: Array.from(a.operations), + lastActive: a.lastEdit.toISOString(), + })), + activePageContent: pageSnippets.map(p => ({ + title: p.title, + type: p.type, + preview: p.contentPreview, + })), + directMessages: unreadDMs.map(m => ({ + from: m.from, + message: m.content, + sentAt: m.sentAt.toISOString(), + })), + pageDiscussions: Object.entries(chatsByPage).map(([_, data]) => ({ + page: data.pageTitle, + messages: data.messages.map(m => ({ + from: m.from, + message: m.content, + sentAt: m.sentAt.toISOString(), + })), + })), mentions: recentMentions.map(m => ({ by: m.mentionedByName || 'Someone', inPage: m.pageTitle || 'a page', - })), - notifications: unreadNotifications.map(n => ({ - type: n.type, - from: n.triggeredByName, - page: n.pageTitle, + when: m.createdAt.toISOString(), })), sharedWithYou: recentShares.map(s => ({ page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', + preview: s.pageContent?.substring(0, 500) || '', + when: s.grantedAt?.toISOString(), })), - contentChanges: recentlyUpdatedPages.slice(0, 5).map(p => ({ - page: p.title, - by: p.updatedBy, - })), - pages: { - updatedToday: pagesUpdatedToday, - updatedThisWeek: pagesUpdatedThisWeek, - recentlyUpdated: recentlyUpdatedPages.slice(0, 5), - }, - activity: { - collaboratorNames: Array.from(collaboratorNames).slice(0, 8), - recentOperations: recentOperations.slice(0, 5), + tasks: { + overdue: overdueTasks.map(t => ({ + title: t.title, + priority: t.priority, + dueDate: t.dueDate?.toISOString(), + })), + dueToday: todayTasks.map(t => ({ + title: t.title, + priority: t.priority, + })), + recentlyCompleted: recentlyCompletedTasks.map(t => ({ + title: t.title, + completedAt: t.completedAt?.toISOString(), + })), }, + ownRecentActivity: ownActivity.slice(0, 5).map(a => ({ + page: a.pageTitle, + editCount: a.editCount, + lastActive: a.lastEdit.toISOString(), + })), }; // Determine time of day @@ -590,15 +567,19 @@ async function generatePulseForUser(userId: string, now: Date): Promise { else if (hour < 17) timeOfDay = 'afternoon'; else timeOfDay = 'evening'; - // Build prompt for AI - focus on what's interesting, not a data dump - const userPrompt = `Generate a friendly workspace update for ${userName}. + // Build prompt + const userPrompt = `Generate a personalized workspace update for ${userName}. -Time of day: ${timeOfDay} +Time: ${timeOfDay} +Current time: ${now.toISOString()} + +Here's the full context of what's happening in their workspace: -Here's what's been happening in their workspace: ${JSON.stringify(contextData, null, 2)} -Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working on what, any messages that need attention, or things that might be helpful to know. If nothing much is happening, just give a warm greeting. Don't list everything - pick the 1-2 most relevant things.`; +Based on this rich context, write a natural 2-4 sentence update that tells them something genuinely useful. You can see actual page content, full message text, and aggregated activity patterns - use this to be specific and helpful. + +Focus on what would be most relevant to them right now. If colleagues have been active, tell them WHAT those colleagues are working on (you can see the content!). If there are messages, summarize what they actually say. If it's quiet, just give a warm greeting.`; // Get AI provider const providerResult = await createAIProvider(userId, { @@ -629,13 +610,50 @@ Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working } // Save to database + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); const expiresAt = new Date(now.getTime() + 2 * 60 * 60 * 1000); + await db.insert(pulseSummaries).values({ userId, summary, greeting, type: 'scheduled', - contextData, + contextData: { + workspace: contextData.workspace, + workingOn: contextData.colleagueActivity.slice(0, 5).map(a => ({ + person: a.person, + page: a.page, + driveName: a.drive, + action: a.actions[0] || 'update', + })), + tasks: { + dueToday: contextData.tasks.dueToday.length, + dueThisWeek: 0, + overdue: contextData.tasks.overdue.length, + completedThisWeek: contextData.tasks.recentlyCompleted.length, + recentlyCompleted: contextData.tasks.recentlyCompleted.map(t => t.title).filter((t): t is string => !!t), + upcoming: contextData.tasks.dueToday.map(t => t.title).filter((t): t is string => !!t), + overdueItems: contextData.tasks.overdue.map(t => ({ title: t.title || '', priority: t.priority })), + }, + messages: { + unreadCount: unreadDMs.length, + recentSenders: [...new Set(unreadDMs.map(m => m.from))], + recentMessages: unreadDMs.slice(0, 5).map(m => ({ from: m.from, preview: m.content.substring(0, 300) })), + }, + mentions: contextData.mentions.map(m => ({ by: m.by, inPage: m.inPage })), + notifications: [], + sharedWithYou: contextData.sharedWithYou.map(s => ({ page: s.page, by: s.by })), + contentChanges: contextData.colleagueActivity.slice(0, 5).map(a => ({ page: a.page, by: a.person })), + pages: { + updatedToday: contextData.colleagueActivity.filter(a => new Date(a.lastActive) >= startOfToday).length, + updatedThisWeek: contextData.colleagueActivity.length, + recentlyUpdated: contextData.colleagueActivity.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), + }, + activity: { + collaboratorNames: [...new Set(contextData.colleagueActivity.map(a => a.person))], + recentOperations: contextData.colleagueActivity.slice(0, 5).map(a => `${a.person} edited "${a.page}" (${a.editCount} times)`), + }, + }, aiProvider: providerResult.provider, aiModel: providerResult.modelName, periodStart: twoHoursAgo, diff --git a/apps/web/src/app/api/pulse/generate/route.ts b/apps/web/src/app/api/pulse/generate/route.ts index 1dadb23eb..31739495b 100644 --- a/apps/web/src/app/api/pulse/generate/route.ts +++ b/apps/web/src/app/api/pulse/generate/route.ts @@ -18,8 +18,8 @@ import { users, pulseSummaries, userMentions, - notifications, pagePermissions, + chatMessages, eq, and, or, @@ -27,51 +27,43 @@ import { gte, ne, desc, - sql, - count, inArray, } from '@pagespace/db'; -import type { PulseSummaryContextData } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; const AUTH_OPTIONS = { allow: ['session'] as const }; // System prompt for generating pulse summaries -const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion giving the user a natural, conversational update about their workspace. +const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion who deeply understands the user's workspace and can give them genuinely useful, contextual updates. -Your job is to tell them something INTERESTING or USEFUL about what's happening - not give them a robotic status report. +You have access to RICH context about what's happening - actual page content, full messages, aggregated activity patterns, and more. Use this to give MEANINGFUL updates, not robotic summaries. -TONE: -- Like a thoughtful colleague catching you up over coffee -- Natural and conversational, not a bullet-point readout -- If it's a quiet day, just say hi warmly - don't manufacture urgency -- If there's interesting activity, share what's actually happening - -WHAT TO FOCUS ON (pick what's most interesting, not everything): -- What are people actually working on? "Noah's been making progress on the Product Roadmap" -- Interesting updates: "Sarah added some new ideas to the Q1 Planning doc" -- Meaningful messages: If someone asked a specific question, mention it -- Recent shares/mentions: "Alex shared the Budget proposal with you" -- If someone left you a message, summarize WHAT they said, not just that they messaged +YOUR JOB: +- Tell them something they'd actually want to know +- Be specific about WHAT people are working on (you can see page content!) +- If someone messaged them, tell them what the message actually says +- Notice interesting patterns: "Noah's been really focused on the roadmap today" +- Connect the dots: "Sarah's updates to the Budget doc might be related to what Alex was asking about" -WHAT TO AVOID: -- Robotic stat dumps: "You have 5 tasks, 26 pages updated" - USELESS -- Vague summaries: "activity has occurred in your workspace" - BORING -- Task-list mentality: Don't treat this as a to-do reminder -- Counts without context: Never say "X tasks" or "X pages" without specifics -- Filler when there's nothing: If it's quiet, a simple warm greeting is fine +TONE: +- Like a thoughtful colleague who's been paying attention +- Natural and conversational +- Warm but not fake-enthusiastic +- If it's quiet, just say hi - don't manufacture activity -EXAMPLES OF GOOD SUMMARIES: -- "Hey! Noah's been working on the Product Roadmap this morning - looks like he added the Q2 timeline. Sarah also dropped some comments on your Budget doc." -- "Good afternoon! Alex shared the Marketing Brief with you, and it looks pretty comprehensive. Also, Sarah was asking about the launch date in your DMs." -- "Morning! Things are quiet right now. The team was active yesterday on the Sprint Planning doc if you want to catch up." +EXAMPLES OF GREAT SUMMARIES: +- "Morning! Noah's been heads-down on the Product Roadmap - he's added a whole new Q2 section with timeline estimates. Also, Sarah left you a DM asking if you've reviewed the pricing changes yet." +- "Hey! Looks like the team's been active on Sprint Planning today. Alex added some notes about the API migration, and there's a thread going in the comments about the timeline." +- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier if you want to take a look." +- "Evening! Quick catch-up: Noah finished that Q4 Projections doc you were both discussing. The final revenue numbers look different from the draft." -EXAMPLES OF BAD SUMMARIES: -- "Good morning. You have 3 tasks due today and 12 pages were updated this week." (robotic, no context) -- "Evening, Jonathan. Activity has occurred in your drives." (vague, useless) -- "You've completed 5 tasks this week, though no specific details were provided." (never admit lack of context - just omit) +WHAT TO AVOID: +- "You have X tasks and Y pages were updated" - useless without specifics +- "Activity occurred in your workspace" - vague nonsense +- Listing every single thing that happened - pick what matters +- Admitting you don't have information - just focus on what you DO know -Keep it to 2-3 natural sentences. Start with a brief, time-appropriate greeting.`; +Keep it to 2-4 natural sentences. Be genuinely helpful.`; export async function POST(req: Request) { const auth = await authenticateRequestWithOptions(req, AUTH_OPTIONS); @@ -83,30 +75,32 @@ export async function POST(req: Request) { const [user] = await db.select().from(users).where(eq(users.id, userId)); const userName = user?.name || user?.email?.split('@')[0] || 'there'; - // Gather context data + // Time windows const now = new Date(); - const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // Week boundaries (Sunday start) const dayOfWeek = now.getDay(); const startOfWeek = new Date(startOfToday); startOfWeek.setDate(startOfWeek.getDate() - dayOfWeek); - // Get user's drives with full context + // Get user's drives const userDrives = await db .select({ driveId: driveMembers.driveId }) .from(driveMembers) .where(eq(driveMembers.userId, userId)); const driveIds = userDrives.map(d => d.driveId); - // Get drive details for workspace context + // ======================================== + // 1. WORKSPACE CONTEXT - Drives and team members + // ======================================== const driveDetails = driveIds.length > 0 ? await db .select({ id: drives.id, name: drives.name, - description: drives.drivePrompt, // Can contain project description + description: drives.drivePrompt, }) .from(drives) .where(and( @@ -114,96 +108,129 @@ export async function POST(req: Request) { eq(drives.isTrashed, false) )) : []; - // Task data - const [tasksOverdue] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - lt(taskItems.dueDate, startOfToday) - ) - ); + // Get team members for each drive + const teamMembers = driveIds.length > 0 ? await db + .select({ + driveId: driveMembers.driveId, + userName: users.name, + userEmail: users.email, + }) + .from(driveMembers) + .leftJoin(users, eq(users.id, driveMembers.userId)) + .where(and( + inArray(driveMembers.driveId, driveIds), + ne(driveMembers.userId, userId) // Exclude current user + )) : []; - // Get overdue task details with priority - const overdueTasksList = await db - .select({ title: taskItems.title, priority: taskItems.priority }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - lt(taskItems.dueDate, startOfToday) - ) - ) - .orderBy(desc(taskItems.priority), taskItems.dueDate) - .limit(5); + // Group team members by drive + const teamByDrive = teamMembers.reduce((acc, m) => { + if (!acc[m.driveId]) acc[m.driveId] = []; + acc[m.driveId].push(m.userName || m.userEmail?.split('@')[0] || 'Unknown'); + return acc; + }, {} as Record); - const [tasksDueToday] = await db - .select({ count: count() }) - .from(taskItems) + // ======================================== + // 2. AGGREGATED ACTIVITY - What people are ACTUALLY working on + // ======================================== + // Get all activity in last 48 hours and aggregate intelligently + const rawActivity = driveIds.length > 0 ? await db + .select({ + actorId: activityLogs.userId, + actorName: activityLogs.actorDisplayName, + operation: activityLogs.operation, + resourceType: activityLogs.resourceType, + resourceId: activityLogs.resourceId, + resourceTitle: activityLogs.resourceTitle, + driveId: activityLogs.driveId, + timestamp: activityLogs.timestamp, + }) + .from(activityLogs) .where( and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday), - lt(taskItems.dueDate, endOfToday) + inArray(activityLogs.driveId, driveIds), + gte(activityLogs.timestamp, fortyEightHoursAgo) ) - ); + ) + .orderBy(desc(activityLogs.timestamp)) + .limit(200) : []; + + // Aggregate activity by person and page - count operations instead of listing each one + const activityByPersonPage: Record; + isOwnActivity: boolean; + }> = {}; + + rawActivity.forEach(a => { + if (a.resourceType !== 'page' || !a.resourceId) return; + const key = `${a.actorId}-${a.resourceId}`; + const driveName = driveDetails.find(d => d.id === a.driveId)?.name || 'Unknown'; + + if (!activityByPersonPage[key]) { + activityByPersonPage[key] = { + person: a.actorName || 'Someone', + pageId: a.resourceId, + pageTitle: a.resourceTitle || 'Untitled', + driveName, + editCount: 0, + lastEdit: a.timestamp, + operations: new Set(), + isOwnActivity: a.actorId === userId, + }; + } + activityByPersonPage[key].editCount++; + activityByPersonPage[key].operations.add(a.operation); + if (a.timestamp > activityByPersonPage[key].lastEdit) { + activityByPersonPage[key].lastEdit = a.timestamp; + } + }); - const [tasksDueThisWeek] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday), - lt(taskItems.dueDate, new Date(startOfWeek.getTime() + 7 * 24 * 60 * 60 * 1000)) - ) - ); + // Convert to array and sort by edit count (most active first) + const aggregatedActivity = Object.values(activityByPersonPage) + .sort((a, b) => b.editCount - a.editCount) + .slice(0, 15); - const [tasksCompletedThisWeek] = await db - .select({ count: count() }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - eq(taskItems.status, 'completed'), - gte(taskItems.completedAt, startOfWeek) - ) - ); + // Separate own activity from others' activity + const othersActivity = aggregatedActivity.filter(a => !a.isOwnActivity); + const ownActivity = aggregatedActivity.filter(a => a.isOwnActivity); - // Recently completed tasks (last 24h) - const recentlyCompletedTasks = await db - .select({ title: taskItems.title }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - eq(taskItems.status, 'completed'), - gte(taskItems.completedAt, new Date(now.getTime() - 24 * 60 * 60 * 1000)) - ) - ) - .orderBy(desc(taskItems.completedAt)) - .limit(3); + // ======================================== + // 3. PAGE CONTENT - What these pages actually contain + // ======================================== + // Get content snippets for the most active pages (by others) + const activePageIds = othersActivity.slice(0, 8).map(a => a.pageId); - // Upcoming tasks - const upcomingTasks = await db - .select({ title: taskItems.title }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - ne(taskItems.status, 'completed'), - gte(taskItems.dueDate, startOfToday) - ) - ) - .orderBy(taskItems.dueDate) - .limit(3); + const pageContents = activePageIds.length > 0 ? await db + .select({ + id: pages.id, + title: pages.title, + content: pages.content, + type: pages.type, + updatedAt: pages.updatedAt, + }) + .from(pages) + .where(and( + inArray(pages.id, activePageIds), + eq(pages.isTrashed, false) + )) : []; - // Unread messages + // Create content snippets (first 1500 chars for rich context) + const pageSnippets = pageContents.map(p => ({ + id: p.id, + title: p.title, + type: p.type, + contentPreview: p.content?.substring(0, 1500) || '', + updatedAt: p.updatedAt, + })); + + // ======================================== + // 4. DIRECT MESSAGES - Full content, not truncated + // ======================================== const userConversations = await db .select({ id: dmConversations.id }) .from(dmConversations) @@ -214,98 +241,109 @@ export async function POST(req: Request) { ) ); - let unreadCount = 0; - const recentSenders: string[] = []; - const recentMessages: { from: string; preview?: string }[] = []; + let unreadDMs: { from: string; content: string; sentAt: Date }[] = []; if (userConversations.length > 0) { const conversationIds = userConversations.map(c => c.id); - const [unreadResult] = await db - .select({ count: count() }) + const unreadMessagesList = await db + .select({ + senderName: users.name, + senderEmail: users.email, + content: directMessages.content, + createdAt: directMessages.createdAt, + }) .from(directMessages) + .leftJoin(users, eq(users.id, directMessages.senderId)) .where( and( inArray(directMessages.conversationId, conversationIds), ne(directMessages.senderId, userId), eq(directMessages.isRead, false) ) - ); - unreadCount = unreadResult?.count ?? 0; - - // Get recent unread messages with content preview - if (unreadCount > 0) { - const unreadMessagesList = await db - .select({ - senderId: directMessages.senderId, - senderName: users.name, - content: directMessages.content, - }) - .from(directMessages) - .leftJoin(users, eq(users.id, directMessages.senderId)) - .where( - and( - inArray(directMessages.conversationId, conversationIds), - ne(directMessages.senderId, userId), - eq(directMessages.isRead, false) - ) - ) - .orderBy(desc(directMessages.createdAt)) - .limit(3); - - const uniqueSenders = new Set(); - unreadMessagesList.forEach(m => { - if (m.senderName) uniqueSenders.add(m.senderName); - recentMessages.push({ - from: m.senderName || 'Someone', - preview: m.content?.substring(0, 300), // Longer preview for context - }); - }); - recentSenders.push(...Array.from(uniqueSenders).slice(0, 5)); - } + ) + .orderBy(desc(directMessages.createdAt)) + .limit(10); + + unreadDMs = unreadMessagesList.map(m => ({ + from: m.senderName || m.senderEmail?.split('@')[0] || 'Someone', + content: m.content || '', // Full content, no truncation + sentAt: m.createdAt, + })); } - // Get recent @mentions of the user - const recentMentions = await db + // ======================================== + // 5. PAGE CHAT MESSAGES - Conversations happening on pages + // ======================================== + // Get recent chat messages on pages in user's drives (excluding AI responses) + const recentPageChats = driveIds.length > 0 ? await db .select({ - mentionedByName: users.name, + pageId: chatMessages.pageId, pageTitle: pages.title, + senderName: users.name, + senderEmail: users.email, + content: chatMessages.content, + role: chatMessages.role, + createdAt: chatMessages.createdAt, }) - .from(userMentions) - .leftJoin(users, eq(users.id, userMentions.mentionedByUserId)) - .leftJoin(pages, eq(pages.id, userMentions.sourcePageId)) + .from(chatMessages) + .leftJoin(pages, eq(pages.id, chatMessages.pageId)) + .leftJoin(users, eq(users.id, chatMessages.userId)) .where( and( - eq(userMentions.targetUserId, userId), - gte(userMentions.createdAt, twoHoursAgo) + inArray(pages.driveId, driveIds), + eq(chatMessages.role, 'user'), // Only human messages + eq(chatMessages.isActive, true), + gte(chatMessages.createdAt, twentyFourHoursAgo), + ne(chatMessages.userId, userId) // Messages from others ) ) - .orderBy(desc(userMentions.createdAt)) - .limit(3); + .orderBy(desc(chatMessages.createdAt)) + .limit(15) : []; + + // Group chat messages by page + const chatsByPage = recentPageChats.reduce((acc, chat) => { + const key = chat.pageId; + if (!acc[key]) { + acc[key] = { + pageTitle: chat.pageTitle || 'Untitled', + messages: [], + }; + } + acc[key].messages.push({ + from: chat.senderName || chat.senderEmail?.split('@')[0] || 'Someone', + content: chat.content?.substring(0, 500) || '', + sentAt: chat.createdAt, + }); + return acc; + }, {} as Record); - // Get unread notifications - const unreadNotifications = await db + // ======================================== + // 6. MENTIONS & SHARES + // ======================================== + const recentMentions = await db .select({ - type: notifications.type, - triggeredByName: users.name, + mentionedByName: users.name, pageTitle: pages.title, + createdAt: userMentions.createdAt, }) - .from(notifications) - .leftJoin(users, eq(users.id, notifications.triggeredByUserId)) - .leftJoin(pages, eq(pages.id, notifications.pageId)) + .from(userMentions) + .leftJoin(users, eq(users.id, userMentions.mentionedByUserId)) + .leftJoin(pages, eq(pages.id, userMentions.sourcePageId)) .where( and( - eq(notifications.userId, userId), - eq(notifications.isRead, false) + eq(userMentions.targetUserId, userId), + gte(userMentions.createdAt, fortyEightHoursAgo) ) ) - .orderBy(desc(notifications.createdAt)) + .orderBy(desc(userMentions.createdAt)) .limit(5); - // Get pages recently shared with user const recentShares = await db .select({ pageTitle: pages.title, + pageContent: pages.content, sharedByName: users.name, + grantedAt: pagePermissions.grantedAt, }) .from(pagePermissions) .leftJoin(pages, eq(pages.id, pagePermissions.pageId)) @@ -313,182 +351,152 @@ export async function POST(req: Request) { .where( and( eq(pagePermissions.userId, userId), - gte(pagePermissions.grantedAt, twoHoursAgo) + gte(pagePermissions.grantedAt, fortyEightHoursAgo) ) ) .orderBy(desc(pagePermissions.grantedAt)) - .limit(3); - - // Pages updated - let pagesUpdatedToday = 0; - let pagesUpdatedThisWeek = 0; - const recentlyUpdatedPages: { title: string; updatedBy: string }[] = []; - - if (driveIds.length > 0) { - const [todayResult] = await db - .select({ count: count() }) - .from(pages) - .where( - and( - inArray(pages.driveId, driveIds), - eq(pages.isTrashed, false), - gte(pages.updatedAt, startOfToday) - ) - ); - pagesUpdatedToday = todayResult?.count ?? 0; + .limit(5); - const [weekResult] = await db - .select({ count: count() }) - .from(pages) - .where( - and( - inArray(pages.driveId, driveIds), - eq(pages.isTrashed, false), - gte(pages.updatedAt, startOfWeek) - ) - ); - pagesUpdatedThisWeek = weekResult?.count ?? 0; + // ======================================== + // 7. TASKS - With full context + // ======================================== + const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); - // Recent page updates (by others) - const recentUpdates = await db - .select({ - pageTitle: pages.title, - actorName: activityLogs.actorDisplayName, - }) - .from(activityLogs) - .leftJoin(pages, eq(pages.id, activityLogs.pageId)) - .where( - and( - inArray(activityLogs.driveId, driveIds), - eq(activityLogs.operation, 'update'), - eq(activityLogs.resourceType, 'page'), - ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twoHoursAgo) - ) + const overdueTasks = await db + .select({ + title: taskItems.title, + priority: taskItems.priority, + dueDate: taskItems.dueDate, + }) + .from(taskItems) + .where( + and( + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + ne(taskItems.status, 'completed'), + lt(taskItems.dueDate, startOfToday) ) - .orderBy(desc(activityLogs.timestamp)) - .limit(5); - - const seenPages = new Set(); - recentUpdates.forEach(u => { - if (u.pageTitle && !seenPages.has(u.pageTitle)) { - seenPages.add(u.pageTitle); - recentlyUpdatedPages.push({ - title: u.pageTitle, - updatedBy: u.actorName || 'Someone', - }); - } - }); - } - - // Extended time window for "what you missed" context (24 hours) - const twentyFourHoursAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + ) + .orderBy(desc(taskItems.priority), taskItems.dueDate) + .limit(10); - // Recent activity by collaborators - get more detail about what's happening - const collaboratorActivity = await db + const todayTasks = await db .select({ - actorName: activityLogs.actorDisplayName, - operation: activityLogs.operation, - resourceType: activityLogs.resourceType, - resourceTitle: activityLogs.resourceTitle, - driveId: activityLogs.driveId, - timestamp: activityLogs.timestamp, + title: taskItems.title, + priority: taskItems.priority, }) - .from(activityLogs) + .from(taskItems) .where( and( - driveIds.length > 0 ? inArray(activityLogs.driveId, driveIds) : sql`false`, - ne(activityLogs.userId, userId), - gte(activityLogs.timestamp, twentyFourHoursAgo) + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + ne(taskItems.status, 'completed'), + gte(taskItems.dueDate, startOfToday), + lt(taskItems.dueDate, endOfToday) ) ) - .orderBy(desc(activityLogs.timestamp)) - .limit(20); - - // Build rich activity summaries - group by person and what they're working on - const collaboratorNames = new Set(); - const recentOperations: string[] = []; - const workingOn: { person: string; page: string; driveName?: string; action: string }[] = []; - - collaboratorActivity.forEach(a => { - if (a.actorName) collaboratorNames.add(a.actorName); - if (a.resourceTitle && a.resourceType === 'page') { - const driveName = driveDetails.find(d => d.id === a.driveId)?.name; - workingOn.push({ - person: a.actorName || 'Someone', - page: a.resourceTitle, - driveName, - action: a.operation, - }); - } - if (a.resourceTitle && recentOperations.length < 5) { - recentOperations.push(`${a.actorName || 'Someone'} ${a.operation}d "${a.resourceTitle}"`); - } - }); + .orderBy(desc(taskItems.priority)) + .limit(10); - // Dedupe and limit workingOn to most relevant - const uniqueWorkingOn = workingOn.reduce((acc, curr) => { - const key = `${curr.person}-${curr.page}`; - if (!acc.some(x => `${x.person}-${x.page}` === key)) { - acc.push(curr); - } - return acc; - }, [] as typeof workingOn).slice(0, 5); + const recentlyCompletedTasks = await db + .select({ title: taskItems.title, completedAt: taskItems.completedAt }) + .from(taskItems) + .where( + and( + or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), + eq(taskItems.status, 'completed'), + gte(taskItems.completedAt, twentyFourHoursAgo) + ) + ) + .orderBy(desc(taskItems.completedAt)) + .limit(5); - // Build context data with rich workspace context - const contextData: PulseSummaryContextData = { - // Workspace context - what projects/drives exist + // ======================================== + // BUILD THE RICH CONTEXT OBJECT + // ======================================== + const contextData = { + // User context + userName, + + // Workspace overview workspace: { drives: driveDetails.map(d => ({ name: d.name, - description: d.description?.substring(0, 200) || undefined, + description: d.description || undefined, + teamMembers: teamByDrive[d.id] || [], })), }, - // What people are actively working on (most valuable context) - workingOn: uniqueWorkingOn, - tasks: { - dueToday: tasksDueToday?.count ?? 0, - dueThisWeek: tasksDueThisWeek?.count ?? 0, - overdue: tasksOverdue?.count ?? 0, - completedThisWeek: tasksCompletedThisWeek?.count ?? 0, - recentlyCompleted: recentlyCompletedTasks.map(t => t.title).filter((t): t is string => !!t), - upcoming: upcomingTasks.map(t => t.title).filter((t): t is string => !!t), - overdueItems: overdueTasksList.map(t => ({ - title: t.title ?? '', - priority: t.priority, - })).filter(t => t.title), - }, - messages: { - unreadCount, - recentSenders, - recentMessages, - }, + + // What others are working on (aggregated, not raw operations) + colleagueActivity: othersActivity.map(a => ({ + person: a.person, + page: a.pageTitle, + drive: a.driveName, + editCount: a.editCount, + actions: Array.from(a.operations), + lastActive: a.lastEdit.toISOString(), + })), + + // Actual page content for context + activePageContent: pageSnippets.map(p => ({ + title: p.title, + type: p.type, + preview: p.contentPreview, + })), + + // Direct messages - full content + directMessages: unreadDMs.map(m => ({ + from: m.from, + message: m.content, + sentAt: m.sentAt.toISOString(), + })), + + // Page chat discussions + pageDiscussions: Object.entries(chatsByPage).map(([_, data]) => ({ + page: data.pageTitle, + messages: data.messages.map(m => ({ + from: m.from, + message: m.content, + sentAt: m.sentAt.toISOString(), + })), + })), + + // Mentions mentions: recentMentions.map(m => ({ by: m.mentionedByName || 'Someone', inPage: m.pageTitle || 'a page', + when: m.createdAt.toISOString(), })), - notifications: unreadNotifications.map(n => ({ - type: n.type, - from: n.triggeredByName, - page: n.pageTitle, - })), + + // Shares (with content preview) sharedWithYou: recentShares.map(s => ({ page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', + preview: s.pageContent?.substring(0, 500) || '', + when: s.grantedAt?.toISOString(), })), - contentChanges: recentlyUpdatedPages.slice(0, 5).map(p => ({ - page: p.title, - by: p.updatedBy, - })), - pages: { - updatedToday: pagesUpdatedToday, - updatedThisWeek: pagesUpdatedThisWeek, - recentlyUpdated: recentlyUpdatedPages.slice(0, 5), - }, - activity: { - collaboratorNames: Array.from(collaboratorNames).slice(0, 8), - recentOperations: recentOperations.slice(0, 5), + + // Tasks + tasks: { + overdue: overdueTasks.map(t => ({ + title: t.title, + priority: t.priority, + dueDate: t.dueDate?.toISOString(), + })), + dueToday: todayTasks.map(t => ({ + title: t.title, + priority: t.priority, + })), + recentlyCompleted: recentlyCompletedTasks.map(t => ({ + title: t.title, + completedAt: t.completedAt?.toISOString(), + })), }, + + // User's own recent activity (for context on what they've been doing) + ownRecentActivity: ownActivity.slice(0, 5).map(a => ({ + page: a.pageTitle, + editCount: a.editCount, + lastActive: a.lastEdit.toISOString(), + })), }; // Determine greeting based on time of day @@ -498,15 +506,19 @@ export async function POST(req: Request) { else if (hour < 17) timeOfDay = 'afternoon'; else timeOfDay = 'evening'; - // Build prompt for AI - focus on what's interesting, not a data dump - const userPrompt = `Generate a friendly workspace update for ${userName}. + // Build prompt for AI + const userPrompt = `Generate a personalized workspace update for ${userName}. -Time of day: ${timeOfDay} +Time: ${timeOfDay} +Current time: ${now.toISOString()} + +Here's the full context of what's happening in their workspace: -Here's what's been happening in their workspace: ${JSON.stringify(contextData, null, 2)} -Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working on what, any messages that need attention, or things that might be helpful to know. If nothing much is happening, just give a warm greeting. Don't list everything - pick the 1-2 most relevant things.`; +Based on this rich context, write a natural 2-4 sentence update that tells them something genuinely useful. You can see actual page content, full message text, and aggregated activity patterns - use this to be specific and helpful. + +Focus on what would be most relevant to them right now. If colleagues have been active, tell them WHAT those colleagues are working on (you can see the content!). If there are messages, summarize what they actually say. If it's quiet, just give a warm greeting.`; // Get AI provider (use standard model) const providerResult = await createAIProvider(userId, { @@ -537,14 +549,51 @@ Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working greeting = greetingMatch[1]; } - // Save to database - const expiresAt = new Date(now.getTime() + 2 * 60 * 60 * 1000); // Expires in 2 hours + // Save to database (store simplified context for transparency) + const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); + const expiresAt = new Date(now.getTime() + 2 * 60 * 60 * 1000); + const [savedSummary] = await db.insert(pulseSummaries).values({ userId, summary, greeting, type: 'on_demand', - contextData, + contextData: { + workspace: contextData.workspace, + workingOn: contextData.colleagueActivity.slice(0, 5).map(a => ({ + person: a.person, + page: a.page, + driveName: a.drive, + action: a.actions[0] || 'update', + })), + tasks: { + dueToday: contextData.tasks.dueToday.length, + dueThisWeek: 0, + overdue: contextData.tasks.overdue.length, + completedThisWeek: contextData.tasks.recentlyCompleted.length, + recentlyCompleted: contextData.tasks.recentlyCompleted.map(t => t.title).filter((t): t is string => !!t), + upcoming: contextData.tasks.dueToday.map(t => t.title).filter((t): t is string => !!t), + overdueItems: contextData.tasks.overdue.map(t => ({ title: t.title || '', priority: t.priority })), + }, + messages: { + unreadCount: unreadDMs.length, + recentSenders: [...new Set(unreadDMs.map(m => m.from))], + recentMessages: unreadDMs.slice(0, 5).map(m => ({ from: m.from, preview: m.content.substring(0, 300) })), + }, + mentions: contextData.mentions.map(m => ({ by: m.by, inPage: m.inPage })), + notifications: [], + sharedWithYou: contextData.sharedWithYou.map(s => ({ page: s.page, by: s.by })), + contentChanges: contextData.colleagueActivity.slice(0, 5).map(a => ({ page: a.page, by: a.person })), + pages: { + updatedToday: contextData.colleagueActivity.filter(a => new Date(a.lastActive) >= startOfToday).length, + updatedThisWeek: contextData.colleagueActivity.length, + recentlyUpdated: contextData.colleagueActivity.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), + }, + activity: { + collaboratorNames: [...new Set(contextData.colleagueActivity.map(a => a.person))], + recentOperations: contextData.colleagueActivity.slice(0, 5).map(a => `${a.person} edited "${a.page}" (${a.editCount} times)`), + }, + }, aiProvider: providerResult.provider, aiModel: providerResult.modelName, periodStart: twoHoursAgo, @@ -557,6 +606,7 @@ Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working userId, summaryId: savedSummary.id, summaryLength: summary.length, + contextSize: JSON.stringify(contextData).length, }); return NextResponse.json({ @@ -565,7 +615,7 @@ Write a natural 2-3 sentence update. Focus on what's INTERESTING - who's working greeting, generatedAt: savedSummary.generatedAt, expiresAt: savedSummary.expiresAt, - contextData, + contextData: savedSummary.contextData, }); } catch (error) { From 23baff40095d9ec241b5746c1d943abcfb2460da Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Feb 2026 18:55:28 +0000 Subject: [PATCH 3/3] feat(pulse): integrate content diffs to show WHAT changed, not just counts - Import and use existing diff utilities from @pagespace/lib/content - Fetch activity logs with contentRef and contentSnapshot for diff generation - Use groupActivitiesForDiff to collapse autosave operations into meaningful groups - Use resolveStackedVersionContent to get before/after content pairs - Generate unified diffs within budget using generateDiffsWithinBudget - Pass actual content diffs (+ lines = additions, - lines = deletions) to AI - Update system prompts to explain how to read and summarize diffs - AI now summarizes the substance of changes instead of counting edits Example output transformation: - Before: "Noah made 15 edits to the Roadmap" - After: "Noah added a Q2 section to the Roadmap covering API migration timeline" https://claude.ai/code/session_01MzgqCneugAdEMYGZcb1iub --- apps/web/src/app/api/pulse/cron/route.ts | 292 +++++++++++---- apps/web/src/app/api/pulse/generate/route.ts | 368 +++++++++++-------- 2 files changed, 433 insertions(+), 227 deletions(-) diff --git a/apps/web/src/app/api/pulse/cron/route.ts b/apps/web/src/app/api/pulse/cron/route.ts index f17c2ee07..34e42b983 100644 --- a/apps/web/src/app/api/pulse/cron/route.ts +++ b/apps/web/src/app/api/pulse/cron/route.ts @@ -30,7 +30,17 @@ import { inArray, isNull, } from '@pagespace/db'; -import { loggers } from '@pagespace/lib/server'; +import { + groupActivitiesForDiff, + resolveStackedVersionContent, + generateDiffsWithinBudget, + calculateDiffBudget, + type ActivityForDiff, + type ActivityDiffGroup, + type DiffRequest, + type StackedDiff, +} from '@pagespace/lib/content'; +import { readPageContent, loggers } from '@pagespace/lib/server'; // This endpoint should be protected by a cron secret in production const CRON_SECRET = process.env.CRON_SECRET; @@ -38,31 +48,34 @@ const CRON_SECRET = process.env.CRON_SECRET; // System prompt for generating pulse summaries const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion who deeply understands the user's workspace and can give them genuinely useful, contextual updates. -You have access to RICH context about what's happening - actual page content, full messages, aggregated activity patterns, and more. Use this to give MEANINGFUL updates, not robotic summaries. +You have access to RICH context including ACTUAL CONTENT DIFFS showing exactly what changed. Use this to tell users WHAT was written/edited, not just that something changed. YOUR JOB: -- Tell them something they'd actually want to know -- Be specific about WHAT people are working on (you can see page content!) +- Tell them WHAT changed, not just that changes happened +- Read the diffs and summarize the actual content: "Noah added a section about Q2 pricing with 3 new tiers" - If someone messaged them, tell them what the message actually says -- Notice interesting patterns: "Noah's been really focused on the roadmap today" -- Connect the dots: "Sarah's updates to the Budget doc might be related to what Alex was asking about" +- Be specific: "Sarah updated the API docs to include OAuth2 examples" not "Sarah edited the API docs" + +READING DIFFS: +- Lines starting with + are additions (new content) +- Lines starting with - are deletions (removed content) +- Focus on the MEANING of what was added/removed, not line counts +- Summarize the substance: "Added a troubleshooting section" not "added 15 lines" TONE: -- Like a thoughtful colleague who's been paying attention +- Like a thoughtful colleague who read the changes and can summarize them - Natural and conversational -- Warm but not fake-enthusiastic - If it's quiet, just say hi - don't manufacture activity EXAMPLES OF GREAT SUMMARIES: -- "Morning! Noah's been heads-down on the Product Roadmap - he's added a whole new Q2 section with timeline estimates. Also, Sarah left you a DM asking if you've reviewed the pricing changes yet." -- "Hey! Looks like the team's been active on Sprint Planning today. Alex added some notes about the API migration, and there's a thread going in the comments about the timeline." -- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier if you want to take a look." -- "Evening! Quick catch-up: Noah finished that Q4 Projections doc you were both discussing. The final revenue numbers look different from the draft." +- "Morning! Noah's been working on the Product Roadmap - he added a whole Q2 section covering the API migration timeline and new pricing tiers. Also, Sarah left you a DM asking if the launch date is still Feb 15th." +- "Hey! Alex updated the onboarding guide with step-by-step screenshots for the new dashboard. There's also a discussion going on the Sprint Planning page about the deployment schedule." +- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier - it has projections through Q3." WHAT TO AVOID: -- "You have X tasks and Y pages were updated" - useless without specifics -- "Activity occurred in your workspace" - vague nonsense -- Listing every single thing that happened - pick what matters +- "5 pages were updated" - useless without substance +- "Changes were made to the document" - vague nonsense +- Reporting diff statistics like "23 lines added" - focus on meaning instead - Admitting you don't have information - just focus on what you DO know Keep it to 2-4 natural sentences. Be genuinely helpful.`; @@ -218,29 +231,143 @@ async function generatePulseForUser(userId: string, now: Date): Promise { }, {} as Record); // ======================================== - // 2. AGGREGATED ACTIVITY + // 2. ACTIVITY WITH CONTENT DIFFS - The key improvement! // ======================================== const rawActivity = driveIds.length > 0 ? await db .select({ + id: activityLogs.id, actorId: activityLogs.userId, actorName: activityLogs.actorDisplayName, + actorEmail: activityLogs.actorEmail, operation: activityLogs.operation, resourceType: activityLogs.resourceType, resourceId: activityLogs.resourceId, + pageId: activityLogs.pageId, resourceTitle: activityLogs.resourceTitle, driveId: activityLogs.driveId, timestamp: activityLogs.timestamp, + changeGroupId: activityLogs.changeGroupId, + aiConversationId: activityLogs.aiConversationId, + isAiGenerated: activityLogs.isAiGenerated, + contentRef: activityLogs.contentRef, + contentSnapshot: activityLogs.contentSnapshot, }) .from(activityLogs) .where( and( inArray(activityLogs.driveId, driveIds), + ne(activityLogs.userId, userId), // Only others' activity for diffs gte(activityLogs.timestamp, fortyEightHoursAgo) ) ) .orderBy(desc(activityLogs.timestamp)) - .limit(200) : []; + .limit(100) : []; + + // ======================================== + // 3. GENERATE ACTUAL CONTENT DIFFS + // ======================================== + const pageActivities = rawActivity.filter( + a => a.pageId && + a.resourceType === 'page' && + (a.operation === 'update' || a.operation === 'create') && + (a.contentRef || a.contentSnapshot) + ); + + const activitiesForDiff: (ActivityForDiff & { driveId: string })[] = []; + const activityContentRefs = new Map(); + + for (const activity of pageActivities) { + if (activity.contentRef) { + activityContentRefs.set(activity.id, activity.contentRef); + } + + activitiesForDiff.push({ + id: activity.id, + timestamp: activity.timestamp, + pageId: activity.pageId, + resourceTitle: activity.resourceTitle, + changeGroupId: activity.changeGroupId, + aiConversationId: activity.aiConversationId, + isAiGenerated: activity.isAiGenerated, + actorEmail: activity.actorEmail, + actorDisplayName: activity.actorName, + content: activity.contentSnapshot ?? null, + driveId: activity.driveId!, + }); + } + + // Group activities to collapse autosaves + const diffGroups = groupActivitiesForDiff(activitiesForDiff); + + // Resolve before/after content from page versions + const groupsWithChangeGroupId = diffGroups.filter( + (g: ActivityDiffGroup) => g.last.changeGroupId && g.last.pageId + ); + + const versionContentPairs = await resolveStackedVersionContent( + groupsWithChangeGroupId.map((g: ActivityDiffGroup) => ({ + changeGroupId: g.last.changeGroupId!, + pageId: g.last.pageId!, + firstContentRef: activityContentRefs.get(g.first.id) ?? null, + })) + ); + + // Build diff requests + const diffRequests: DiffRequest[] = []; + + for (const group of diffGroups) { + const firstActivity = activitiesForDiff.find(a => a.id === group.first.id); + if (!firstActivity || !firstActivity.pageId) continue; + + let beforeContent: string | null = null; + let afterContent: string | null = null; + + if (group.last.changeGroupId && group.last.pageId) { + const compositeKey = `${group.last.pageId}:${group.last.changeGroupId}`; + const versionPair = versionContentPairs.get(compositeKey); + if (versionPair) { + if (versionPair.beforeContentRef) { + try { + beforeContent = await readPageContent(versionPair.beforeContentRef); + } catch { + beforeContent = null; + } + } + if (versionPair.afterContentRef) { + try { + afterContent = await readPageContent(versionPair.afterContentRef); + } catch { + afterContent = null; + } + } + } + } + + // Fallback to inline snapshot + if (beforeContent === null && firstActivity.content) { + beforeContent = firstActivity.content; + } + + // Skip if we can't generate meaningful diff + if (afterContent === null && beforeContent === null) continue; + if (afterContent === null) continue; + diffRequests.push({ + pageId: firstActivity.pageId, + beforeContent, + afterContent, + group, + driveId: firstActivity.driveId, + }); + } + + // Generate diffs within budget + const diffBudget = calculateDiffBudget(30000); + const contentDiffs = generateDiffsWithinBudget(diffRequests, diffBudget); + + // ======================================== + // 4. AGGREGATED ACTIVITY SUMMARY + // ======================================== const activityByPersonPage: Record { isOwnActivity: boolean; }> = {}; - rawActivity.forEach(a => { + // Also fetch own activity for summary + const ownRawActivity = driveIds.length > 0 ? await db + .select({ + actorId: activityLogs.userId, + actorName: activityLogs.actorDisplayName, + operation: activityLogs.operation, + resourceType: activityLogs.resourceType, + resourceId: activityLogs.resourceId, + resourceTitle: activityLogs.resourceTitle, + driveId: activityLogs.driveId, + timestamp: activityLogs.timestamp, + }) + .from(activityLogs) + .where( + and( + inArray(activityLogs.driveId, driveIds), + eq(activityLogs.userId, userId), + gte(activityLogs.timestamp, fortyEightHoursAgo) + ) + ) + .orderBy(desc(activityLogs.timestamp)) + .limit(50) : []; + + // Combine for summary + const allActivity = [...rawActivity, ...ownRawActivity]; + + allActivity.forEach(a => { if (a.resourceType !== 'page' || !a.resourceId) return; const key = `${a.actorId}-${a.resourceId}`; const driveName = driveDetails.find(d => d.id === a.driveId)?.name || 'Unknown'; @@ -284,34 +437,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { const ownActivity = aggregatedActivity.filter(a => a.isOwnActivity); // ======================================== - // 3. PAGE CONTENT - // ======================================== - const activePageIds = othersActivity.slice(0, 8).map(a => a.pageId); - - const pageContents = activePageIds.length > 0 ? await db - .select({ - id: pages.id, - title: pages.title, - content: pages.content, - type: pages.type, - updatedAt: pages.updatedAt, - }) - .from(pages) - .where(and( - inArray(pages.id, activePageIds), - eq(pages.isTrashed, false) - )) : []; - - const pageSnippets = pageContents.map(p => ({ - id: p.id, - title: p.title, - type: p.type, - contentPreview: p.content?.substring(0, 1500) || '', - updatedAt: p.updatedAt, - })); - - // ======================================== - // 4. DIRECT MESSAGES + // 5. DIRECT MESSAGES // ======================================== const userConversations = await db .select({ id: dmConversations.id }) @@ -354,7 +480,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { } // ======================================== - // 5. PAGE CHAT MESSAGES + // 6. PAGE CHAT MESSAGES // ======================================== const recentPageChats = driveIds.length > 0 ? await db .select({ @@ -398,7 +524,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { }, {} as Record); // ======================================== - // 6. MENTIONS & SHARES + // 7. MENTIONS & SHARES // ======================================== const recentMentions = await db .select({ @@ -438,7 +564,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { .limit(5); // ======================================== - // 7. TASKS + // 8. TASKS // ======================================== const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); @@ -490,7 +616,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { .limit(5); // ======================================== - // BUILD THE RICH CONTEXT OBJECT + // BUILD THE RICH CONTEXT WITH DIFFS // ======================================== const contextData = { userName, @@ -501,7 +627,21 @@ async function generatePulseForUser(userId: string, now: Date): Promise { teamMembers: teamByDrive[d.id] || [], })), }, - colleagueActivity: othersActivity.map(a => ({ + + // THE KEY: Actual content diffs showing WHAT changed + contentChanges: contentDiffs.map((diff: StackedDiff & { driveId: string }) => ({ + page: diff.pageTitle || 'Untitled', + actors: diff.actors, + editCount: diff.collapsedCount, + timeRange: diff.timeRange, + isAiGenerated: diff.isAiGenerated, + // The actual diff showing what was written/changed + diff: diff.unifiedDiff, + stats: diff.stats, + })), + + // Activity summary (for context on who's been active) + activitySummary: othersActivity.map(a => ({ person: a.person, page: a.pageTitle, drive: a.driveName, @@ -509,16 +649,15 @@ async function generatePulseForUser(userId: string, now: Date): Promise { actions: Array.from(a.operations), lastActive: a.lastEdit.toISOString(), })), - activePageContent: pageSnippets.map(p => ({ - title: p.title, - type: p.type, - preview: p.contentPreview, - })), + + // Direct messages - full content directMessages: unreadDMs.map(m => ({ from: m.from, message: m.content, sentAt: m.sentAt.toISOString(), })), + + // Page discussions pageDiscussions: Object.entries(chatsByPage).map(([_, data]) => ({ page: data.pageTitle, messages: data.messages.map(m => ({ @@ -527,17 +666,20 @@ async function generatePulseForUser(userId: string, now: Date): Promise { sentAt: m.sentAt.toISOString(), })), })), + mentions: recentMentions.map(m => ({ by: m.mentionedByName || 'Someone', inPage: m.pageTitle || 'a page', when: m.createdAt.toISOString(), })), + sharedWithYou: recentShares.map(s => ({ page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', preview: s.pageContent?.substring(0, 500) || '', when: s.grantedAt?.toISOString(), })), + tasks: { overdue: overdueTasks.map(t => ({ title: t.title, @@ -553,6 +695,7 @@ async function generatePulseForUser(userId: string, now: Date): Promise { completedAt: t.completedAt?.toISOString(), })), }, + ownRecentActivity: ownActivity.slice(0, 5).map(a => ({ page: a.pageTitle, editCount: a.editCount, @@ -573,13 +716,20 @@ async function generatePulseForUser(userId: string, now: Date): Promise { Time: ${timeOfDay} Current time: ${now.toISOString()} -Here's the full context of what's happening in their workspace: +Here's what's happening in their workspace, INCLUDING ACTUAL CONTENT DIFFS: ${JSON.stringify(contextData, null, 2)} -Based on this rich context, write a natural 2-4 sentence update that tells them something genuinely useful. You can see actual page content, full message text, and aggregated activity patterns - use this to be specific and helpful. +IMPORTANT: The "contentChanges" array contains actual diffs showing what was written/changed. Read these diffs and summarize WHAT the content says, not just that changes were made. + +For example, if you see a diff like: ++ ## Q2 Timeline ++ - Sprint 1: API Migration ++ - Sprint 2: New Dashboard -Focus on what would be most relevant to them right now. If colleagues have been active, tell them WHAT those colleagues are working on (you can see the content!). If there are messages, summarize what they actually say. If it's quiet, just give a warm greeting.`; +Say something like "Noah added a Q2 timeline covering the API migration and new dashboard sprints" + +Write a natural 2-4 sentence update that tells them something genuinely useful about what changed.`; // Get AI provider const providerResult = await createAIProvider(userId, { @@ -620,7 +770,7 @@ Focus on what would be most relevant to them right now. If colleagues have been type: 'scheduled', contextData: { workspace: contextData.workspace, - workingOn: contextData.colleagueActivity.slice(0, 5).map(a => ({ + workingOn: contextData.activitySummary.slice(0, 5).map(a => ({ person: a.person, page: a.page, driveName: a.drive, @@ -643,15 +793,18 @@ Focus on what would be most relevant to them right now. If colleagues have been mentions: contextData.mentions.map(m => ({ by: m.by, inPage: m.inPage })), notifications: [], sharedWithYou: contextData.sharedWithYou.map(s => ({ page: s.page, by: s.by })), - contentChanges: contextData.colleagueActivity.slice(0, 5).map(a => ({ page: a.page, by: a.person })), + contentChanges: contextData.contentChanges.slice(0, 5).map((c: { page: string; actors: string[] }) => ({ + page: c.page, + by: c.actors[0] || 'Someone', + })), pages: { - updatedToday: contextData.colleagueActivity.filter(a => new Date(a.lastActive) >= startOfToday).length, - updatedThisWeek: contextData.colleagueActivity.length, - recentlyUpdated: contextData.colleagueActivity.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), + updatedToday: contextData.activitySummary.filter(a => new Date(a.lastActive) >= startOfToday).length, + updatedThisWeek: contextData.activitySummary.length, + recentlyUpdated: contextData.activitySummary.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), }, activity: { - collaboratorNames: [...new Set(contextData.colleagueActivity.map(a => a.person))], - recentOperations: contextData.colleagueActivity.slice(0, 5).map(a => `${a.person} edited "${a.page}" (${a.editCount} times)`), + collaboratorNames: [...new Set(contextData.activitySummary.map(a => a.person))], + recentOperations: contextData.activitySummary.slice(0, 5).map(a => `${a.person} edited "${a.page}"`), }, }, aiProvider: providerResult.provider, @@ -662,7 +815,12 @@ Focus on what would be most relevant to them right now. If colleagues have been expiresAt, }); - loggers.api.info('Pulse cron: Generated summary for user', { userId }); + loggers.api.info('Pulse cron: Generated summary for user', { + userId, + summaryLength: summary.length, + diffCount: contentDiffs.length, + contextSize: JSON.stringify(contextData).length, + }); } // Also support GET for easy cron setup (some cron services only support GET) diff --git a/apps/web/src/app/api/pulse/generate/route.ts b/apps/web/src/app/api/pulse/generate/route.ts index 31739495b..811e493f5 100644 --- a/apps/web/src/app/api/pulse/generate/route.ts +++ b/apps/web/src/app/api/pulse/generate/route.ts @@ -29,38 +29,51 @@ import { desc, inArray, } from '@pagespace/db'; -import { loggers } from '@pagespace/lib/server'; +import { + groupActivitiesForDiff, + resolveStackedVersionContent, + generateDiffsWithinBudget, + calculateDiffBudget, + type ActivityForDiff, + type ActivityDiffGroup, + type DiffRequest, + type StackedDiff, +} from '@pagespace/lib/content'; +import { readPageContent, loggers } from '@pagespace/lib/server'; const AUTH_OPTIONS = { allow: ['session'] as const }; // System prompt for generating pulse summaries const PULSE_SYSTEM_PROMPT = `You are a friendly workspace companion who deeply understands the user's workspace and can give them genuinely useful, contextual updates. -You have access to RICH context about what's happening - actual page content, full messages, aggregated activity patterns, and more. Use this to give MEANINGFUL updates, not robotic summaries. +You have access to RICH context including ACTUAL CONTENT DIFFS showing exactly what changed. Use this to tell users WHAT was written/edited, not just that something changed. YOUR JOB: -- Tell them something they'd actually want to know -- Be specific about WHAT people are working on (you can see page content!) +- Tell them WHAT changed, not just that changes happened +- Read the diffs and summarize the actual content: "Noah added a section about Q2 pricing with 3 new tiers" - If someone messaged them, tell them what the message actually says -- Notice interesting patterns: "Noah's been really focused on the roadmap today" -- Connect the dots: "Sarah's updates to the Budget doc might be related to what Alex was asking about" +- Be specific: "Sarah updated the API docs to include OAuth2 examples" not "Sarah edited the API docs" + +READING DIFFS: +- Lines starting with + are additions (new content) +- Lines starting with - are deletions (removed content) +- Focus on the MEANING of what was added/removed, not line counts +- Summarize the substance: "Added a troubleshooting section" not "added 15 lines" TONE: -- Like a thoughtful colleague who's been paying attention +- Like a thoughtful colleague who read the changes and can summarize them - Natural and conversational -- Warm but not fake-enthusiastic - If it's quiet, just say hi - don't manufacture activity EXAMPLES OF GREAT SUMMARIES: -- "Morning! Noah's been heads-down on the Product Roadmap - he's added a whole new Q2 section with timeline estimates. Also, Sarah left you a DM asking if you've reviewed the pricing changes yet." -- "Hey! Looks like the team's been active on Sprint Planning today. Alex added some notes about the API migration, and there's a thread going in the comments about the timeline." -- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier if you want to take a look." -- "Evening! Quick catch-up: Noah finished that Q4 Projections doc you were both discussing. The final revenue numbers look different from the draft." +- "Morning! Noah's been working on the Product Roadmap - he added a whole Q2 section covering the API migration timeline and new pricing tiers. Also, Sarah left you a DM asking if the launch date is still Feb 15th." +- "Hey! Alex updated the onboarding guide with step-by-step screenshots for the new dashboard. There's also a discussion going on the Sprint Planning page about the deployment schedule." +- "Afternoon! Things are pretty quiet. Sarah shared the Budget Analysis with you earlier - it has projections through Q3." WHAT TO AVOID: -- "You have X tasks and Y pages were updated" - useless without specifics -- "Activity occurred in your workspace" - vague nonsense -- Listing every single thing that happened - pick what matters +- "5 pages were updated" - useless without substance +- "Changes were made to the document" - vague nonsense +- Reporting diff statistics like "23 lines added" - focus on meaning instead - Admitting you don't have information - just focus on what you DO know Keep it to 2-4 natural sentences. Be genuinely helpful.`; @@ -81,11 +94,6 @@ export async function POST(req: Request) { const fortyEightHoursAgo = new Date(now.getTime() - 48 * 60 * 60 * 1000); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - // Week boundaries (Sunday start) - const dayOfWeek = now.getDay(); - const startOfWeek = new Date(startOfToday); - startOfWeek.setDate(startOfWeek.getDate() - dayOfWeek); - // Get user's drives const userDrives = await db .select({ driveId: driveMembers.driveId }) @@ -94,7 +102,7 @@ export async function POST(req: Request) { const driveIds = userDrives.map(d => d.driveId); // ======================================== - // 1. WORKSPACE CONTEXT - Drives and team members + // 1. WORKSPACE CONTEXT // ======================================== const driveDetails = driveIds.length > 0 ? await db .select({ @@ -108,7 +116,7 @@ export async function POST(req: Request) { eq(drives.isTrashed, false) )) : []; - // Get team members for each drive + // Get team members const teamMembers = driveIds.length > 0 ? await db .select({ driveId: driveMembers.driveId, @@ -119,10 +127,9 @@ export async function POST(req: Request) { .leftJoin(users, eq(users.id, driveMembers.userId)) .where(and( inArray(driveMembers.driveId, driveIds), - ne(driveMembers.userId, userId) // Exclude current user + ne(driveMembers.userId, userId) )) : []; - // Group team members by drive const teamByDrive = teamMembers.reduce((acc, m) => { if (!acc[m.driveId]) acc[m.driveId] = []; acc[m.driveId].push(m.userName || m.userEmail?.split('@')[0] || 'Unknown'); @@ -130,31 +137,147 @@ export async function POST(req: Request) { }, {} as Record); // ======================================== - // 2. AGGREGATED ACTIVITY - What people are ACTUALLY working on + // 2. ACTIVITY WITH CONTENT DIFFS - The key improvement! // ======================================== - // Get all activity in last 48 hours and aggregate intelligently + // Get activity logs WITH contentRef for diff generation const rawActivity = driveIds.length > 0 ? await db .select({ + id: activityLogs.id, actorId: activityLogs.userId, actorName: activityLogs.actorDisplayName, + actorEmail: activityLogs.actorEmail, operation: activityLogs.operation, resourceType: activityLogs.resourceType, resourceId: activityLogs.resourceId, + pageId: activityLogs.pageId, resourceTitle: activityLogs.resourceTitle, driveId: activityLogs.driveId, timestamp: activityLogs.timestamp, + changeGroupId: activityLogs.changeGroupId, + aiConversationId: activityLogs.aiConversationId, + isAiGenerated: activityLogs.isAiGenerated, + contentRef: activityLogs.contentRef, + contentSnapshot: activityLogs.contentSnapshot, }) .from(activityLogs) .where( and( inArray(activityLogs.driveId, driveIds), + ne(activityLogs.userId, userId), // Only others' activity gte(activityLogs.timestamp, fortyEightHoursAgo) ) ) .orderBy(desc(activityLogs.timestamp)) - .limit(200) : []; + .limit(100) : []; + + // ======================================== + // 3. GENERATE ACTUAL CONTENT DIFFS + // ======================================== + // Filter to page content changes that we can diff + const pageActivities = rawActivity.filter( + a => a.pageId && + a.resourceType === 'page' && + (a.operation === 'update' || a.operation === 'create') && + (a.contentRef || a.contentSnapshot) + ); + + // Convert to ActivityForDiff format + const activitiesForDiff: (ActivityForDiff & { driveId: string })[] = []; + const activityContentRefs = new Map(); + + for (const activity of pageActivities) { + if (activity.contentRef) { + activityContentRefs.set(activity.id, activity.contentRef); + } + + activitiesForDiff.push({ + id: activity.id, + timestamp: activity.timestamp, + pageId: activity.pageId, + resourceTitle: activity.resourceTitle, + changeGroupId: activity.changeGroupId, + aiConversationId: activity.aiConversationId, + isAiGenerated: activity.isAiGenerated, + actorEmail: activity.actorEmail, + actorDisplayName: activity.actorName, + content: activity.contentSnapshot ?? null, + driveId: activity.driveId!, + }); + } + + // Group activities to collapse autosaves + const diffGroups = groupActivitiesForDiff(activitiesForDiff); + + // Resolve before/after content from page versions + const groupsWithChangeGroupId = diffGroups.filter( + (g: ActivityDiffGroup) => g.last.changeGroupId && g.last.pageId + ); + + const versionContentPairs = await resolveStackedVersionContent( + groupsWithChangeGroupId.map((g: ActivityDiffGroup) => ({ + changeGroupId: g.last.changeGroupId!, + pageId: g.last.pageId!, + firstContentRef: activityContentRefs.get(g.first.id) ?? null, + })) + ); + + // Build diff requests + const diffRequests: DiffRequest[] = []; + + for (const group of diffGroups) { + const firstActivity = activitiesForDiff.find(a => a.id === group.first.id); + if (!firstActivity || !firstActivity.pageId) continue; + + let beforeContent: string | null = null; + let afterContent: string | null = null; + + if (group.last.changeGroupId && group.last.pageId) { + const compositeKey = `${group.last.pageId}:${group.last.changeGroupId}`; + const versionPair = versionContentPairs.get(compositeKey); + if (versionPair) { + if (versionPair.beforeContentRef) { + try { + beforeContent = await readPageContent(versionPair.beforeContentRef); + } catch { + beforeContent = null; + } + } + if (versionPair.afterContentRef) { + try { + afterContent = await readPageContent(versionPair.afterContentRef); + } catch { + afterContent = null; + } + } + } + } + + // Fallback to inline snapshot + if (beforeContent === null && firstActivity.content) { + beforeContent = firstActivity.content; + } + + // Skip if we can't generate meaningful diff + if (afterContent === null && beforeContent === null) continue; + if (afterContent === null) continue; - // Aggregate activity by person and page - count operations instead of listing each one + diffRequests.push({ + pageId: firstActivity.pageId, + beforeContent, + afterContent, + group, + driveId: firstActivity.driveId, + }); + } + + // Generate diffs within budget (generous budget for Pulse) + const diffBudget = calculateDiffBudget(30000); // ~7.5k tokens for diffs + const contentDiffs = generateDiffsWithinBudget(diffRequests, diffBudget); + + // ======================================== + // 4. AGGREGATE ACTIVITY SUMMARY + // ======================================== + // Group by person+page for summary const activityByPersonPage: Record; - isOwnActivity: boolean; }> = {}; rawActivity.forEach(a => { @@ -179,57 +300,20 @@ export async function POST(req: Request) { driveName, editCount: 0, lastEdit: a.timestamp, - operations: new Set(), - isOwnActivity: a.actorId === userId, }; } activityByPersonPage[key].editCount++; - activityByPersonPage[key].operations.add(a.operation); if (a.timestamp > activityByPersonPage[key].lastEdit) { activityByPersonPage[key].lastEdit = a.timestamp; } }); - // Convert to array and sort by edit count (most active first) const aggregatedActivity = Object.values(activityByPersonPage) .sort((a, b) => b.editCount - a.editCount) - .slice(0, 15); - - // Separate own activity from others' activity - const othersActivity = aggregatedActivity.filter(a => !a.isOwnActivity); - const ownActivity = aggregatedActivity.filter(a => a.isOwnActivity); - - // ======================================== - // 3. PAGE CONTENT - What these pages actually contain - // ======================================== - // Get content snippets for the most active pages (by others) - const activePageIds = othersActivity.slice(0, 8).map(a => a.pageId); - - const pageContents = activePageIds.length > 0 ? await db - .select({ - id: pages.id, - title: pages.title, - content: pages.content, - type: pages.type, - updatedAt: pages.updatedAt, - }) - .from(pages) - .where(and( - inArray(pages.id, activePageIds), - eq(pages.isTrashed, false) - )) : []; - - // Create content snippets (first 1500 chars for rich context) - const pageSnippets = pageContents.map(p => ({ - id: p.id, - title: p.title, - type: p.type, - contentPreview: p.content?.substring(0, 1500) || '', - updatedAt: p.updatedAt, - })); + .slice(0, 10); // ======================================== - // 4. DIRECT MESSAGES - Full content, not truncated + // 5. DIRECT MESSAGES - Full content // ======================================== const userConversations = await db .select({ id: dmConversations.id }) @@ -266,15 +350,14 @@ export async function POST(req: Request) { unreadDMs = unreadMessagesList.map(m => ({ from: m.senderName || m.senderEmail?.split('@')[0] || 'Someone', - content: m.content || '', // Full content, no truncation + content: m.content || '', sentAt: m.createdAt, })); } // ======================================== - // 5. PAGE CHAT MESSAGES - Conversations happening on pages + // 6. PAGE CHAT DISCUSSIONS // ======================================== - // Get recent chat messages on pages in user's drives (excluding AI responses) const recentPageChats = driveIds.length > 0 ? await db .select({ pageId: chatMessages.pageId, @@ -282,7 +365,6 @@ export async function POST(req: Request) { senderName: users.name, senderEmail: users.email, content: chatMessages.content, - role: chatMessages.role, createdAt: chatMessages.createdAt, }) .from(chatMessages) @@ -291,16 +373,15 @@ export async function POST(req: Request) { .where( and( inArray(pages.driveId, driveIds), - eq(chatMessages.role, 'user'), // Only human messages + eq(chatMessages.role, 'user'), eq(chatMessages.isActive, true), gte(chatMessages.createdAt, twentyFourHoursAgo), - ne(chatMessages.userId, userId) // Messages from others + ne(chatMessages.userId, userId) ) ) .orderBy(desc(chatMessages.createdAt)) .limit(15) : []; - // Group chat messages by page const chatsByPage = recentPageChats.reduce((acc, chat) => { const key = chat.pageId; if (!acc[key]) { @@ -318,7 +399,7 @@ export async function POST(req: Request) { }, {} as Record); // ======================================== - // 6. MENTIONS & SHARES + // 7. MENTIONS & SHARES // ======================================== const recentMentions = await db .select({ @@ -341,7 +422,6 @@ export async function POST(req: Request) { const recentShares = await db .select({ pageTitle: pages.title, - pageContent: pages.content, sharedByName: users.name, grantedAt: pagePermissions.grantedAt, }) @@ -358,16 +438,12 @@ export async function POST(req: Request) { .limit(5); // ======================================== - // 7. TASKS - With full context + // 8. TASKS // ======================================== const endOfToday = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); const overdueTasks = await db - .select({ - title: taskItems.title, - priority: taskItems.priority, - dueDate: taskItems.dueDate, - }) + .select({ title: taskItems.title, priority: taskItems.priority }) .from(taskItems) .where( and( @@ -376,14 +452,11 @@ export async function POST(req: Request) { lt(taskItems.dueDate, startOfToday) ) ) - .orderBy(desc(taskItems.priority), taskItems.dueDate) - .limit(10); + .orderBy(desc(taskItems.priority)) + .limit(5); const todayTasks = await db - .select({ - title: taskItems.title, - priority: taskItems.priority, - }) + .select({ title: taskItems.title, priority: taskItems.priority }) .from(taskItems) .where( and( @@ -393,30 +466,14 @@ export async function POST(req: Request) { lt(taskItems.dueDate, endOfToday) ) ) - .orderBy(desc(taskItems.priority)) - .limit(10); - - const recentlyCompletedTasks = await db - .select({ title: taskItems.title, completedAt: taskItems.completedAt }) - .from(taskItems) - .where( - and( - or(eq(taskItems.assigneeId, userId), eq(taskItems.userId, userId)), - eq(taskItems.status, 'completed'), - gte(taskItems.completedAt, twentyFourHoursAgo) - ) - ) - .orderBy(desc(taskItems.completedAt)) .limit(5); // ======================================== - // BUILD THE RICH CONTEXT OBJECT + // BUILD THE RICH CONTEXT WITH DIFFS // ======================================== const contextData = { - // User context userName, - // Workspace overview workspace: { drives: driveDetails.map(d => ({ name: d.name, @@ -425,23 +482,27 @@ export async function POST(req: Request) { })), }, - // What others are working on (aggregated, not raw operations) - colleagueActivity: othersActivity.map(a => ({ + // THE KEY: Actual content diffs showing WHAT changed + contentChanges: contentDiffs.map((diff: StackedDiff & { driveId: string }) => ({ + page: diff.pageTitle || 'Untitled', + actors: diff.actors, + editCount: diff.collapsedCount, + timeRange: diff.timeRange, + isAiGenerated: diff.isAiGenerated, + // The actual diff showing what was written/changed + diff: diff.unifiedDiff, + stats: diff.stats, + })), + + // Activity summary (for context on who's been active) + activitySummary: aggregatedActivity.map(a => ({ person: a.person, page: a.pageTitle, drive: a.driveName, editCount: a.editCount, - actions: Array.from(a.operations), lastActive: a.lastEdit.toISOString(), })), - // Actual page content for context - activePageContent: pageSnippets.map(p => ({ - title: p.title, - type: p.type, - preview: p.contentPreview, - })), - // Direct messages - full content directMessages: unreadDMs.map(m => ({ from: m.from, @@ -449,7 +510,7 @@ export async function POST(req: Request) { sentAt: m.sentAt.toISOString(), })), - // Page chat discussions + // Page discussions pageDiscussions: Object.entries(chatsByPage).map(([_, data]) => ({ page: data.pageTitle, messages: data.messages.map(m => ({ @@ -459,44 +520,20 @@ export async function POST(req: Request) { })), })), - // Mentions mentions: recentMentions.map(m => ({ by: m.mentionedByName || 'Someone', inPage: m.pageTitle || 'a page', - when: m.createdAt.toISOString(), })), - // Shares (with content preview) sharedWithYou: recentShares.map(s => ({ page: s.pageTitle || 'a page', by: s.sharedByName || 'Someone', - preview: s.pageContent?.substring(0, 500) || '', - when: s.grantedAt?.toISOString(), })), - // Tasks tasks: { - overdue: overdueTasks.map(t => ({ - title: t.title, - priority: t.priority, - dueDate: t.dueDate?.toISOString(), - })), - dueToday: todayTasks.map(t => ({ - title: t.title, - priority: t.priority, - })), - recentlyCompleted: recentlyCompletedTasks.map(t => ({ - title: t.title, - completedAt: t.completedAt?.toISOString(), - })), + overdue: overdueTasks.map(t => ({ title: t.title, priority: t.priority })), + dueToday: todayTasks.map(t => ({ title: t.title, priority: t.priority })), }, - - // User's own recent activity (for context on what they've been doing) - ownRecentActivity: ownActivity.slice(0, 5).map(a => ({ - page: a.pageTitle, - editCount: a.editCount, - lastActive: a.lastEdit.toISOString(), - })), }; // Determine greeting based on time of day @@ -512,15 +549,22 @@ export async function POST(req: Request) { Time: ${timeOfDay} Current time: ${now.toISOString()} -Here's the full context of what's happening in their workspace: +Here's what's happening in their workspace, INCLUDING ACTUAL CONTENT DIFFS: ${JSON.stringify(contextData, null, 2)} -Based on this rich context, write a natural 2-4 sentence update that tells them something genuinely useful. You can see actual page content, full message text, and aggregated activity patterns - use this to be specific and helpful. +IMPORTANT: The "contentChanges" array contains actual diffs showing what was written/changed. Read these diffs and summarize WHAT the content says, not just that changes were made. -Focus on what would be most relevant to them right now. If colleagues have been active, tell them WHAT those colleagues are working on (you can see the content!). If there are messages, summarize what they actually say. If it's quiet, just give a warm greeting.`; +For example, if you see a diff like: ++ ## Q2 Timeline ++ - Sprint 1: API Migration ++ - Sprint 2: New Dashboard - // Get AI provider (use standard model) +Say something like "Noah added a Q2 timeline covering the API migration and new dashboard sprints" + +Write a natural 2-4 sentence update that tells them something genuinely useful about what changed.`; + + // Get AI provider const providerResult = await createAIProvider(userId, { selectedProvider: 'pagespace', selectedModel: 'standard', @@ -542,14 +586,14 @@ Focus on what would be most relevant to them right now. If colleagues have been const summary = result.text.trim(); - // Extract greeting if present (first sentence ending with !) + // Extract greeting let greeting: string | null = null; const greetingMatch = summary.match(/^([^.!?]+[!])\s*/); if (greetingMatch) { greeting = greetingMatch[1]; } - // Save to database (store simplified context for transparency) + // Save to database const twoHoursAgo = new Date(now.getTime() - 2 * 60 * 60 * 1000); const expiresAt = new Date(now.getTime() + 2 * 60 * 60 * 1000); @@ -560,18 +604,18 @@ Focus on what would be most relevant to them right now. If colleagues have been type: 'on_demand', contextData: { workspace: contextData.workspace, - workingOn: contextData.colleagueActivity.slice(0, 5).map(a => ({ + workingOn: contextData.activitySummary.slice(0, 5).map(a => ({ person: a.person, page: a.page, driveName: a.drive, - action: a.actions[0] || 'update', + action: 'update', })), tasks: { dueToday: contextData.tasks.dueToday.length, dueThisWeek: 0, overdue: contextData.tasks.overdue.length, - completedThisWeek: contextData.tasks.recentlyCompleted.length, - recentlyCompleted: contextData.tasks.recentlyCompleted.map(t => t.title).filter((t): t is string => !!t), + completedThisWeek: 0, + recentlyCompleted: [], upcoming: contextData.tasks.dueToday.map(t => t.title).filter((t): t is string => !!t), overdueItems: contextData.tasks.overdue.map(t => ({ title: t.title || '', priority: t.priority })), }, @@ -583,15 +627,18 @@ Focus on what would be most relevant to them right now. If colleagues have been mentions: contextData.mentions.map(m => ({ by: m.by, inPage: m.inPage })), notifications: [], sharedWithYou: contextData.sharedWithYou.map(s => ({ page: s.page, by: s.by })), - contentChanges: contextData.colleagueActivity.slice(0, 5).map(a => ({ page: a.page, by: a.person })), + contentChanges: contextData.contentChanges.slice(0, 5).map((c: { page: string; actors: string[] }) => ({ + page: c.page, + by: c.actors[0] || 'Someone', + })), pages: { - updatedToday: contextData.colleagueActivity.filter(a => new Date(a.lastActive) >= startOfToday).length, - updatedThisWeek: contextData.colleagueActivity.length, - recentlyUpdated: contextData.colleagueActivity.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), + updatedToday: contextData.activitySummary.filter(a => new Date(a.lastActive) >= startOfToday).length, + updatedThisWeek: contextData.activitySummary.length, + recentlyUpdated: contextData.activitySummary.slice(0, 5).map(a => ({ title: a.page, updatedBy: a.person })), }, activity: { - collaboratorNames: [...new Set(contextData.colleagueActivity.map(a => a.person))], - recentOperations: contextData.colleagueActivity.slice(0, 5).map(a => `${a.person} edited "${a.page}" (${a.editCount} times)`), + collaboratorNames: [...new Set(contextData.activitySummary.map(a => a.person))], + recentOperations: contextData.activitySummary.slice(0, 5).map(a => `${a.person} edited "${a.page}"`), }, }, aiProvider: providerResult.provider, @@ -606,6 +653,7 @@ Focus on what would be most relevant to them right now. If colleagues have been userId, summaryId: savedSummary.id, summaryLength: summary.length, + diffCount: contentDiffs.length, contextSize: JSON.stringify(contextData).length, });