mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
email drafts
This commit is contained in:
parent
38b1f78ede
commit
75e0f50855
3 changed files with 154 additions and 18 deletions
|
|
@ -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) => `<p>${escapeHtml(para).replace(/\n/g, '<br />')}</p>`)
|
||||
.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)
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
|
|
@ -31,23 +102,46 @@ export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue