diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8e..a0f9dc53a 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -17,6 +17,7 @@ import { capture as analyticsCapture, identify as analyticsIdentify, reset as an import { isSignedIn } from '@x/core/dist/account/account.js'; import { getWebappUrl } from '@x/core/dist/config/remote-config.js'; import { claimTokensViaBackend } from '@x/core/dist/auth/google-backend-oauth.js'; +import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js'; function buildRedirectUri(port: number): string { return `http://localhost:${port}/oauth/callback`; @@ -330,6 +331,9 @@ export async function connectProvider(provider: string, credentials?: { clientId if (provider === 'google') { triggerGmailSync(); triggerCalendarSync(); + // Copilot instructions route email tasks based on native Gmail + // connection state — rebuild them on the next agent run. + invalidateCopilotInstructionsCache(); } else if (provider === 'fireflies-ai') { triggerFirefliesSync(); } @@ -481,6 +485,7 @@ export async function completeRowboatGoogleConnect(state: string): Promise }); triggerGmailSync(); triggerCalendarSync(); + invalidateCopilotInstructionsCache(); emitOAuthEvent({ provider: 'google', success: true }); console.log('[OAuth] Rowboat-mode Google connect complete'); } catch (error) { @@ -519,6 +524,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b } await oauthRepo.delete(provider); + if (provider === 'google') { + invalidateCopilotInstructionsCache(); + } if (provider === 'rowboat') { analyticsCapture('user_signed_out'); analyticsReset(); @@ -598,6 +606,7 @@ export async function disconnectGoogleIfScopesStale(): Promise { tokens: null, error: 'Google permissions changed. Please reconnect to continue.', }); + invalidateCopilotInstructionsCache(); // Nudge any already-open window to re-read state. The renderer's initial // mount also re-reads, so the prompt shows even if no window is up yet. diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index b3611fe4e..71ca4d6b1 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -5,6 +5,7 @@ import { isConfigured as isComposioConfigured } from "../../composio/client.js"; import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js"; import container from "../../di/container.js"; import type { ICodeModeConfigRepo } from "../../code-mode/repo.js"; +import { getConnectionStatus as getGmailConnectionStatus } from "../../knowledge/sync_gmail.js"; const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext()); @@ -31,19 +32,35 @@ Load the \`composio-integration\` skill when the user asks to interact with any `; } -function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string { - // Conditionally include Composio-related instruction sections - const emailDraftSuffix = composioEnabled - ? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.` - : ` Do NOT load this skill for reading, fetching, or checking emails.`; +function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true, nativeGmailConnected: boolean = false): string { + // Conditionally include Composio- and native-Gmail-related instruction sections + const emailDraftSuffix = nativeGmailConnected + ? ` Do NOT load this skill for reading, fetching, or checking emails — load the \`read-emails\` skill and use the native \`gmail-*\` tools for that instead.` + : composioEnabled + ? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.` + : ` Do NOT load this skill for reading, fetching, or checking emails.`; - const thirdPartyBlock = composioEnabled - ? `\n**Third-Party Services:** When users ask to interact with any external service (Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.) — reading emails, listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n` + const nativeGmailBlock = nativeGmailConnected + ? `\n**Email (Gmail):** Reading, checking, searching, or summarizing email uses the NATIVE Gmail tools (\`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\`) — load the \`read-emails\` skill first. Do NOT use Composio for Gmail. Do NOT read raw files from \`gmail_sync/\`.\n` : ''; + const composioServiceExamples = nativeGmailConnected + ? '(GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.)' + : '(Gmail, GitHub, Slack, LinkedIn, Notion, Google Sheets, Jira, etc.)'; + const thirdPartyBlock = composioEnabled + ? `${nativeGmailBlock}\n**Third-Party Services:** When users ask to interact with any external service ${composioServiceExamples} — ${nativeGmailConnected ? '' : 'reading emails, '}listing issues, sending messages, fetching profiles — load the \`composio-integration\` skill first. Do NOT look in local \`gmail_sync/\` or \`calendar_sync/\` folders for live data.\n` + : nativeGmailBlock; + + const gmailToolPriority = nativeGmailConnected + ? ` For Gmail reading/search, use the native \`gmail-*\` tools (load the \`read-emails\` skill).` + : ''; const toolPriority = composioEnabled - ? `For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integration\` skill. For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.` - : `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.`; + ? `For third-party services ${nativeGmailConnected ? '(GitHub, Slack, Notion, etc.)' : '(GitHub, Gmail, Slack, etc.)'}, load the \`composio-integration\` skill.${gmailToolPriority} For capabilities Composio doesn't cover (web search, file scraping, audio), use MCP tools via the \`mcp-integration\` skill.` + : `For capabilities like web search, file scraping, and audio, use MCP tools via the \`mcp-integration\` skill.${gmailToolPriority}`; + + const gmailToolsLine = nativeGmailConnected + ? `- \`gmail-checkConnection\`, \`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\` - Native Gmail tools for reading, checking, searching, and summarizing email. Load the \`read-emails\` skill for usage guidance.\n` + : ''; const slackToolsLine = composioEnabled ? `- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.\n` @@ -266,7 +283,7 @@ ${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with fu - \`app-navigation\` - Control the app UI: open notes, switch views, filter/search the knowledge base, manage saved views. **Load the \`app-navigation\` skill before using this tool.** - \`browser-control\` - Control the embedded browser pane: open sites, inspect the live page, switch tabs, and interact with indexed page elements. **Load the \`browser-control\` skill before using this tool.** - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. -${composioToolsLine} +${gmailToolsLine}${composioToolsLine} **Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`. @@ -332,13 +349,21 @@ export async function buildCopilotInstructions(): Promise { } catch { // repo unavailable — default to disabled } + let nativeGmailConnected = false; + try { + const status = await getGmailConnectionStatus(); + nativeGmailConnected = status.connected && status.hasRequiredScope; + } catch { + // connection check unavailable — keep composio routing + } const excludeIds: string[] = []; if (!composioEnabled) excludeIds.push('composio-integration'); if (!codeModeEnabled) excludeIds.push('code-with-agents'); + if (!nativeGmailConnected) excludeIds.push('read-emails'); const catalog = excludeIds.length > 0 ? buildSkillCatalog({ excludeIds }) : skillCatalog; - const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled); + const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled, nativeGmailConnected); const composioPrompt = await getComposioToolsPrompt(); cachedInstructions = composioPrompt ? baseInstructions + '\n' + composioPrompt diff --git a/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts b/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts index 795daeeb2..ed4e77f7a 100644 --- a/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/composio-integration/skill.ts @@ -1,7 +1,9 @@ export const skill = String.raw` # Composio Integration -**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools. +**Load this skill** when the user asks to interact with ANY third-party service — GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools. + +**Gmail exception:** if the native Gmail tools (\`gmail-listThreads\`, \`gmail-readThread\`, \`gmail-searchEmails\`) are available, use those for ALL Gmail reading/fetching/searching (load the \`read-emails\` skill). Only use the Composio \`gmail\` toolkit when the native tools report they are not connected (\`composioFallback: true\`). ## Available Tools @@ -16,7 +18,7 @@ export const skill = String.raw` | Service | Slug | |---------|------| -| Gmail | \`gmail\` | +| Gmail | \`gmail\` (fallback only — prefer the native \`gmail-*\` tools) | | Google Calendar | \`googlecalendar\` | | Google Sheets | \`googlesheets\` | | Google Docs | \`googledocs\` | @@ -89,13 +91,13 @@ User says: "Get me the open issues on rowboatlabs/rowboat" → finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"] 2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\` -### Example: Gmail Fetch +### Example: Notion Search -User says: "What's my latest email?" +User says: "Find my Notion page about Q3 planning" -1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\` - → finds \`GMAIL_FETCH_EMAILS\` -2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\` +1. \`composio-search-tools({ query: "search pages", toolkitSlug: "notion" })\` + → finds \`NOTION_SEARCH_NOTION_PAGE\` +2. \`composio-execute-tool({ toolSlug: "NOTION_SEARCH_NOTION_PAGE", toolkitSlug: "notion", arguments: { query: "Q3 planning" } })\` ### Example: LinkedIn Profile (no-arg tool) diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 30ceea951..2dfbe1c31 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -4,6 +4,7 @@ import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import docCollabSkill from "./doc-collab/skill.js"; import draftEmailsSkill from "./draft-emails/skill.js"; +import readEmailsSkill from "./read-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/skill.js"; @@ -54,6 +55,12 @@ const definitions: SkillDefinition[] = [ summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.", content: draftEmailsSkill, }, + { + id: "read-emails", + title: "Read Emails", + summary: "Read, check, search, and summarize the user's Gmail inbox using the native gmail-* tools and cached per-thread summaries.", + content: readEmailsSkill, + }, { id: "meeting-prep", title: "Meeting Prep", diff --git a/apps/x/packages/core/src/application/assistant/skills/read-emails/skill.ts b/apps/x/packages/core/src/application/assistant/skills/read-emails/skill.ts new file mode 100644 index 000000000..2d199360f --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/read-emails/skill.ts @@ -0,0 +1,62 @@ +export const skill = String.raw` +# Read Emails Skill + +You are helping the user read, check, search, or summarize their Gmail inbox using the NATIVE Gmail tools. These run against a local sync of the user's inbox — fast, private, and already enriched with per-thread LLM summaries. + +Do NOT use the \`composio-integration\` skill or any \`composio-*\` tool for Gmail unless a native tool response says \`composioFallback: true\`. + +## Tools + +| Tool | What it does | +|------|--------------| +| \`gmail-checkConnection\` | Is native Gmail connected, and as which account? | +| \`gmail-listThreads\` | Page through the synced inbox (\`section: 'important'\` or \`'other'\`). Each thread carries a cached 1-2 sentence \`summary\` and \`importance\` classification. | +| \`gmail-readThread\` | Full plain-text messages of ONE thread by \`threadId\`. | +| \`gmail-searchEmails\` | Live Gmail search with a query string — for anything the cached inbox can't answer. | + +## Summarizing the Inbox (the common case) + +For "summarize my emails", "what's in my inbox", "anything important today": + +1. Call \`gmail-listThreads\` (defaults to \`section: 'important'\`). Include \`section: 'other'\` only if the user asks about newsletters/notifications or "everything". +2. Compose your answer directly from the returned \`summary\` + \`importance\` fields (plus \`from\`/\`subject\`/\`date\`). +3. **Do NOT** call \`gmail-readThread\` once per thread, and do NOT re-summarize message bodies — the cached summaries already exist and are fresh from sync. Read a full thread ONLY when the user drills into a specific one. + +Threads without a \`summary\` include a \`latestSnippet\` to work from. + +## Freshness + +- The cache syncs continuously in the background (typically minutes fresh). +- When the user says "check my email NOW" / "any NEW emails?", pass \`sync: true\` on \`gmail-listThreads\` — it triggers a background re-sync (non-blocking). If results look stale, mention fresh data lands shortly and retry once. + +## Searching ("emails from Stripe last month") + +Use \`gmail-searchEmails\` with a Gmail query string. Common operators: +- \`from:stripe.com\`, \`to:someone@example.com\` +- \`subject:invoice\` +- \`newer_than:7d\`, \`older_than:30d\`, \`after:2026/05/01\` +- \`is:unread\`, \`has:attachment\`, \`label:work\` +- Free text matches message content. + +Results may come from the cache (with summaries) or live metadata only. Follow up with \`gmail-readThread({ threadId })\` when the user wants the content. + +## Reading One Thread + +\`gmail-readThread({ threadId })\` returns the most recent messages (default 10, \`omittedOlderMessages\` counts the rest), with quoted replies stripped and long bodies truncated. It also includes the cached \`summary\`, \`importance\`, and any \`draft_response\` the classifier prepared. Attachments are listed with a \`savedPath\` (workspace-relative) — use file tools / \`parseFile\` on it if the user asks about an attachment. + +## Pagination + +Default page size is 20. Follow \`nextCursor\` only when the user asks for more ("show me the rest", "older emails"). + +## Not Connected + +If a tool returns \`connected: false\`: +- Tell the user to connect their Google account in **Settings** (the \`action\` field says exactly what to suggest). +- Only if the response also says \`composioFallback: true\` may you fall back to the \`composio-integration\` skill for Gmail. + +## Drafting + +This skill is for READING email. When the user wants to draft or compose a reply, load the \`draft-emails\` skill. +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 9bfb42501..c83d7a904 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -14,6 +14,15 @@ import { WorkDir } from "../../config/config.js"; import { composioAccountsRepo } from "../../composio/repo.js"; import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, searchTools as searchComposioTools } from "../../composio/client.js"; import { CURATED_TOOLKITS, CURATED_TOOLKIT_SLUGS } from "@x/shared/dist/composio.js"; +import { + gmailCheckConnection, + gmailListThreads, + gmailReadThread, + gmailSearchEmails, + type GmailListThreadsInput, + type GmailReadThreadInput, + type GmailSearchEmailsInput, +} from "./gmail-tools.js"; import { BrowserControlInputSchema, type BrowserControlInput } from "@x/shared/dist/browser-control.js"; import { BackgroundTaskSchema, TriggersSchema } from "@x/shared/dist/background-task.js"; @@ -1257,6 +1266,49 @@ export const BuiltinTools: z.infer = { }, }, + // ======================================================================== + // Native Gmail Tools + // ======================================================================== + // Always registered (not gated on connection status — the instructions + // cache would drift otherwise). Each execute checks the native Google + // connection itself and returns a connect-in-Settings payload when + // disconnected. Execute bodies live in gmail-tools.ts. + + 'gmail-checkConnection': { + description: 'Check whether the native Gmail integration is connected and which account it uses. Returns { connected, email } or a not-connected payload with next steps.', + inputSchema: z.object({}), + execute: async () => gmailCheckConnection(), + }, + + 'gmail-listThreads': { + description: "List the user's Gmail inbox from the locally synced cache. Each thread includes an LLM-generated 1-2 sentence summary and importance classification — prefer these summaries for 'summarize my emails' style requests instead of reading full bodies. Load the `read-emails` skill for usage guidance.", + inputSchema: z.object({ + section: z.enum(['important', 'other']).optional().describe("Inbox section: 'important' (real correspondence) or 'other' (newsletters, notifications). Defaults to 'important'."), + cursor: z.string().optional().describe('Pagination cursor from a previous call (nextCursor).'), + limit: z.number().int().min(1).max(50).optional().describe('Threads per page. Defaults to 20.'), + sync: z.boolean().optional().describe('Set true to trigger a background re-sync first (does not wait for it).'), + }), + execute: async (input: GmailListThreadsInput) => gmailListThreads(input), + }, + + 'gmail-readThread': { + description: 'Read the full messages of one Gmail thread by threadId (from gmail-listThreads or gmail-searchEmails). Returns plain-text bodies (quoted replies stripped, capped per message) plus the cached summary/importance and any attachments.', + inputSchema: z.object({ + threadId: z.string().describe('The Gmail thread id.'), + maxMessages: z.number().int().min(1).max(25).optional().describe('Most-recent messages to include. Defaults to 10; older ones are counted in omittedOlderMessages.'), + }), + execute: async (input: GmailReadThreadInput) => gmailReadThread(input), + }, + + 'gmail-searchEmails': { + description: "Search Gmail live with a Gmail query string (from:, subject:, newer_than:7d, has:attachment, is:unread, label:, free text). Use when the cached inbox can't answer — older mail, specific sender/date searches. Follow up with gmail-readThread for bodies.", + inputSchema: z.object({ + query: z.string().describe('Gmail search query, e.g. "from:stripe.com newer_than:30d".'), + maxResults: z.number().int().min(1).max(25).optional().describe('Max threads to return. Defaults to 10.'), + }), + execute: async (input: GmailSearchEmailsInput) => gmailSearchEmails(input), + }, + // ======================================================================== // Composio Meta-Tools // ======================================================================== diff --git a/apps/x/packages/core/src/application/lib/gmail-tools.ts b/apps/x/packages/core/src/application/lib/gmail-tools.ts new file mode 100644 index 000000000..ee37815c7 --- /dev/null +++ b/apps/x/packages/core/src/application/lib/gmail-tools.ts @@ -0,0 +1,262 @@ +import { + listInboxPage, + getThreadSnapshot, + fetchThreadLive, + searchThreadsLive, + triggerSync, + getConnectionStatus, + stripGmailQuotedReplyText, + type GmailThreadSnapshot, + type GmailConnectionStatus, + type InboxSection, +} from "../../knowledge/sync_gmail.js"; +import { composioAccountsRepo } from "../../composio/repo.js"; + +// Execute bodies for the native gmail-* builtin tools (registered in +// builtin-tools.ts). All payloads are trimmed to be LLM-friendly: plain-text +// bodies only, capped sizes, and the cached per-thread classification +// (summary + importance) surfaced so the model doesn't re-summarize. + +const SNIPPET_CHARS = 300; +const BODY_CHARS = 4000; +const DEFAULT_LIST_LIMIT = 20; +const DEFAULT_READ_MESSAGES = 10; +const CONNECTION_MEMO_MS = 30_000; + +let connectionMemo: { at: number; status: GmailConnectionStatus } | null = null; + +async function checkConnection(): Promise { + const now = Date.now(); + if (connectionMemo && now - connectionMemo.at < CONNECTION_MEMO_MS) { + return connectionMemo.status; + } + const status = await getConnectionStatus(); + connectionMemo = { at: now, status }; + return status; +} + +interface NotConnectedPayload { + connected: false; + error: string; + action: string; + composioFallback: boolean; +} + +function notConnectedPayload(status: GmailConnectionStatus): NotConnectedPayload { + const composioFallback = composioAccountsRepo.isConnected('gmail'); + return { + connected: false, + error: status.connected + ? `Google is connected but missing required Gmail scopes: ${status.missingScopes.join(', ')}.` + : 'Gmail is not connected natively.', + action: 'Ask the user to connect (or re-connect) their Google account in Settings.', + composioFallback, + }; +} + +async function requireConnection(): Promise { + try { + const status = await checkConnection(); + if (!status.connected || !status.hasRequiredScope) return notConnectedPayload(status); + return null; + } catch (err) { + return { + connected: false, + error: `Failed to check Gmail connection: ${err instanceof Error ? err.message : String(err)}`, + action: 'Ask the user to connect their Google account in Settings.', + composioFallback: composioAccountsRepo.isConnected('gmail'), + }; + } +} + +function collapseWhitespace(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function latestSnippet(snapshot: GmailThreadSnapshot): string | undefined { + const latest = snapshot.messages[snapshot.messages.length - 1]; + const body = latest?.body; + if (!body) return undefined; + const collapsed = collapseWhitespace(stripGmailQuotedReplyText(body)); + return collapsed.length > SNIPPET_CHARS ? `${collapsed.slice(0, SNIPPET_CHARS)}…` : collapsed; +} + +interface ThreadRow { + threadId: string; + subject?: string; + from?: string; + to?: string; + date?: string; + unread?: boolean; + importance?: 'important' | 'other'; + summary?: string; + messageCount: number; + latestSnippet?: string; +} + +function toThreadRow(snapshot: GmailThreadSnapshot): ThreadRow { + return { + threadId: snapshot.threadId, + subject: snapshot.subject, + from: snapshot.from, + to: snapshot.to, + date: snapshot.date, + unread: snapshot.unread, + importance: snapshot.importance, + summary: snapshot.summary, + messageCount: snapshot.messages.length, + latestSnippet: snapshot.summary ? undefined : latestSnippet(snapshot), + }; +} + +function trimBody(body: string | undefined): string { + if (!body) return ''; + const stripped = stripGmailQuotedReplyText(body); + return stripped.length > BODY_CHARS ? `${stripped.slice(0, BODY_CHARS)}\n[truncated]` : stripped; +} + +export interface GmailListThreadsInput { + section?: InboxSection; + cursor?: string; + limit?: number; + sync?: boolean; +} + +export async function gmailListThreads(input: GmailListThreadsInput) { + const notConnected = await requireConnection(); + if (notConnected) return notConnected; + + if (input.sync) triggerSync(); + + const { threads, nextCursor } = listInboxPage({ + section: input.section ?? 'important', + cursor: input.cursor, + limit: Math.max(1, Math.min(50, input.limit ?? DEFAULT_LIST_LIMIT)), + }); + + if (threads.length === 0) { + return { + connected: true, + threads: [], + nextCursor: null, + note: input.sync + ? 'A re-sync was just triggered — fresh data lands shortly; retry in a moment.' + : 'No synced threads in this section yet. Sync may still be running — retry with sync:true or in a moment.', + }; + } + + return { + connected: true, + threads: threads.map(toThreadRow), + nextCursor, + hint: 'Each thread carries a cached LLM summary + importance — compose inbox overviews from these. Use gmail-readThread only when the user drills into one thread.', + }; +} + +export interface GmailReadThreadInput { + threadId: string; + maxMessages?: number; +} + +export async function gmailReadThread(input: GmailReadThreadInput) { + const notConnected = await requireConnection(); + if (notConnected) return notConnected; + + let snapshot = getThreadSnapshot(input.threadId); + let source: 'cache' | 'live' = 'cache'; + if (!snapshot) { + try { + snapshot = await fetchThreadLive(input.threadId); + source = 'live'; + } catch (err) { + return { + connected: true, + error: `Failed to fetch thread ${input.threadId}: ${err instanceof Error ? err.message : String(err)}`, + }; + } + } + if (!snapshot) { + return { connected: true, error: `Thread ${input.threadId} was not found (or has no visible messages).` }; + } + + const maxMessages = Math.max(1, Math.min(25, input.maxMessages ?? DEFAULT_READ_MESSAGES)); + const omittedOlderMessages = Math.max(0, snapshot.messages.length - maxMessages); + const messages = snapshot.messages.slice(-maxMessages).map((m) => ({ + from: m.from, + to: m.to, + cc: m.cc, + date: m.date, + subject: m.subject, + body: trimBody(m.body), + unread: m.unread, + attachments: m.attachments?.map((a) => ({ + filename: a.filename, + sizeBytes: a.sizeBytes, + savedPath: a.savedPath, + })), + })); + + return { + connected: true, + source, + threadId: snapshot.threadId, + threadUrl: snapshot.threadUrl, + subject: snapshot.subject, + importance: snapshot.importance, + summary: snapshot.summary, + draft_response: snapshot.draft_response, + omittedOlderMessages, + messages, + }; +} + +export interface GmailSearchEmailsInput { + query: string; + maxResults?: number; +} + +export async function gmailSearchEmails(input: GmailSearchEmailsInput) { + const notConnected = await requireConnection(); + if (notConnected) return notConnected; + + let results; + try { + results = await searchThreadsLive(input.query, Math.max(1, Math.min(25, input.maxResults ?? 10))); + } catch (err) { + return { + connected: true, + threads: [], + error: `Gmail search failed: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + return { + connected: true, + threads: results.map((r) => r.snapshot + ? { source: 'cache' as const, ...toThreadRow(r.snapshot) } + : { + source: 'live' as const, + threadId: r.threadId, + subject: r.subject, + from: r.from, + to: r.to, + date: r.date, + latestSnippet: r.snippet, + messageCount: 0, + }), + hint: 'Follow up with gmail-readThread({ threadId }) to read full message bodies.', + }; +} + +export async function gmailCheckConnection() { + try { + const status = await checkConnection(); + if (!status.connected || !status.hasRequiredScope) return notConnectedPayload(status); + return { connected: true, email: status.email }; + } catch (err) { + return { + connected: false, + error: err instanceof Error ? err.message : String(err), + }; + } +} diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 77055f370..d65e88bb5 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -668,6 +668,102 @@ export async function listRecentThreadIds(daysAgo: number = 2): Promise { + const gmailClient = await getGmailClientOrThrow(); + const res = await gmailClient.users.threads.list({ + userId: 'me', + q: query, + maxResults: Math.max(1, Math.min(25, maxResults)), + }); + const threads = (res.data.threads || []).filter((t) => !!t.id); + return Promise.all(threads.map(async (thread) => { + const threadId = thread.id!; + const cached = readCachedSnapshot(threadId)?.snapshot; + if (cached) return { threadId, snippet: thread.snippet || undefined, snapshot: cached }; + try { + const meta = await gmailClient.users.threads.get({ + userId: 'me', + id: threadId, + format: 'metadata', + metadataHeaders: ['Subject', 'From', 'To', 'Date'], + }); + const msgs = meta.data.messages || []; + const latest = msgs[msgs.length - 1]; + const headers = latest?.payload?.headers || undefined; + return { + threadId, + snippet: thread.snippet || latest?.snippet || undefined, + subject: headerValue(headers, 'Subject'), + from: headerValue(headers, 'From'), + to: headerValue(headers, 'To'), + date: headerValue(headers, 'Date'), + }; + } catch { + return { threadId, snippet: thread.snippet || undefined }; + } + })); +} + +/** + * Live-fetch a thread that isn't in the snapshot cache (e.g. an older thread + * surfaced by searchThreadsLive). Plain-text bodies only — no HTML inlining, + * no classification, and nothing is written to the cache. + */ +export async function fetchThreadLive(threadId: string): Promise { + const gmailClient = await getGmailClientOrThrow(); + const res = await gmailClient.users.threads.get({ userId: 'me', id: threadId, format: 'full' }); + const messages = (res.data.messages || []).filter((msg) => !(msg.labelIds?.includes('DRAFT') ?? false)); + if (messages.length === 0) return null; + const parsed = messages.map((msg) => { + const headers = msg.payload?.headers || undefined; + const parts = msg.payload ? extractBodyParts(msg.payload) : { text: '', html: '' }; + const attachments = msg.payload && msg.id ? extractAttachments(msg.id, msg.payload, parts.html) : []; + return { + id: msg.id || undefined, + from: headerValue(headers, 'From') || 'Unknown', + to: headerValue(headers, 'To'), + cc: headerValue(headers, 'Cc'), + date: headerValue(headers, 'Date'), + subject: headerValue(headers, 'Subject') || '(No Subject)', + body: msg.payload ? normalizeBody(getBody(msg.payload)) : '', + unread: msg.labelIds?.includes('UNREAD') ?? false, + attachments: attachments.length > 0 ? attachments : undefined, + }; + }); + const latest = parsed[parsed.length - 1]!; + return { + threadId, + threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`, + subject: latest.subject || parsed[0]?.subject, + from: latest.from, + to: latest.to, + date: latest.date, + unread: parsed.some((m) => m.unread), + messages: parsed, + }; +} + /** * Build a GmailThreadSnapshot from an already-fetched threads.get response, * classify it, and write to inbox_lists/. Called by the background sync