diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 7f92012e..8894dedd 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -398,6 +398,14 @@ function ComposeBox({ const latest = latestMessage(thread) const to = mode === 'reply' ? extractAddress(latest?.from) : '' + const initialContent = useMemo(() => { + if (mode !== 'reply' || !thread.draft_response) return '' + return thread.draft_response + .split(/\n{2,}/) + .map((para) => `

${escapeHtml(para).replace(/\n/g, '
')}

`) + .join('') + }, [mode, thread.draft_response]) + const editor = useEditor({ extensions: [ StarterKit, @@ -409,7 +417,7 @@ function ComposeBox({ editorProps: { attributes: { class: 'gmail-compose-content' }, }, - content: '', + content: initialContent, }) const [linkOpen, setLinkOpen] = useState(false) diff --git a/apps/x/packages/core/src/knowledge/classify_thread.ts b/apps/x/packages/core/src/knowledge/classify_thread.ts index c60b821a..1a742ecc 100644 --- a/apps/x/packages/core/src/knowledge/classify_thread.ts +++ b/apps/x/packages/core/src/knowledge/classify_thread.ts @@ -1,7 +1,10 @@ +import fs from 'fs'; +import path from 'path'; import { z } from 'zod'; import { generateObject } from 'ai'; import { google } from 'googleapis'; import type { OAuth2Client } from 'google-auth-library'; +import { WorkDir } from '../config/config.js'; import { createProvider } from '../models/models.js'; import { getDefaultModelAndProvider, @@ -11,6 +14,74 @@ import { import { captureLlmUsage } from '../analytics/usage.js'; import type { GmailThreadSnapshot } from './sync_gmail.js'; +const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md'); +const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync'); +const CALENDAR_LOOKAHEAD_DAYS = 7; +const MAX_CALENDAR_EVENTS = 25; + +function readEmailStyleGuide(): string | null { + try { + const raw = fs.readFileSync(STYLE_GUIDE_PATH, 'utf-8').trim(); + return raw || null; + } catch { + return null; + } +} + +interface CalendarSlice { + summary: string; + startIso: string; + endIso?: string; +} + +function readUpcomingCalendar(): CalendarSlice[] { + if (!fs.existsSync(CALENDAR_DIR)) return []; + const now = Date.now(); + const cutoff = now + CALENDAR_LOOKAHEAD_DAYS * 24 * 60 * 60 * 1000; + const out: CalendarSlice[] = []; + let names: string[]; + try { + names = fs.readdirSync(CALENDAR_DIR); + } catch { + return []; + } + for (const name of names) { + if (!name.endsWith('.json')) continue; + try { + const raw = fs.readFileSync(path.join(CALENDAR_DIR, name), 'utf-8'); + const ev = JSON.parse(raw) as { + summary?: string; + start?: { dateTime?: string; date?: string }; + end?: { dateTime?: string; date?: string }; + status?: string; + }; + if (ev.status === 'cancelled') continue; + const startStr = ev.start?.dateTime ?? ev.start?.date; + if (!startStr) continue; + const startMs = Date.parse(startStr); + if (Number.isNaN(startMs)) continue; + if (startMs < now || startMs > cutoff) continue; + out.push({ + summary: ev.summary || '(no title)', + startIso: startStr, + endIso: ev.end?.dateTime ?? ev.end?.date, + }); + } catch { + // skip malformed + } + } + out.sort((a, b) => Date.parse(a.startIso) - Date.parse(b.startIso)); + return out.slice(0, MAX_CALENDAR_EVENTS); +} + +function formatCalendar(events: CalendarSlice[]): string { + if (events.length === 0) return '(no upcoming events)'; + return events.map((e) => { + const end = e.endIso ? ` – ${e.endIso}` : ''; + return `- ${e.startIso}${end}: ${e.summary}`; + }).join('\n'); +} + let cachedUserEmail: string | null = null; export async function getUserEmail(auth: OAuth2Client): Promise { @@ -31,23 +102,46 @@ export async function getUserEmail(auth: OAuth2Client): Promise { export interface Classification { importance: 'important' | 'other'; summary?: string; + draftResponse?: string; } const ClassificationSchema = z.object({ importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'), summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'), + draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'), }); -const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view. +const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user. + +# Importance Decide if the thread is "important" or "other": - - important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions). - other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach. -When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied (e.g. "Customer requesting a demo next Tuesday; needs a calendar link." or "Investor following up on Q3 metrics; reply expected."). Omit the summary when the thread is "other". +# Summary (important only) -Be decisive — pick exactly one label. Do not hedge.`; +When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied. Omit when "other". + +# Draft response (important only) + +When the thread is important AND a reply is reasonably expected from the user, write a complete draft reply they could send as-is. + +Apply the user's email-style guide (when provided below) — match their tone, sign-off, length, and phrasing patterns. If no style guide is provided, default to a brief, warm, professional voice. + +For scheduling-related threads (where the sender proposes meeting times, asks for the user's availability, or follows up on a meeting request), look at the user's upcoming calendar (provided below) and either: +- Propose 2-3 specific time windows from genuinely free slots, or +- Confirm/decline a specific time the sender proposed, based on calendar conflicts. + +Use the same timezone the user appears to operate in (inferable from their previous messages or calendar events). + +Omit the draft when: +- importance is "other" +- the thread is purely informational and doesn't ask for a reply +- the latest message is from the user (they already replied; no draft needed) +- you can't write a meaningful reply without information you don't have (don't fabricate) + +Be decisive — pick exactly one importance label. Do not hedge.`; function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): boolean { if (!userEmail) return false; @@ -55,22 +149,50 @@ function userReplied(snapshot: GmailThreadSnapshot, userEmail: string | null): b return snapshot.messages.some(m => (m.from || '').toLowerCase().includes(needle)); } -function buildPrompt(snapshot: GmailThreadSnapshot): string { +function buildPrompt( + snapshot: GmailThreadSnapshot, + userEmail: string | null, + styleGuide: string | null, + calendar: CalendarSlice[], +): string { const lines: string[] = []; + + if (userEmail) { + lines.push(`# Your identity`); + lines.push(`The user's own email is ${userEmail}. You write as this person when drafting replies.`); + lines.push(''); + } + + if (styleGuide) { + lines.push(`# Email style guide`); + lines.push(styleGuide); + lines.push(''); + } + + lines.push(`# User's upcoming calendar (next ${CALENDAR_LOOKAHEAD_DAYS} days)`); + lines.push(formatCalendar(calendar)); + lines.push(''); + + lines.push(`# Thread to classify`); lines.push(`Subject: ${snapshot.subject || '(no subject)'}`); lines.push(`Message count: ${snapshot.messages.length}`); lines.push(''); - const latest = snapshot.messages[snapshot.messages.length - 1]; - if (latest) { - lines.push(`Latest message:`); - lines.push(` From: ${latest.from || 'unknown'}`); - if (latest.to) lines.push(` To: ${latest.to}`); - if (latest.date) lines.push(` Date: ${latest.date}`); + + for (let i = 0; i < snapshot.messages.length; i += 1) { + const msg = snapshot.messages[i]; + const isLast = i === snapshot.messages.length - 1; + lines.push(`## Message ${i + 1}${isLast ? ' (latest)' : ''}`); + lines.push(`From: ${msg.from || 'unknown'}`); + if (msg.to) lines.push(`To: ${msg.to}`); + if (msg.date) lines.push(`Date: ${msg.date}`); + const body = (msg.body || '').replace(/\s+/g, ' ').slice(0, isLast ? 2000 : 600).trim(); + if (body) { + lines.push(''); + lines.push(body); + } lines.push(''); - const snippet = (latest.body || '').replace(/\s+/g, ' ').slice(0, 1200).trim(); - lines.push(` Body:`); - lines.push(` ${snippet}`); } + return lines.join('\n'); } @@ -83,6 +205,9 @@ export async function classifyThread( } try { + const styleGuide = readEmailStyleGuide(); + const calendar = readUpcomingCalendar(); + const modelId = await getKgModel(); const { provider } = await getDefaultModelAndProvider(); const config = await resolveProviderConfig(provider); @@ -91,7 +216,7 @@ export async function classifyThread( const result = await generateObject({ model, system: SYSTEM_PROMPT, - prompt: buildPrompt(snapshot), + prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar), schema: ClassificationSchema, }); @@ -104,8 +229,9 @@ export async function classifyThread( }); const out: Classification = { importance: result.object.importance }; - if (result.object.importance === 'important' && result.object.summary) { - out.summary = result.object.summary; + if (result.object.importance === 'important') { + if (result.object.summary) out.summary = result.object.summary; + if (result.object.draftResponse) out.draftResponse = result.object.draftResponse; } return out; } catch (err) { diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index c454fadb..ed92ef70 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -82,6 +82,7 @@ export interface GmailThreadSnapshot { past_summary?: string; unread?: boolean; importance?: 'important' | 'other'; + draft_response?: string; messages: Array<{ id?: string; from?: string; @@ -405,6 +406,7 @@ export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?: const classification = await classifyThread(snapshot, userEmail); snapshot.importance = classification.importance; if (classification.summary) snapshot.summary = classification.summary; + if (classification.draftResponse) snapshot.draft_response = classification.draftResponse; } catch (err) { console.warn(`[Gmail] classify failed for ${threadId}:`, err); }