diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 72055579..78f8b55e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -47,7 +47,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { fetchThreadSnapshot, listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; +import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js'; import { getInstallationId } from '@x/core/dist/analytics/installation.js'; import { API_URL } from '@x/core/dist/config/env.js'; @@ -483,16 +483,6 @@ export function setupIpcHandlers() { 'workspace:remove': async (_event, args) => { return workspace.remove(args.path, args.opts); }, - 'gmail:getThread': async (_event, args) => { - try { - return { thread: await fetchThreadSnapshot(args.threadId, args.expectedHistoryId) }; - } catch (error) { - return { - thread: null, - error: error instanceof Error ? error.message : String(error), - }; - } - }, 'gmail:getImportant': async (_event, args) => { return listImportantThreads({ cursor: args.cursor, limit: args.limit }); }, diff --git a/apps/x/apps/renderer/src/extensions/email-block.tsx b/apps/x/apps/renderer/src/extensions/email-block.tsx index cbfed013..d615c212 100644 --- a/apps/x/apps/renderer/src/extensions/email-block.tsx +++ b/apps/x/apps/renderer/src/extensions/email-block.tsx @@ -54,137 +54,6 @@ function avatarColor(from: string): string { return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length] } -function extractThreadId(config: Pick): string | null { - if (config.threadId) return config.threadId - if (!config.threadUrl) return null - const url = config.threadUrl.trim() - const hashId = url.match(/#(?:all|inbox|sent|important|starred|search\/[^/]+)\/([^/?#]+)/) - if (hashId?.[1]) return decodeURIComponent(hashId[1]) - const queryId = url.match(/[?&](?:th|threadId)=([^&]+)/) - if (queryId?.[1]) return decodeURIComponent(queryId[1]) - const tailId = url.match(/\/([a-f0-9]{12,})\/?$/i) - return tailId?.[1] || null -} - -function parseSyncedGmailThread(markdown: string, threadId: string): blocks.EmailBlock | null { - const subject = markdown.match(/^#\s+(.+)$/m)?.[1]?.trim() - const chunks = markdown - .split(/\n---\n/g) - .map(chunk => chunk.trim()) - .filter(chunk => /^### From:/m.test(chunk)) - - const messages = chunks.map((chunk) => { - const from = chunk.match(/^### From:\s*(.+)$/m)?.[1]?.trim() - const date = chunk.match(/^\*\*Date:\*\*\s*(.+)$/m)?.[1]?.trim() - const body = chunk - .replace(/^### From:\s*.+$/m, '') - .replace(/^\*\*Date:\*\*\s*.+$/m, '') - .replace(/\n\*\*Attachments:\*\*[\s\S]*$/m, '') - .trim() - return { from, date, body } - }).filter(message => message.from || message.body) - - const latest = messages[messages.length - 1] - if (!latest) return null - - const earlier = messages.slice(0, -1) - const pastSummary = earlier - .map((message) => { - const date = message.date ? ` (${message.date})` : '' - const body = message.body.replace(/\s+/g, ' ').slice(0, 500).trim() - return `${message.from || 'Unknown'}${date}: ${body}` - }) - .filter(Boolean) - .join('\n\n') - - return { - threadId, - threadUrl: `https://mail.google.com/mail/u/0/#all/${threadId}`, - subject, - from: latest.from, - date: latest.date, - latest_email: latest.body, - past_summary: pastSummary || undefined, - } -} - -function mergeHydratedEmail(base: blocks.EmailBlock, hydrated: blocks.EmailBlock | null): blocks.EmailBlock { - if (!hydrated) return base - return { - ...hydrated, - ...base, - threadId: base.threadId || hydrated.threadId, - threadUrl: base.threadUrl || hydrated.threadUrl, - subject: hydrated.subject || base.subject, - from: hydrated.from || base.from, - to: hydrated.to || base.to, - date: hydrated.date || base.date, - latest_email: hydrated.latest_email || base.latest_email, - past_summary: hydrated.past_summary || base.past_summary, - summary: base.summary || hydrated.summary, - } -} - -function useHydratedEmail(config: blocks.EmailBlock): { email: blocks.EmailBlock; loading: boolean; error: string | null } { - const [email, setEmail] = useState(config) - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const threadId = extractThreadId(config) - const configKey = JSON.stringify(config) - - useEffect(() => { - let cancelled = false - const baseConfig = blocks.EmailBlockSchema.parse(JSON.parse(configKey)) - - async function load() { - setEmail(baseConfig) - setError(null) - if (!threadId) { - setLoading(false) - return - } - - setLoading(true) - let hydrated: blocks.EmailBlock | null = null - let loadError: string | null = null - - try { - const result = await window.ipc.invoke('gmail:getThread', { threadId }) - if (result.thread) { - hydrated = result.thread - } else if (result.error) { - loadError = result.error - } - } catch (err) { - loadError = err instanceof Error ? err.message : String(err) - } - - if (!hydrated) { - try { - const result = await window.ipc.invoke('workspace:readFile', { - path: `gmail_sync/${threadId}.md`, - encoding: 'utf8', - }) - hydrated = parseSyncedGmailThread(result.data, threadId) - } catch (err) { - loadError ||= err instanceof Error ? err.message : String(err) - } - } - - if (!cancelled) { - setEmail(mergeHydratedEmail(baseConfig, hydrated)) - setError(hydrated ? null : loadError) - setLoading(false) - } - } - - void load() - return () => { cancelled = true } - }, [configKey, threadId]) - - return { email, loading, error } -} - declare global { interface Window { __pendingEmailDraft?: { prompt: string } @@ -219,9 +88,8 @@ function EmailExpandedBody({ let prompt = draftBody ? `Help me refine this draft response to an email` : `Help me draft a response to this email` - const threadId = extractThreadId(config) - if (threadId) { - prompt += `. Read the full thread at gmail_sync/${threadId}.md for context` + if (config.threadId) { + prompt += `. Read the full thread at gmail_sync/${config.threadId}.md for context` } prompt += `.\n\n**From:** ${config.from || 'Unknown'}\n**Subject:** ${config.subject || 'No subject'}\n` if (draftBody) prompt += `\n**Current draft:**\n${draftBody}\n` @@ -245,8 +113,9 @@ function EmailExpandedBody({ }) }, [draftBody]) - const threadId = extractThreadId(config) - const gmailUrl = config.threadUrl || (threadId ? `https://mail.google.com/mail/u/0/#all/${threadId}` : null) + const gmailUrl = config.threadId + ? `https://mail.google.com/mail/u/0/#all/${config.threadId}` + : null const initial = config.from ? getInitial(config.from) : '?' const color = config.from ? avatarColor(config.from) : '#5f6368' @@ -269,7 +138,7 @@ function EmailExpandedBody({ -
{config.latest_email || 'Loading latest message...'}
+
{config.latest_email}
{config.past_summary && (
@@ -355,69 +224,6 @@ function EmailExpandedBody({ // --- Multi-email inbox block (language-emails) --- -function EmailInboxRow({ - email, - expanded, - onToggle, - resolvedTheme, -}: { - email: blocks.EmailBlock - expanded: boolean - onToggle: () => void - resolvedTheme: string -}) { - const { email: hydratedEmail, loading, error } = useHydratedEmail(email) - const senderName = hydratedEmail.from ? extractName(hydratedEmail.from) : 'Unknown' - const initial = hydratedEmail.from ? getInitial(hydratedEmail.from) : '?' - const color = hydratedEmail.from ? avatarColor(hydratedEmail.from) : '#5f6368' - const snippet = hydratedEmail.summary - || (hydratedEmail.latest_email ? hydratedEmail.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '') - || (loading ? 'Loading latest Gmail thread...' : error || '') - - return ( -
- {/* Collapsed row */} -
{ e.stopPropagation(); onToggle() }} - onMouseDown={(e) => e.stopPropagation()} - > -
{initial}
- -
-
- {senderName} - {hydratedEmail.date && {formatEmailDate(hydratedEmail.date)}} -
-
- {hydratedEmail.subject && {hydratedEmail.subject}} - {snippet && ( - - {hydratedEmail.subject ? ` — ${snippet}` : snippet} - - )} -
-
- - -
- - {/* Expanded content */} - {expanded && ( -
- -
- )} -
- ) -} - function EmailsBlockView({ node, deleteNode }: { node: { attrs: Record } deleteNode: () => void @@ -452,14 +258,53 @@ function EmailsBlockView({ node, deleteNode }: {
{config.emails.map((email, i) => { const isExpanded = expandedIndex === i + const senderName = email.from ? extractName(email.from) : 'Unknown' + const initial = email.from ? getInitial(email.from) : '?' + const color = email.from ? avatarColor(email.from) : '#5f6368' + const snippet = email.summary + || (email.latest_email ? email.latest_email.slice(0, 100).replace(/\s+/g, ' ').trim() : '') + return ( - setExpandedIndex(isExpanded ? null : i)} - resolvedTheme={resolvedTheme} - /> +
+ {/* Collapsed row */} +
{ e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }} + onMouseDown={(e) => e.stopPropagation()} + > +
{initial}
+ +
+
+ {senderName} + {email.date && {formatEmailDate(email.date)}} +
+
+ {email.subject && {email.subject}} + {snippet && ( + + {email.subject ? ` — ${snippet}` : snippet} + + )} +
+
+ + +
+ + {/* Expanded content */} + {isExpanded && ( +
+ +
+ )} +
) })}
@@ -542,13 +387,11 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { ) } - const { email, loading, error } = useHydratedEmail(config) - const senderName = email.from ? extractName(email.from) : 'Unknown' - const initial = email.from ? getInitial(email.from) : '?' - const color = email.from ? avatarColor(email.from) : '#5f6368' - const snippet = email.summary - || (email.latest_email ? email.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '') - || (loading ? 'Loading latest Gmail thread...' : error || '') + const senderName = config.from ? extractName(config.from) : 'Unknown' + const initial = config.from ? getInitial(config.from) : '?' + const color = config.from ? avatarColor(config.from) : '#5f6368' + const snippet = config.summary + || (config.latest_email ? config.latest_email.slice(0, 120).replace(/\s+/g, ' ').trim() : '') return ( @@ -564,11 +407,11 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
{senderName} - {email.date && {formatEmailDate(email.date)}} + {config.date && {formatEmailDate(config.date)}}
- {email.subject && {email.subject}} - {snippet && {email.subject ? ` — ${snippet}` : snippet}} + {config.subject && {config.subject}} + {snippet && {config.subject ? ` — ${snippet}` : snippet}}
@@ -576,7 +419,7 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: { {expanded && ( )} diff --git a/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts b/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts index 48952e43..715c049d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/live-note/skill.ts @@ -324,8 +324,7 @@ live: Maintain a digest of email threads worth attention today, as a single \`emails\` block. Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the - full digest from scratch. In each email entry, store only \`threadId\` or \`threadUrl\`, - optional \`summary\`, and optional \`draft_response\`; the block hydrates Gmail metadata. + full digest from scratch. With an event payload (event run): integrate the new thread into the existing digest — add it if new, update its entry if the threadId is already shown — and don't re-list diff --git a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts index 8d6e960c..341ddfd9 100644 --- a/apps/x/packages/core/src/knowledge/ensure_daily_note.ts +++ b/apps/x/packages/core/src/knowledge/ensure_daily_note.ts @@ -14,7 +14,7 @@ const DAILY_NOTE_PATH = path.join(KNOWLEDGE_DIR, 'Today.md'); // on-disk `templateVersion` against this constant — if older or missing, the // existing file is renamed to Today.md.bkp. and replaced with the // new template. v2 is the live-note rewrite (single objective, no `track:`). -const CANONICAL_DAILY_NOTE_VERSION = 3; +const CANONICAL_DAILY_NOTE_VERSION = 2; const TODAY_LIVE_NOTE: z.infer = { objective: @@ -24,7 +24,7 @@ const TODAY_LIVE_NOTE: z.infer = { 2. **Calendar** — today's meetings as a single \`calendar\` block titled "Today's Meetings". Read \`calendar_sync/\` via \`workspace-readdir\` → \`workspace-readFile\` each \`.json\`. Filter to today; after 10am drop meetings that have already ended. Always emit the block (use \`events: []\` when empty). Set \`showJoinButton: true\` if any event has a \`conferenceLink\`. -3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful for why it matters. Do **not** copy \`subject\`, \`from\`, \`date\`, or \`latest_email\` from Gmail into the block — the email block hydrates the latest thread from Gmail directly. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`, and use the filename/Thread ID to set \`threadId\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails." +3. **Emails** — a digest of email threads worth attention today, as a **single** fenced \`emails\` block (plural — never individual \`email\` blocks per thread). Body shape: \`{"title":"Today's Emails","emails":[...]}\`. Each entry: \`threadId\`, \`subject\`, \`from\`, \`date\`, \`summary\`, \`latest_email\`. For threads needing a reply, add \`draft_response\` written in the user's voice — direct, informal, no fluff. For FYI threads, omit \`draft_response\`. Skip marketing, auto-notifications, and closed threads. Without an event payload, scan \`gmail_sync/\` (skip \`sync_state.json\` and \`attachments/\`), prioritising threads where frontmatter \`action = "reply"\` or \`"respond"\`. With an event payload, integrate qualifying new threads into the existing digest (add a new entry for a new threadId; update the existing entry if shown). Don't re-list threads the user has already seen unless their state changed. If nothing qualifies: "No new emails." 4. **What you missed** — a short markdown summary of yesterday's meetings + emails that matter this morning. Pull decisions / action items from \`knowledge/Meetings///\` (\`workspace-readdir\` recursive on \`knowledge/Meetings\`, filter folders matching yesterday's date, read each file). Skim \`gmail_sync/\` for unresolved threads. Skip recurring/routine events. If nothing notable: "Quiet day yesterday — nothing to flag." diff --git a/apps/x/packages/core/src/knowledge/inline_task_agent.ts b/apps/x/packages/core/src/knowledge/inline_task_agent.ts index a991812c..cd17a940 100644 --- a/apps/x/packages/core/src/knowledge/inline_task_agent.ts +++ b/apps/x/packages/core/src/knowledge/inline_task_agent.ts @@ -163,11 +163,11 @@ If there are events, include them: 1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`) 2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`) 3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response -4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Each entry should contain only \`threadId\` and/or \`threadUrl\`, plus optional \`summary\` if useful. Do not copy sender, subject, date, or latest body into the block; the renderer hydrates those from Gmail. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example: +4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example: \`\`\` \\\`\\\`\\\`emails -{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation needs acknowledgement.","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"FYI security alert."}]} +{"title":"Today's Emails","emails":[{"threadId":"abc123","summary":"Payment confirmation","subject":"Google services payment","from":"Sender ","date":"2026-04-01T11:28:39+05:30","latest_email":"Hi, I've made the payment...","draft_response":"Thanks for confirming. I'll update our records."},{"threadId":"def456","summary":"Security alert","subject":"New sign-in from Chrome","from":"Google ","date":"2026-04-01T09:15:00+05:30","latest_email":"A new sign-in to your account was detected."}]} \\\`\\\`\\\` \`\`\` @@ -221,4 +221,4 @@ When you see a target region associated with your task (during a scheduled run), - Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate) - Do NOT include the target tags themselves in your response `; -} +} \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/live-note/agent.ts b/apps/x/packages/core/src/knowledge/live-note/agent.ts index 610551ab..66cacbc3 100644 --- a/apps/x/packages/core/src/knowledge/live-note/agent.ts +++ b/apps/x/packages/core/src/knowledge/live-note/agent.ts @@ -160,17 +160,19 @@ Required: \`events\` (array). Each event optionally has \`summary\`, \`start\`/\ ## \`email\` — single email or thread digest (JSON) -Use for: surfacing one important Gmail thread. Prefer storing only a Gmail thread reference plus an optional draft reply; the renderer hydrates sender, subject, date, and latest body from Gmail. +Use for: surfacing one important thread — latest message body, summary of prior context, optional draft reply. \`\`\`email { - "threadId": "18d5a3b2c1e4f567", - "summary": "Needs a reply on the Q3 launch blocker.", - "draft_response": "Thanks for flagging. I'll check with infra and get back to you today." + "subject": "Q3 launch readiness", + "from": "sarah@acme.com", + "date": "2026-04-19T16:42:00Z", + "summary": "Sarah confirms timeline; flagged blocker on infra capacity.", + "latest_email": "Hey — quick update on Q3...\\n\\nThanks,\\nSarah" } \`\`\` -Required: \`threadId\` or \`threadUrl\`. Optional: \`summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both"). Legacy fields \`subject\`, \`from\`, \`to\`, \`date\`, \`latest_email\`, and \`past_summary\` are supported for older notes, but don't emit them for Gmail threads. +Required: \`latest_email\` (string). Optional: \`threadId\`, \`summary\`, \`subject\`, \`from\`, \`to\`, \`date\`, \`past_summary\`, \`draft_response\`, \`response_mode\` ("inline" | "assistant" | "both"). For digests of **many** threads, prefer a \`table\` (Subject | From | Snippet) — \`email\` is for one thread at a time. diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 3cf952e3..84e25a00 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -134,7 +134,7 @@ async function publishCalendarSyncEvent( // Configuration const SYNC_DIR = path.join(WorkDir, 'calendar_sync'); -const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes +const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds const LOOKBACK_DAYS = 7; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index c28ff0d9..2c8ebd1f 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -25,7 +25,7 @@ const CACHE_DIR = path.join(WorkDir, 'inbox_lists'); console.warn('[Gmail] Cache directory migration failed:', err); } })(); -const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes +const SYNC_INTERVAL_MS = 30 * 1000; // Check every 30 seconds const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const MAX_THREADS_IN_DIGEST = 10; const nhm = new NodeHtmlMarkdown(); @@ -428,8 +428,8 @@ export async function listRecentThreadIds(daysAgo: number = 2): Promise(); if (cached) { for (const m of cached.snapshot.messages) { @@ -533,22 +547,6 @@ async function buildAndCacheSnapshot( return snapshot; } -export async function fetchThreadSnapshot(threadId: string, expectedHistoryId?: string): Promise { - const cached = readCachedSnapshot(threadId); - if (expectedHistoryId && cached && cached.historyId === expectedHistoryId) { - return cached.snapshot; - } - - const auth = await GoogleClientFactory.getClient(); - if (!auth) { - throw new Error('Gmail is not connected.'); - } - - const gmailClient = google.gmail({ version: 'v1', auth }); - const res = await gmailClient.users.threads.get({ userId: 'me', id: threadId }); - return buildAndCacheSnapshot(threadId, res.data, gmailClient, auth); -} - async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise { const filename = part.filename; const attId = part.body?.attachmentId; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 1074672d..37cf41e7 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -124,16 +124,6 @@ const ipcSchemas = { req: WorkspaceChangeEvent, res: z.null(), }, - 'gmail:getThread': { - req: z.object({ - threadId: z.string().min(1), - expectedHistoryId: z.string().optional(), - }), - res: z.object({ - thread: GmailThreadSchema.nullable(), - error: z.string().optional(), - }), - }, 'gmail:getImportant': { req: z.object({ cursor: z.string().optional(),