From e7f6e85880bf1dfd0e29e0891e00b13fe64386c7 Mon Sep 17 00:00:00 2001 From: Jason Carreira Date: Tue, 3 Mar 2026 14:24:58 -0500 Subject: [PATCH] feat(polling): add custom prompt support for Gmail email polling (#471) Co-authored-by: Letta Code --- docs/configuration.md | 53 +++++++++++++- src/config/types.ts | 17 ++++- src/core/prompts.ts | 59 +++++++++++++++ src/main.ts | 7 +- src/polling/service.test.ts | 63 +++++++++++++--- src/polling/service.ts | 138 ++++++++++++++++++++++++++++-------- 6 files changed, 294 insertions(+), 43 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index c1e672c..accc799 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -728,7 +728,58 @@ polling: | `polling.intervalMs` | number | `60000` | Polling interval in milliseconds | | `polling.gmail.enabled` | boolean | auto | Enable Gmail polling. Auto-detected from `account` or `accounts` | | `polling.gmail.account` | string | - | Gmail account to poll for unread messages | -| `polling.gmail.accounts` | string[] | - | Gmail accounts to poll for unread messages | +| `polling.gmail.accounts` | (string \| object)[] | - | Gmail accounts to poll. Can be strings or objects with `account`, `prompt`, `promptFile` | +| `polling.gmail.prompt` | string | - | Default custom prompt for all accounts | +| `polling.gmail.promptFile` | string | - | Path to default prompt file for all accounts | + +### Custom Email Prompts + +You can customize what the agent is told when new emails are detected. The custom text replaces the default body while keeping the silent mode envelope (account, time, trigger metadata, and messaging instructions). + +**Inline prompt for all accounts:** + +```yaml +polling: + gmail: + enabled: true + prompt: "Summarize these emails and flag anything urgent." + accounts: + - user@example.com +``` + +**Per-account prompts:** + +```yaml +polling: + gmail: + enabled: true + prompt: "Review these emails and notify me of anything important." + accounts: + - "personal@gmail.com" # Uses global prompt above + - account: "work@company.com" + prompt: "Focus on emails from executives and flag urgent matters." + - account: "notifications@example.com" + promptFile: ./prompts/notifications.txt # Re-read each poll +``` + +**Prompt file for live editing:** + +```yaml +polling: + gmail: + enabled: true + promptFile: ./prompts/email-review.md # Re-read each poll for live editing + accounts: + - user@example.com +``` + +**Priority order:** + +1. Account-specific `prompt` (inline in accounts array) +2. Account-specific `promptFile` +3. Global `polling.gmail.prompt` +4. Global `polling.gmail.promptFile` +5. Built-in default prompt ### Legacy config path diff --git a/src/config/types.ts b/src/config/types.ts index 02c9ffb..64cfb6a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -239,13 +239,26 @@ export interface TranscriptionConfig { model?: string; // Defaults to 'whisper-1' (OpenAI) or 'voxtral-mini-latest' (Mistral) } +export interface GmailAccountConfig { + /** Gmail account email address */ + account: string; + /** Custom email prompt for this account (inline) - replaces default body */ + prompt?: string; + /** Path to prompt file (re-read each poll for live editing) */ + promptFile?: string; +} + export interface PollingYamlConfig { enabled?: boolean; // Master switch (default: auto-detected from sub-configs) intervalMs?: number; // Polling interval in milliseconds (default: 60000) gmail?: { enabled?: boolean; // Enable Gmail polling - account?: string; // Gmail account to poll (e.g., user@example.com) - accounts?: string[]; // Multiple Gmail accounts to poll + account?: string; // Single Gmail account (simple string form) + accounts?: (string | GmailAccountConfig)[]; // Multiple accounts (string or config object) + /** Default prompt for all accounts (can be overridden per-account) */ + prompt?: string; + /** Default prompt file for all accounts (re-read each poll for live editing) */ + promptFile?: string; }; } diff --git a/src/core/prompts.ts b/src/core/prompts.ts index a299e21..f1b19b3 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -232,6 +232,65 @@ something they need to know or act on. `.trim(); } +/** + * Email prompt (silent mode) - for Gmail polling + */ +export function buildEmailPrompt( + account: string, + emailCount: number, + emailData: string, + time: string +): string { + return ` +${SILENT_MODE_PREFIX} + +TRIGGER: Email polling +ACCOUNT: ${account} +TIME: ${time} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +YOUR TEXT OUTPUT IS PRIVATE - only you can see it. +To notify your human about important emails, run: + lettabot-message send --text "Important email: ..." + +NEW EMAILS (${emailCount}): +${emailData} + +Review and summarize important emails. Use \`lettabot-message send --text "..."\` to notify the user if needed. +`.trim(); +} + +/** + * Custom email prompt - wraps user-provided text with silent mode envelope + */ +export function buildCustomEmailPrompt( + customPrompt: string, + account: string, + emailCount: number, + emailData: string, + time: string +): string { + return ` +${SILENT_MODE_PREFIX} + +TRIGGER: Email polling +ACCOUNT: ${account} +TIME: ${time} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +YOUR TEXT OUTPUT IS PRIVATE - only you can see it. +To notify your human, run: + lettabot-message send --text "Your message here" + +${customPrompt} + +NEW EMAILS (${emailCount}): +${emailData} +`.trim(); +} + /** * Base persona addition for message CLI awareness * diff --git a/src/main.ts b/src/main.ts index bbe88cd..6dbf45f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -745,7 +745,12 @@ async function main() { ?? (agentConfig.integrations?.google?.pollIntervalSec ? agentConfig.integrations.google.pollIntervalSec * 1000 : 60000), - gmail: { enabled: gmailEnabled, accounts: gmailAccounts }, + gmail: { + enabled: gmailEnabled, + accounts: gmailAccounts, + prompt: agentConfig.polling?.gmail?.prompt, + promptFile: agentConfig.polling?.gmail?.promptFile, + }, }; })(); diff --git a/src/polling/service.test.ts b/src/polling/service.test.ts index b6d3cda..14287dd 100644 --- a/src/polling/service.test.ts +++ b/src/polling/service.test.ts @@ -11,34 +11,81 @@ describe('parseGmailAccounts', () => { }); it('parses single account string', () => { - expect(parseGmailAccounts('user@gmail.com')).toEqual(['user@gmail.com']); + expect(parseGmailAccounts('user@gmail.com')).toEqual([{ account: 'user@gmail.com' }]); }); it('parses comma-separated string', () => { - expect(parseGmailAccounts('a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts('a@gmail.com,b@gmail.com')).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); }); it('trims whitespace', () => { - expect(parseGmailAccounts(' a@gmail.com , b@gmail.com ')).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts(' a@gmail.com , b@gmail.com ')).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); }); it('deduplicates accounts', () => { - expect(parseGmailAccounts('a@gmail.com,a@gmail.com,b@gmail.com')).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts('a@gmail.com,a@gmail.com,b@gmail.com')).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); }); it('skips empty segments', () => { - expect(parseGmailAccounts('a@gmail.com,,b@gmail.com,')).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts('a@gmail.com,,b@gmail.com,')).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); }); it('accepts string array', () => { - expect(parseGmailAccounts(['a@gmail.com', 'b@gmail.com'])).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts(['a@gmail.com', 'b@gmail.com'])).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); }); it('deduplicates string array', () => { - expect(parseGmailAccounts(['a@gmail.com', 'a@gmail.com'])).toEqual(['a@gmail.com']); + expect(parseGmailAccounts(['a@gmail.com', 'a@gmail.com'])).toEqual([{ account: 'a@gmail.com' }]); }); it('trims string array values', () => { - expect(parseGmailAccounts([' a@gmail.com ', ' b@gmail.com '])).toEqual(['a@gmail.com', 'b@gmail.com']); + expect(parseGmailAccounts([' a@gmail.com ', ' b@gmail.com '])).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com' }, + ]); + }); + + it('accepts GmailAccountConfig array', () => { + expect(parseGmailAccounts([ + { account: 'a@gmail.com', prompt: 'Check urgent emails' }, + { account: 'b@gmail.com' }, + ])).toEqual([ + { account: 'a@gmail.com', prompt: 'Check urgent emails' }, + { account: 'b@gmail.com' }, + ]); + }); + + it('accepts mixed array of strings and config objects', () => { + expect(parseGmailAccounts([ + 'a@gmail.com', + { account: 'b@gmail.com', prompt: 'Only important' }, + ])).toEqual([ + { account: 'a@gmail.com' }, + { account: 'b@gmail.com', prompt: 'Only important' }, + ]); + }); + + it('deduplicates mixed array by account', () => { + expect(parseGmailAccounts([ + 'a@gmail.com', + { account: 'a@gmail.com', prompt: 'Override' }, + ])).toEqual([ + { account: 'a@gmail.com' }, // First one wins + ]); }); }); diff --git a/src/polling/service.ts b/src/polling/service.ts index 9f8445e..0e1711a 100644 --- a/src/polling/service.ts +++ b/src/polling/service.ts @@ -7,26 +7,55 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { resolve, join } from 'node:path'; import type { AgentSession } from '../core/interfaces.js'; +import type { TriggerContext } from '../core/types.js'; +import type { GmailAccountConfig } from '../config/types.js'; +import { buildEmailPrompt, buildCustomEmailPrompt } from '../core/prompts.js'; import { createLogger } from '../logger.js'; const log = createLogger('Polling'); + /** - * Parse Gmail accounts from a string (comma-separated) or string array. - * Deduplicates and trims whitespace. + * Parse Gmail accounts from various formats. + * Handles: string (comma-separated), string array, or GmailAccountConfig array. + * Deduplicates by account email. */ -export function parseGmailAccounts(raw?: string | string[]): string[] { +export function parseGmailAccounts(raw?: string | (string | GmailAccountConfig)[]): GmailAccountConfig[] { if (!raw) return []; - const values = Array.isArray(raw) ? raw : raw.split(','); - const seen = new Set(); - for (const value of values) { - const trimmed = value.trim(); - if (!trimmed || seen.has(trimmed)) continue; - seen.add(trimmed); + + let items: (string | GmailAccountConfig)[]; + if (typeof raw === 'string') { + items = raw.split(',').map(s => s.trim()).filter(Boolean); + } else { + items = raw; } - return Array.from(seen); + + const seen = new Set(); + const result: GmailAccountConfig[] = []; + + for (const item of items) { + if (typeof item === 'string') { + const account = item.trim(); + if (account && !seen.has(account)) { + seen.add(account); + result.push({ account }); + } + } else if (item && typeof item === 'object' && item.account) { + const account = item.account.trim(); + if (account && !seen.has(account)) { + seen.add(account); + result.push({ + account, + prompt: item.prompt, + promptFile: item.promptFile, + }); + } + } + } + + return result; } export interface PollingConfig { @@ -34,7 +63,11 @@ export interface PollingConfig { workingDir: string; // For persisting state gmail?: { enabled: boolean; - accounts: string[]; + accounts: GmailAccountConfig[]; + /** Default prompt for all accounts (can be overridden per-account) */ + prompt?: string; + /** Default prompt file for all accounts (re-read each poll for live editing) */ + promptFile?: string; }; } @@ -77,7 +110,7 @@ export class PollingService { // Legacy single-account format: { ids: [...] } if (data && Array.isArray(data.ids)) { const accounts = this.config.gmail?.accounts || []; - const targetAccount = accounts[0]; + const targetAccount = accounts[0]?.account; if (targetAccount) { this.seenEmailIdsByAccount.set(targetAccount, new Set(data.ids)); log.info(`Migrated legacy seen emails to ${targetAccount}`); @@ -159,16 +192,56 @@ export class PollingService { */ private async poll(): Promise { if (this.config.gmail?.enabled) { - for (const account of this.config.gmail.accounts) { - await this.pollGmail(account); + for (const accountConfig of this.config.gmail.accounts) { + await this.pollGmail(accountConfig); } } } + /** + * Resolve custom prompt for an account. + * Priority: account-specific > global default > none (use built-in prompt) + */ + private resolvePrompt(accountConfig: GmailAccountConfig): string | undefined { + const gmail = this.config.gmail; + if (!gmail) return undefined; + + // Check for account-specific prompt first + if (accountConfig.prompt) { + return accountConfig.prompt; + } + + // Check for account-specific promptFile + if (accountConfig.promptFile) { + try { + return readFileSync(resolve(this.config.workingDir, accountConfig.promptFile), 'utf-8').trim(); + } catch (err) { + log.error(`Failed to read promptFile for ${accountConfig.account}:`, err); + } + } + + // Fall back to global prompt + if (gmail.prompt) { + return gmail.prompt; + } + + // Fall back to global promptFile + if (gmail.promptFile) { + try { + return readFileSync(resolve(this.config.workingDir, gmail.promptFile), 'utf-8').trim(); + } catch (err) { + log.error(`Failed to read global promptFile:`, err); + } + } + + return undefined; + } + /** * Poll Gmail for new unread messages */ - private async pollGmail(account: string): Promise { + private async pollGmail(accountConfig: GmailAccountConfig): Promise { + const account = accountConfig.account; if (!account) return; if (!this.seenEmailIdsByAccount.has(account)) { this.seenEmailIdsByAccount.set(account, new Set()); @@ -229,22 +302,25 @@ export class PollingService { const header = lines[0]; const newEmailsOutput = [header, ...newEmails].join('\n'); - // Send to agent for processing (SILENT MODE - no auto-delivery) - // Agent must use `lettabot-message` CLI to notify user - const message = [ - '╔════════════════════════════════════════════════════════════════╗', - '║ [SILENT MODE] - Your text output is NOT sent to anyone. ║', - '║ To send a message, use: lettabot-message send --text "..." ║', - '╚════════════════════════════════════════════════════════════════╝', - '', - `[email] ${newEmails.length} new unread email(s) for ${account}:`, - '', - newEmailsOutput, - '', - 'Review and summarize important emails. Use `lettabot-message send --text "..."` to notify the user if needed.', - ].join('\n'); + // Resolve custom prompt (re-read each poll for live editing) + const customPrompt = this.resolvePrompt(accountConfig); + const now = new Date(); + const time = now.toLocaleString(); - const response = await this.bot.sendToAgent(message); + // Build message using prompt builders + const message = customPrompt + ? buildCustomEmailPrompt(customPrompt, account, newEmails.length, newEmailsOutput, time) + : buildEmailPrompt(account, newEmails.length, newEmailsOutput, time); + + // Build trigger context for silent mode + const context: TriggerContext = { + type: 'feed', + outputMode: 'silent', + sourceChannel: 'gmail', + sourceChatId: account, + }; + + const response = await this.bot.sendToAgent(message, context); // Log response but do NOT auto-deliver (silent mode) log.info(`Agent finished (SILENT MODE)`);