mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
remove rended driven email path
This commit is contained in:
parent
3eaf752c94
commit
1e90ce1a49
9 changed files with 93 additions and 271 deletions
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,137 +54,6 @@ function avatarColor(from: string): string {
|
|||
return GMAIL_AVATAR_COLORS[hash % GMAIL_AVATAR_COLORS.length]
|
||||
}
|
||||
|
||||
function extractThreadId(config: Pick<blocks.EmailBlock, 'threadId' | 'threadUrl'>): 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<string | null>(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({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="email-gmail-exp-body">{config.latest_email || 'Loading latest message...'}</div>
|
||||
<div className="email-gmail-exp-body">{config.latest_email}</div>
|
||||
|
||||
{config.past_summary && (
|
||||
<div className="email-gmail-exp-history">
|
||||
|
|
@ -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 (
|
||||
<div className={`email-inbox-row${expanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{hydratedEmail.date && <span className="email-inbox-date">{formatEmailDate(hydratedEmail.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{hydratedEmail.subject && <span className="email-inbox-subject">{hydratedEmail.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{hydratedEmail.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${expanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={hydratedEmail}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmailsBlockView({ node, deleteNode }: {
|
||||
node: { attrs: Record<string, unknown> }
|
||||
deleteNode: () => void
|
||||
|
|
@ -452,14 +258,53 @@ function EmailsBlockView({ node, deleteNode }: {
|
|||
<div className="email-inbox-list">
|
||||
{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 (
|
||||
<EmailInboxRow
|
||||
key={email.threadId || email.threadUrl || i}
|
||||
email={email}
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setExpandedIndex(isExpanded ? null : i)}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
<div key={i} className={`email-inbox-row${isExpanded ? ' email-inbox-row-expanded' : ''}`}>
|
||||
{/* Collapsed row */}
|
||||
<div
|
||||
className="email-inbox-row-header"
|
||||
onClick={(e) => { e.stopPropagation(); setExpandedIndex(isExpanded ? null : i) }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="email-inbox-avatar" style={{ backgroundColor: color }}>{initial}</div>
|
||||
|
||||
<div className="email-inbox-content">
|
||||
<div className="email-inbox-top-row">
|
||||
<span className="email-inbox-sender">{senderName}</span>
|
||||
{email.date && <span className="email-inbox-date">{formatEmailDate(email.date)}</span>}
|
||||
</div>
|
||||
<div className="email-inbox-bottom-row">
|
||||
{email.subject && <span className="email-inbox-subject">{email.subject}</span>}
|
||||
{snippet && (
|
||||
<span className="email-inbox-snippet">
|
||||
{email.subject ? ` — ${snippet}` : snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`email-inbox-chevron${isExpanded ? ' email-inbox-chevron-open' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expanded content */}
|
||||
{isExpanded && (
|
||||
<div className="email-inbox-expanded-wrap">
|
||||
<EmailExpandedBody
|
||||
config={email}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<NodeViewWrapper className="email-block-wrapper" data-type="email-block">
|
||||
|
|
@ -564,11 +407,11 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
<div className="email-gmail-content">
|
||||
<div className="email-gmail-top-row">
|
||||
<span className="email-gmail-sender">{senderName}</span>
|
||||
{email.date && <span className="email-gmail-date">{formatEmailDate(email.date)}</span>}
|
||||
{config.date && <span className="email-gmail-date">{formatEmailDate(config.date)}</span>}
|
||||
</div>
|
||||
<div className="email-gmail-bottom-row">
|
||||
{email.subject && <span className="email-gmail-subject">{email.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{email.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
{config.subject && <span className="email-gmail-subject">{config.subject}</span>}
|
||||
{snippet && <span className="email-gmail-snippet">{config.subject ? ` — ${snippet}` : snippet}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown size={15} className={`email-gmail-chevron${expanded ? ' email-gmail-chevron-open' : ''}`} />
|
||||
|
|
@ -576,7 +419,7 @@ function EmailBlockView({ node, deleteNode, updateAttributes }: {
|
|||
|
||||
{expanded && (
|
||||
<EmailExpandedBody
|
||||
config={email}
|
||||
config={config}
|
||||
resolvedTheme={resolvedTheme}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.<ISO-stamp> 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<typeof LiveNoteSchema> = {
|
||||
objective:
|
||||
|
|
@ -24,7 +24,7 @@ const TODAY_LIVE_NOTE: z.infer<typeof LiveNoteSchema> = {
|
|||
|
||||
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/<source>/<yesterday>/\` (\`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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <sender@example.com>","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 <no-reply@accounts.google.com>","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
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<RecentTh
|
|||
|
||||
/**
|
||||
* Build a GmailThreadSnapshot from an already-fetched threads.get response,
|
||||
* classify it, and write to inbox_lists/. Shared by the renderer-driven
|
||||
* fetchThreadSnapshot and the background sync (processThread).
|
||||
* classify it, and write to inbox_lists/. Called by the background sync
|
||||
* (processThread) — the only path that materializes snapshots.
|
||||
*
|
||||
* Returns null when the thread has no visible (non-draft) messages —
|
||||
* those shouldn't show up in the inbox.
|
||||
|
|
@ -444,6 +444,20 @@ async function buildAndCacheSnapshot(
|
|||
if (!messages || messages.length === 0) return null;
|
||||
|
||||
const cached = readCachedSnapshot(threadId);
|
||||
// Short-circuit: if the thread hasn't changed since we last classified it,
|
||||
// skip the rebuild + classifier. Saves the cid-image fetches and one LLM
|
||||
// call per unchanged thread (matters most during fullSync after a
|
||||
// historyId expiry, where the whole window is re-walked).
|
||||
// We require `importance` to be present too — pre-classifier cache files
|
||||
// would otherwise stick around forever uncategorised.
|
||||
if (
|
||||
threadData.historyId &&
|
||||
cached &&
|
||||
cached.historyId === threadData.historyId &&
|
||||
cached.snapshot.importance
|
||||
) {
|
||||
return cached.snapshot;
|
||||
}
|
||||
const heightCarryover = new Map<string, number>();
|
||||
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<GmailThreadSnapshot | null> {
|
||||
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<string | null> {
|
||||
const filename = part.filename;
|
||||
const attId = part.body?.attachmentId;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue