allow labels and drafts to be steered by the user in the app

This commit is contained in:
Arjun 2026-07-14 11:40:17 +05:30
parent b2158f320f
commit 8436e0e245
7 changed files with 543 additions and 78 deletions

View file

@ -99,6 +99,8 @@ import { getAccessToken } from '@x/core/dist/auth/tokens.js';
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, archiveCategoryThreads, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance, setThreadCategory } from '@x/core/dist/knowledge/sync_gmail.js'; import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, archiveCategoryThreads, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance, setThreadCategory } from '@x/core/dist/knowledge/sync_gmail.js';
import { loadEmailInstructions, saveEmailInstructions } from '@x/core/dist/knowledge/email_instructions.js';
import { getEmailLabels, syncCustomLabelsFromInstructions } from '@x/core/dist/knowledge/email_labels.js';
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js'; import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js'; import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js'; import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
@ -921,6 +923,26 @@ export function setupIpcHandlers() {
'gmail:archiveCategory': async (_event, args) => { 'gmail:archiveCategory': async (_event, args) => {
return archiveCategoryThreads(args.category); return archiveCategoryThreads(args.category);
}, },
'gmail:getEmailInstructions': async () => {
return { instructions: loadEmailInstructions() };
},
'gmail:setEmailInstructions': async (_event, args) => {
const saved = saveEmailInstructions(args.instructions);
if (!saved.ok) return saved;
// Extract any custom labels the instructions define so they become
// valid classifier outputs immediately. Extraction failure shouldn't
// fail the save — the instructions themselves are already persisted
// and still steer classification as free text.
try {
await syncCustomLabelsFromInstructions(args.instructions);
} catch (err) {
console.warn('[EmailLabels] custom label extraction failed:', err);
}
return saved;
},
'gmail:getEmailLabels': async () => {
return { labels: getEmailLabels().map(({ id, name, kind }) => ({ id, name, kind })) };
},
'gmail:archiveThread': async (_event, args) => { 'gmail:archiveThread': async (_event, args) => {
return archiveThread(args.threadId); return archiveThread(args.threadId);
}, },

View file

@ -460,12 +460,24 @@
font-size: 13px; font-size: 13px;
} }
/* The bold lead (summary or subject) may shrink with ellipsis but only
after the snippet span (huge shrink factor below) has fully collapsed.
Without this, a long summary plus the never-shrinking category chip
overflows the clipped row and the chip is what gets cut off. */
.gmail-row-content strong { .gmail-row-content strong {
flex-shrink: 0; flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: inherit; color: inherit;
font-weight: 400; font-weight: 400;
} }
/* Snippet gives way first (chips carry .gmail-row-chip and keep shrink 0). */
.gmail-row-content > span:not(.gmail-row-chip) {
flex-shrink: 1000;
}
.gmail-row-date { .gmail-row-date {
justify-self: end; justify-self: end;
color: var(--gm-text-faint); color: var(--gm-text-faint);

View file

@ -1,5 +1,5 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react' import { Archive, Bold, CheckCheck, Forward, Italic, Link as LinkIcon, List, ListOrdered, LoaderIcon, Mail, Paperclip, Quote, Redo2, RefreshCw, Reply, ReplyAll, Search, Send, SlidersHorizontal, Sparkles, SquarePen, Star, StarOff, Strikethrough, Trash2, Undo2, X } from 'lucide-react'
import { useEditor, EditorContent, type Editor } from '@tiptap/react' import { useEditor, EditorContent, type Editor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit' import StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link' import Link from '@tiptap/extension-link'
@ -12,6 +12,7 @@ import { useTheme } from '@/contexts/theme-context'
import { SettingsDialog } from '@/components/settings-dialog' import { SettingsDialog } from '@/components/settings-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { import {
DropdownMenu, DropdownMenu,
@ -96,34 +97,45 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
return thread.messages[thread.messages.length - 1] return thread.messages[thread.messages.length - 1]
} }
// Chip labels for the "Everything else" section — the reason a thread was // The label set (chips, filter pills, correction dropdown) comes from the
// filed there. Plain correspondence gets no chip: chips explain the filing, // backend label registry: built-ins (display names follow Superhuman's Auto
// and correspondence is the default kind of mail. // Label vocabulary — Marketing / Pitch / News / Calendar) plus any labels the
const CATEGORY_CHIP_LABELS: Record<string, string> = { // user defined in their agent instructions. This static copy of the built-ins
meeting: 'Meeting', // is the fallback used until the registry loads, and for ids the registry no
notification: 'Notification', // longer knows (a custom label the user later removed).
newsletter: 'Newsletter', type EmailCategory = string
promotion: 'Promotion',
cold_outreach: 'Cold outreach', interface EmailLabelInfo {
receipt: 'Receipt', id: string
name: string
kind: 'builtin' | 'custom'
} }
type EmailCategory = 'correspondence' | 'meeting' | 'notification' | 'newsletter' | 'promotion' | 'cold_outreach' | 'receipt' const BUILTIN_LABELS: EmailLabelInfo[] = [
{ id: 'correspondence', name: 'Direct', kind: 'builtin' },
{ id: 'meeting', name: 'Calendar', kind: 'builtin' },
{ id: 'notification', name: 'Notification', kind: 'builtin' },
{ id: 'newsletter', name: 'News', kind: 'builtin' },
{ id: 'promotion', name: 'Marketing', kind: 'builtin' },
{ id: 'cold_outreach', name: 'Pitch', kind: 'builtin' },
{ id: 'receipt', name: 'Receipt', kind: 'builtin' },
]
// Full label set for the correction dropdown (correspondence included there — // Pill order in the "Everything else" filter row: noise first (that's what
// "this newsletter is actually a real person" is the most valuable fix). // gets bulk-archived), then the rest of the built-ins; custom labels are
const ALL_CATEGORY_LABELS: Record<EmailCategory, string> = { // appended in registry order at render time. 'unclassified' (threads the
correspondence: 'Correspondence', // classifier hasn't reached yet) is always last and unarchivable.
...CATEGORY_CHIP_LABELS, const BUILTIN_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence']
} as Record<EmailCategory, string>
// Pill order in the "Everything else" filter row. 'unclassified' (threads the // Fallback for ids the registry doesn't know: "portfolio_updates" → "Portfolio updates".
// classifier hasn't reached yet) is deliberately last and unarchivable. function prettifyLabelId(id: string): string {
const CATEGORY_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence', 'unclassified'] const words = id.replace(/_/g, ' ').trim()
return words ? words[0].toUpperCase() + words.slice(1) : id
}
function categoryPillLabel(category: string): string { function labelNameFor(labels: EmailLabelInfo[], category: string): string {
if (category === 'unclassified') return 'Uncategorized' if (category === 'unclassified') return 'Uncategorized'
return ALL_CATEGORY_LABELS[category as EmailCategory] ?? category return labels.find((l) => l.id === category)?.name ?? prettifyLabelId(category)
} }
// The user sent the latest message and someone else is in the conversation — // The user sent the latest message and someone else is in the conversation —
@ -1303,6 +1315,15 @@ const ComposeBox = memo(function ComposeBox({
setGenerating(true) setGenerating(true)
try { try {
// The user's standing email-agent instructions steer this writer too —
// otherwise "keep drafts short, sign off with X" would apply to the
// auto-drafted replies but silently not to the Write-with-AI bar.
try {
const { instructions } = await window.ipc.invoke('gmail:getEmailInstructions', {})
if (instructions.trim()) {
system = `${system}\n\nThe user's standing email preferences — follow any that concern how emails should be written (tone, length, sign-off, what not to write):\n${instructions.trim()}`
}
} catch { /* draft without them */ }
// Draft through Copilot: no model override, so the backend resolves the // Draft through Copilot: no model override, so the backend resolves the
// same default model/provider the Copilot chat uses (models.json). // same default model/provider the Copilot chat uses (models.json).
const res = await window.ipc.invoke('llm:generate', { prompt, system }) const res = await window.ipc.invoke('llm:generate', { prompt, system })
@ -1970,6 +1991,7 @@ function ThreadDetail({
keysDisabled, keysDisabled,
onComposingChange, onComposingChange,
onSetCategory, onSetCategory,
labels = BUILTIN_LABELS,
}: { }: {
thread: GmailThread thread: GmailThread
onClose: () => void onClose: () => void
@ -1980,6 +2002,8 @@ function ThreadDetail({
onComposingChange?: (composing: boolean) => void onComposingChange?: (composing: boolean) => void
/** Present for inbox threads only — search results aren't in the cache the correction writes to. */ /** Present for inbox threads only — search results aren't in the cache the correction writes to. */
onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void> onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void>
/** The label registry (built-ins + user-defined) for the chip and correction dropdown. */
labels?: EmailLabelInfo[]
}) { }) {
const [composeMode, setComposeMode] = useState<ComposeMode | null>(null) const [composeMode, setComposeMode] = useState<ComposeMode | null>(null)
const [selfEmail, setSelfEmail] = useState<string>('') const [selfEmail, setSelfEmail] = useState<string>('')
@ -2069,17 +2093,17 @@ function ThreadDetail({
className="gmail-row-chip gmail-category-chip" className="gmail-row-chip gmail-category-chip"
title="Correct the category — the classifier learns from this" title="Correct the category — the classifier learns from this"
> >
{thread.category ? categoryPillLabel(thread.category) : 'Categorize'} {thread.category ? labelNameFor(labels, thread.category) : 'Categorize'}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="font-sans"> <DropdownMenuContent align="end" className="font-sans">
{(Object.keys(ALL_CATEGORY_LABELS) as EmailCategory[]).map((cat) => ( {labels.map((l) => (
<DropdownMenuItem <DropdownMenuItem
key={cat} key={l.id}
onSelect={() => { void onSetCategory(thread.threadId, cat) }} onSelect={() => { void onSetCategory(thread.threadId, l.id) }}
className={cn(cat === thread.category && 'font-semibold')} className={cn(l.id === thread.category && 'font-semibold')}
> >
{ALL_CATEGORY_LABELS[cat]} {l.name}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@ -2179,6 +2203,7 @@ const ThreadRow = memo(function ThreadRow({
chip, chip,
chipWaiting, chipWaiting,
onSetCategory, onSetCategory,
labels,
onToggle, onToggle,
onMarkRead, onMarkRead,
onArchive, onArchive,
@ -2201,6 +2226,8 @@ const ThreadRow = memo(function ThreadRow({
chip?: string | null chip?: string | null
chipWaiting?: boolean chipWaiting?: boolean
onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void> onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void>
/** Label registry — stable reference from EmailView state (this row is memoized). */
labels: EmailLabelInfo[]
onToggle: (thread: GmailThread) => void onToggle: (thread: GmailThread) => void
onMarkRead: (threadId: string, read?: boolean) => Promise<void> onMarkRead: (threadId: string, read?: boolean) => Promise<void>
onArchive: (threadId: string) => Promise<void> onArchive: (threadId: string) => Promise<void>
@ -2213,10 +2240,14 @@ const ThreadRow = memo(function ThreadRow({
}) { }) {
const latest = latestMessage(thread) const latest = latestMessage(thread)
const isUnread = thread.unread === true const isUnread = thread.unread === true
// Category chip on every row so the list itself answers "worth opening?". // Category chip on classified rows so the list itself answers "worth
// Plain correspondence stays chipless — labeling normal human mail // opening?". Plain correspondence gets none — with granular labels
// "Correspondence" adds noise, not information. // (Investor, Customer, …) available, "Direct" is the default case and
const categoryChip = (thread.category && CATEGORY_CHIP_LABELS[thread.category]) || null // absence reads cleaner than a chip stating it. It stays available in the
// correction dropdown and the Everything-else filter pills.
const categoryChip = thread.category && thread.category !== 'correspondence'
? labelNameFor(labels, thread.category)
: null
const stop = (e: React.MouseEvent | React.KeyboardEvent) => { const stop = (e: React.MouseEvent | React.KeyboardEvent) => {
e.stopPropagation() e.stopPropagation()
} }
@ -2294,12 +2325,87 @@ const ThreadRow = memo(function ThreadRow({
keysDisabled={keysDisabled} keysDisabled={keysDisabled}
onComposingChange={onComposingChange} onComposingChange={onComposingChange}
onSetCategory={onSetCategory} onSetCategory={onSetCategory}
labels={labels}
/> />
)} )}
</div> </div>
) )
}) })
// Free-text standing instructions for the email agent. Saved to
// config/email_instructions.md and injected into every classification /
// draft call as the highest-priority section of the system prompt.
function EmailInstructionsDialog({ open, onOpenChange, onSaved }: { open: boolean; onOpenChange: (open: boolean) => void; onSaved?: () => void }) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!open) return
let cancelled = false
setLoading(true)
window.ipc.invoke('gmail:getEmailInstructions', {})
.then((res) => { if (!cancelled) setText(res.instructions) })
.catch(() => {})
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [open])
const save = async () => {
setSaving(true)
try {
const result = await window.ipc.invoke('gmail:setEmailInstructions', { instructions: text })
if (result.ok) {
toast('Instructions saved — they apply to every email from now on.', 'success')
onOpenChange(false)
onSaved?.()
} else {
toast(`Could not save: ${result.error ?? 'unknown error'}`, 'error')
}
} catch (err) {
toast(`Could not save: ${err instanceof Error ? err.message : String(err)}`, 'error')
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="font-sans sm:max-w-[560px]">
<DialogTitle className="text-base font-semibold">Email agent instructions</DialogTitle>
<p className="text-sm text-muted-foreground">
Standing instructions for how your email is labeled, prioritized, and how draft replies are written.
The agent follows these over its defaults, on every new email. You can also define your own labels
here e.g. Create a label Clients for emails from active client accounts.
</p>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
disabled={loading}
rows={10}
maxLength={8000}
placeholder={
'Examples:\n' +
'- Emails from my investors are always important.\n' +
'- Investor updates I receive are direct emails, not news.\n' +
'- Never draft replies to recruiters or cold outreach.\n' +
'- Keep drafts short and casual; sign off with my first name.\n' +
'- Payment failure alerts are important; other billing emails are receipts.\n' +
'- Create a label "Customers" for emails from paying customers.'
}
className="min-h-[220px] font-sans text-sm leading-relaxed"
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button onClick={() => void save()} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save'}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
function ShortcutKey({ children }: { children: React.ReactNode }) { function ShortcutKey({ children }: { children: React.ReactNode }) {
return ( return (
<kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-foreground"> <kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-foreground">
@ -2479,6 +2585,17 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
// Gmail sync uses the native Google OAuth connection. // Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null) const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false)
const [instructionsOpen, setInstructionsOpen] = useState(false)
// Label registry (built-ins + user-defined). Fetched on mount and again
// after the instructions dialog saves, since saving can create labels.
const [emailLabels, setEmailLabels] = useState<EmailLabelInfo[]>(BUILTIN_LABELS)
const refreshLabels = useCallback(async () => {
try {
const res = await window.ipc.invoke('gmail:getEmailLabels', {})
if (res.labels?.length) setEmailLabels(res.labels)
} catch { /* keep built-ins */ }
}, [])
useEffect(() => { void refreshLabels() }, [refreshLabels])
// Keyboard navigation: the j/k focus cursor over the visible rows, plus the // Keyboard navigation: the j/k focus cursor over the visible rows, plus the
// "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position // "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position
// so it can re-anchor when the focused row disappears (archive/trash/reload). // so it can re-anchor when the focused row disappears (archive/trash/reload).
@ -2714,14 +2831,14 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
} }
return next return next
}) })
toast(`Filed as ${categoryPillLabel(category)} — noted for similar emails.`, 'success') toast(`Filed as ${labelNameFor(emailLabels, category)} — noted for similar emails.`, 'success')
} else if (result.error) { } else if (result.error) {
toast(`Could not update category: ${result.error}`, 'error') toast(`Could not update category: ${result.error}`, 'error')
} }
} catch (err) { } catch (err) {
toast(`Could not update category: ${err instanceof Error ? err.message : String(err)}`, 'error') toast(`Could not update category: ${err instanceof Error ? err.message : String(err)}`, 'error')
} }
}, [updateThreadInState, important.threads, other.threads]) }, [updateThreadInState, important.threads, other.threads, emailLabels])
const trashThreadAction = useCallback(async (threadId: string) => { const trashThreadAction = useCallback(async (threadId: string) => {
markLeaving(threadId, true) markLeaving(threadId, true)
@ -3055,7 +3172,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
// field, while a dialog is up, or while the inline reply composer is open. // field, while a dialog is up, or while the inline reply composer is open.
const listMode: 'search' | 'drafts' | 'inbox' = query.trim() ? 'search' : view === 'drafts' ? 'drafts' : 'inbox' const listMode: 'search' | 'drafts' | 'inbox' = query.trim() ? 'search' : view === 'drafts' ? 'drafts' : 'inbox'
const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen const anyModalOpen = composeOpen || settingsOpen || Boolean(editingDraft) || helpOpen || instructionsOpen
// Row identity for the focus cursor — must match each row's data-thread-id. // Row identity for the focus cursor — must match each row's data-thread-id.
const rowIdOf = useCallback((thread: GmailThread) => ( const rowIdOf = useCallback((thread: GmailThread) => (
@ -3328,6 +3445,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
chip={chip} chip={chip}
chipWaiting={chipWaiting} chipWaiting={chipWaiting}
onSetCategory={section ? setCategoryAction : undefined} onSetCategory={section ? setCategoryAction : undefined}
labels={emailLabels}
onToggle={toggleThread} onToggle={toggleThread}
onMarkRead={markThreadReadAction} onMarkRead={markThreadReadAction}
onArchive={archiveThreadAction} onArchive={archiveThreadAction}
@ -3423,6 +3541,15 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
onClick={() => setView('drafts')} onClick={() => setView('drafts')}
>Drafts{drafts.length > 0 ? ` (${drafts.length})` : ''}</button> >Drafts{drafts.length > 0 ? ` (${drafts.length})` : ''}</button>
</div> </div>
<button
type="button"
className="gmail-icon-button"
onClick={() => setInstructionsOpen(true)}
aria-label="Email agent instructions"
title="Email agent instructions"
>
<SlidersHorizontal size={18} />
</button>
<button <button
type="button" type="button"
className="gmail-icon-button" className="gmail-icon-button"
@ -3524,14 +3651,20 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
</div> </div>
{Object.keys(categoryCounts).length > 0 && ( {Object.keys(categoryCounts).length > 0 && (
<div className="gmail-category-pills"> <div className="gmail-category-pills">
{CATEGORY_PILL_ORDER.filter((c) => (categoryCounts[c] ?? 0) > 0).map((cat) => ( {[
...BUILTIN_PILL_ORDER,
...emailLabels.filter((l) => l.kind === 'custom').map((l) => l.id),
// Stale custom ids still present in counts render too.
...Object.keys(categoryCounts).filter((c) => c !== 'unclassified' && !BUILTIN_PILL_ORDER.includes(c) && !emailLabels.some((l) => l.id === c)),
'unclassified',
].filter((c) => (categoryCounts[c] ?? 0) > 0).map((cat) => (
<button <button
key={cat} key={cat}
type="button" type="button"
className={cn('gmail-category-pill', otherCategory === cat && 'gmail-category-pill-active')} className={cn('gmail-category-pill', otherCategory === cat && 'gmail-category-pill-active')}
onClick={() => setOtherCategory((prev) => (prev === cat ? null : cat))} onClick={() => setOtherCategory((prev) => (prev === cat ? null : cat))}
> >
{categoryPillLabel(cat)} <span className="gmail-category-pill-count">{categoryCounts[cat]}</span> {labelNameFor(emailLabels, cat)} <span className="gmail-category-pill-count">{categoryCounts[cat]}</span>
</button> </button>
))} ))}
{otherCategory && otherCategory !== 'unclassified' && ( {otherCategory && otherCategory !== 'unclassified' && (
@ -3543,13 +3676,13 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
> >
{bulkArchiving {bulkArchiving
? 'Archiving…' ? 'Archiving…'
: `Archive all ${categoryCounts[otherCategory] ?? ''} ${categoryPillLabel(otherCategory).toLowerCase()}`} : `Archive all ${categoryCounts[otherCategory] ?? ''} ${labelNameFor(emailLabels, otherCategory).toLowerCase()}`}
</button> </button>
)} )}
</div> </div>
)} )}
{other.threads.length === 0 && otherCategory !== null && !other.loadingPage && ( {other.threads.length === 0 && otherCategory !== null && !other.loadingPage && (
<div className="gmail-caughtup">Nothing filed as {categoryPillLabel(otherCategory).toLowerCase()}.</div> <div className="gmail-caughtup">Nothing filed as {labelNameFor(emailLabels, otherCategory).toLowerCase()}.</div>
)} )}
{visibleOther.map((t) => renderRow(t, 'other'))} {visibleOther.map((t) => renderRow(t, 'other'))}
{!other.hasReachedEnd && ( {!other.hasReachedEnd && (
@ -3596,6 +3729,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
)} )}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" /> <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
<ShortcutsHelpDialog open={helpOpen} onOpenChange={setHelpOpen} /> <ShortcutsHelpDialog open={helpOpen} onOpenChange={setHelpOpen} />
<EmailInstructionsDialog open={instructionsOpen} onOpenChange={setInstructionsOpen} onSaved={() => void refreshLabels()} />
</div> </div>
) )
} }

View file

@ -15,6 +15,8 @@ import { withUseCase } from '../analytics/use_case.js';
import type { GmailThreadSnapshot } from './sync_gmail.js'; import type { GmailThreadSnapshot } from './sync_gmail.js';
import { formatImportanceFeedbackForPrompt, maybeDistillImportanceRules } from './email_importance_feedback.js'; import { formatImportanceFeedbackForPrompt, maybeDistillImportanceRules } from './email_importance_feedback.js';
import { formatCategoryFeedbackForPrompt } from './email_category_feedback.js'; import { formatCategoryFeedbackForPrompt } from './email_category_feedback.js';
import { formatEmailInstructionsForPrompt } from './email_instructions.js';
import { getEmailLabels, type EmailLabelDef } from './email_labels.js';
const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md'); const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md');
const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync'); const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync');
@ -102,19 +104,14 @@ export async function getUserEmail(auth: OAuth2Client): Promise<string | null> {
} }
/** /**
* What kind of email this is shown as a chip in the inbox's "Everything * What kind of email this is shown as a chip on inbox rows and stamped
* else" section and stamped into the gmail_sync markdown for the knowledge * into the gmail_sync markdown for the knowledge pipeline. The value set is
* pipeline. Orthogonal to importance: a newsletter is almost always "other", * open: built-in ids plus user-defined labels from the email_labels registry
* but an investor update arriving as a newsletter can still carry knowledge. * (which is why this is a string, not a closed union). Orthogonal to
* importance: a newsletter is almost always "other", but an investor update
* arriving as a newsletter can still carry knowledge.
*/ */
export type EmailCategory = export type EmailCategory = string;
| 'correspondence'
| 'meeting'
| 'notification'
| 'newsletter'
| 'promotion'
| 'cold_outreach'
| 'receipt';
export interface Classification { export interface Classification {
importance: 'important' | 'other'; importance: 'important' | 'other';
@ -126,34 +123,46 @@ export interface Classification {
draftResponse?: string; draftResponse?: string;
} }
const ClassificationSchema = z.object({ // The category enum is built per call from the label registry, so labels the
importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'), // user defines in their instructions become valid classifier outputs without
category: z.enum(['correspondence', 'meeting', 'notification', 'newsletter', 'promotion', 'cold_outreach', 'receipt']).describe('What kind of email this is. correspondence = a real person writing to the user with prior engagement. meeting = scheduling/calendar invites involving named people. notification = automated system messages. newsletter = digests, industry reports, subscription content. promotion = marketing, offers, event/webinar invites from companies. cold_outreach = unsolicited pitches from strangers. receipt = completed-transaction confirmations (payments, payroll, tax filings, orders, travel bookings).'), // a code change.
knowledge: z.enum(['extract', 'skip']).describe('Whether this thread contains durable facts worth adding to the user\'s knowledge base about their people, companies, and projects. extract = real relationships (investor, customer, prospect, partner, vendor, team, advisor, press, personal) or substantive topics (deals, contracts, hiring, fundraising, support, incidents, real meetings, intros). skip = noise with no durable facts: marketing, newsletters, automated notifications, receipts, social/forum digests, cold outreach from strangers, job applicants and recruiters.'), function buildClassificationSchema(labelIds: string[]) {
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'), return z.object({
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'), importance: z.enum(['important', 'other']).describe('important = real correspondence, action-required, or content worth referencing later. other = newsletters, marketing, automated notifications, transactional receipts, cold outreach.'),
}); category: z.enum(labelIds as [string, ...string[]]).describe('Which category label fits this email best — definitions are in the system prompt. When a user-defined label and a built-in both fit, prefer the user-defined one.'),
knowledge: z.enum(['extract', 'skip']).describe('Whether this thread contains durable facts worth adding to the user\'s knowledge base about their people, companies, and projects. extract = real relationships (investor, customer, prospect, partner, vendor, team, advisor, press, personal) or substantive topics (deals, contracts, hiring, fundraising, support, incidents, real meetings, intros). skip = noise with no durable facts: marketing, newsletters, automated notifications, receipts, social/forum digests, cold outreach from strangers, job applicants and recruiters.'),
summary: z.string().optional().describe('One or two sentences capturing what the thread is about and any implied action. Required when importance is important. Omit when other.'),
draftResponse: z.string().optional().describe('A complete draft reply the user can send as-is or edit. Plain text with real line breaks (\\n): greeting on its own line, a blank line between paragraphs, and the sign-off on its own line(s) — e.g. "Hi Tyrone,\\n\\nThanks for the follow-up.\\n\\nBest,\\nJohn". If a sign-off name is included, use only the user\'s first name. Required when importance is important AND the thread implies a response is wanted. Omit when other, or when no response is appropriate (e.g. an FYI from a colleague that does not need a reply).'),
});
}
const SYSTEM_PROMPT = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user. function buildCategorySection(labels: EmailLabelDef[]): string {
const lines = [
`# Category`,
``,
`Pick exactly one category — it labels the email in the inbox:`,
];
for (const l of labels) {
const marker = l.kind === 'custom' ? ' [user-defined]' : '';
lines.push(`- ${l.id}${marker}: ${l.description}`);
}
const hasCustom = labels.some(l => l.kind === 'custom');
if (hasCustom) {
lines.push(``);
lines.push(`User-defined labels are the user's own filing system — when one of them fits, prefer it over a built-in that also fits.`);
}
return lines.join('\n');
}
const SYSTEM_PROMPT_HEAD = `You classify a Gmail thread for a personal inbox view and, when appropriate, draft a reply on behalf of the user.
# Importance # Importance
Decide if the thread is "important" or "other": Decide if the thread is "important" or "other":
- important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions). - important: real human correspondence the user is part of (customer, investor, team, vendor, candidate); a time-sensitive notification; a message that needs a response from the user; anything worth referencing later (contracts, pricing, deadlines, decisions).
- other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach. - other: newsletters, industry digests, marketing or promotional, product tips from vendors, automated notifications (verifications, recording uploads, platform policy updates), transactional confirmations (payment receipts, GST/tax filings, salary disbursements), unsolicited cold outreach.`;
# Category const SYSTEM_PROMPT_TAIL = `# Knowledge
Pick exactly one category it labels the email in the inbox:
- correspondence: a real person writing to (or with) the user there is prior engagement or a genuine relationship. A cold sender bumping their own unanswered email is NOT correspondence; it stays cold_outreach.
- meeting: scheduling with named people calendar invites, availability requests, reschedules. Automated meeting reminders with no human context ("your meeting starts in 10 minutes") are notification, not meeting.
- notification: automated system messages verifications, password resets, recording uploads, policy updates, deploy/CI alerts, social and forum digests.
- newsletter: subscription content, industry reports, community digests, product tips even from platforms the user actively uses.
- promotion: marketing, offers, product launches, webinar/workshop invites from companies, startup-program upsells.
- cold_outreach: unsolicited pitches from people with no prior engagement agencies, dev shops, freelancers, hiring platforms even when they mention the user's company by name or offer something free.
- receipt: completed transactions with no decision remaining payment receipts, payroll, tax filings, order/shipping confirmations, travel bookings.
# Knowledge
Decide whether the user's knowledge base should extract durable facts from this thread: Decide whether the user's knowledge base should extract durable facts from this thread:
- extract: threads involving real relationships (investors, customers, prospects, partners, vendors under contract, team, advisors, press, friends and family, government) or substantive topics (sales and deals, support, legal, finance decisions, hiring processes the user is running, fundraising, security incidents, infrastructure issues, real meetings with named people, events the user attends, warm intros, genuine follow-ups). - extract: threads involving real relationships (investors, customers, prospects, partners, vendors under contract, team, advisors, press, friends and family, government) or substantive topics (sales and deals, support, legal, finance decisions, hiring processes the user is running, fundraising, security incidents, infrastructure issues, real meetings with named people, events the user attends, warm intros, genuine follow-ups).
@ -294,9 +303,14 @@ export async function classifyThread(
const config = await resolveProviderConfig(provider); const config = await resolveProviderConfig(provider);
const model = createLanguageModel(config, modelId); const model = createLanguageModel(config, modelId);
// Assembled fresh per call so labels the user just defined apply to
// the very next classification (the registry read is mtime-cached).
const labels = getEmailLabels();
const basePrompt = `${SYSTEM_PROMPT_HEAD}\n\n${buildCategorySection(labels)}\n\n${SYSTEM_PROMPT_TAIL}`;
let systemPrompt = options.skipDraft let systemPrompt = options.skipDraft
? `${SYSTEM_PROMPT}\n\n# Skip the draft\n\nThe user already has their own draft in progress for this thread — DO NOT generate a draftResponse. Always omit the draftResponse field.` ? `${basePrompt}\n\n# Skip the draft\n\nThe user already has their own draft in progress for this thread — DO NOT generate a draftResponse. Always omit the draftResponse field.`
: SYSTEM_PROMPT; : basePrompt;
// The user's learned preferences override the generic criteria — // The user's learned preferences override the generic criteria —
// appended last so they take precedence. // appended last so they take precedence.
@ -308,12 +322,18 @@ export async function classifyThread(
if (categoryFeedback) { if (categoryFeedback) {
systemPrompt = `${systemPrompt}\n\n${categoryFeedback}`; systemPrompt = `${systemPrompt}\n\n${categoryFeedback}`;
} }
// The user's explicit standing instructions go last — the final word
// over both the generic criteria and the learned feedback.
const instructions = formatEmailInstructionsForPrompt();
if (instructions) {
systemPrompt = `${systemPrompt}\n\n${instructions}`;
}
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_classifier' }, () => generateObjectSafe({ const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_classifier' }, () => generateObjectSafe({
model, model,
system: systemPrompt, system: systemPrompt,
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar), prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
schema: ClassificationSchema, schema: buildClassificationSchema(labels.map((l) => l.id)),
retry: true, retry: true,
})); }));

View file

@ -0,0 +1,69 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from '../config/config.js';
/**
* Free-text user instructions for the email classifier/drafter the direct
* channel alongside the learned feedback loops. Whatever the user writes here
* is injected verbatim into every classification call (which also writes the
* pre-drafted replies), so it steers importance, category, knowledge AND
* draft tone in one place: "never draft replies to recruiters", "investor
* updates are correspondence, not newsletters", "sign off with 'Cheers'".
*
* Stored as plain markdown so it's also editable directly on disk.
*/
const INSTRUCTIONS_PATH = path.join(WorkDir, 'config', 'email_instructions.md');
const MAX_LENGTH = 8000;
/**
* First-run seed: a starter set of granular labels for real human mail, so
* the user opens the instructions dialog to something concrete they can edit
* or delete rather than an empty box. Must stay in sync with
* DEFAULT_CUSTOM_LABELS in email_labels.ts these lines are what the label
* extractor would produce those labels from on the next save.
*/
export const DEFAULT_EMAIL_INSTRUCTIONS = [
'Create a label "Investor" for emails from investors and VCs.',
'Create a label "Candidate" for emails from job applicants and people interviewing with us.',
'Create a label "Customer" for emails from paying customers and active pilots.',
].join('\n');
export function loadEmailInstructions(): string {
try {
return fs.readFileSync(INSTRUCTIONS_PATH, 'utf-8').trim();
} catch {
// First run: seed the file so the dialog opens populated. An existing
// file — even one the user saved as empty — is never overwritten.
try {
const dir = path.dirname(INSTRUCTIONS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(INSTRUCTIONS_PATH, DEFAULT_EMAIL_INSTRUCTIONS + '\n', { flag: 'wx' });
} catch { /* raced or unwritable — fall through */ }
return DEFAULT_EMAIL_INSTRUCTIONS;
}
}
export function saveEmailInstructions(text: string): { ok: boolean; error?: string } {
try {
const dir = path.dirname(INSTRUCTIONS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(INSTRUCTIONS_PATH, text.slice(0, MAX_LENGTH).trim() + '\n');
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
/** Render for injection into the classifier system prompt; null when empty. */
export function formatEmailInstructionsForPrompt(): string | null {
const text = loadEmailInstructions();
if (!text) return null;
return [
`# The user's standing instructions (highest priority — follow these over everything above)`,
``,
`The user wrote these instructions for you directly. They govern importance, category, knowledge, and how drafts are written:`,
``,
text,
].join('\n');
}

View file

@ -0,0 +1,183 @@
import fs from 'fs';
import path from 'path';
import { z } from 'zod';
import { WorkDir } from '../config/config.js';
import { createLanguageModel } from '../models/models.js';
import { generateObjectSafe } from '../models/structured.js';
import { getKgModel, resolveProviderConfig } from '../models/defaults.js';
import { captureLlmUsage } from '../analytics/usage.js';
import { withUseCase } from '../analytics/use_case.js';
/**
* The email label registry: the built-in category set plus user-defined
* custom labels. The classifier's category enum, its prompt section, and the
* UI's chips / filter pills / correction dropdown are all built from this
* one list, so a label created here exists everywhere at once.
*
* Custom labels come from the user's free-text agent instructions: on save,
* an extraction pass (syncCustomLabelsFromInstructions) pulls out any labels
* the user asked for and persists them to config/email_labels.json. Built-in
* labels are code-defined and immutable; ids are stable slugs because they
* get stamped into snapshots, markdown frontmatter, and feedback files.
*/
export interface EmailLabelDef {
/** Stable slug — stamped into snapshots/frontmatter; never renamed. */
id: string;
/** Display name shown on chips, pills, and the correction dropdown. */
name: string;
/** Classifier guidance — what belongs under this label. */
description: string;
kind: 'builtin' | 'custom';
}
export const BUILTIN_EMAIL_LABELS: EmailLabelDef[] = [
{ id: 'correspondence', name: 'Direct', kind: 'builtin', description: 'A real person writing to (or with) the user — there is prior engagement or a genuine relationship. A cold sender bumping their own unanswered email is NOT correspondence; that stays cold_outreach.' },
{ id: 'meeting', name: 'Calendar', kind: 'builtin', description: 'Scheduling with named people — calendar invites, availability requests, reschedules. Automated meeting reminders with no human context ("your meeting starts in 10 minutes") are notification, not meeting.' },
{ id: 'notification', name: 'Notification', kind: 'builtin', description: 'Automated system messages — verifications, password resets, recording uploads, policy updates, deploy/CI alerts, social and forum digests.' },
{ id: 'newsletter', name: 'News', kind: 'builtin', description: 'Subscription content, industry reports, community digests, product tips — even from platforms the user actively uses.' },
{ id: 'promotion', name: 'Marketing', kind: 'builtin', description: 'Marketing, offers, product launches, webinar/workshop invites from companies, startup-program upsells.' },
{ id: 'cold_outreach', name: 'Pitch', kind: 'builtin', description: 'Unsolicited pitches from people with no prior engagement — agencies, dev shops, freelancers, hiring platforms — even when they mention the user\'s company by name or offer something free.' },
{ id: 'receipt', name: 'Receipt', kind: 'builtin', description: 'Completed transactions with no decision remaining — payment receipts, payroll, tax filings, order/shipping confirmations, travel bookings.' },
];
const LABELS_PATH = path.join(WorkDir, 'config', 'email_labels.json');
const MAX_CUSTOM_LABELS = 12;
const BUILTIN_IDS = new Set(BUILTIN_EMAIL_LABELS.map(l => l.id));
/**
* First-run seed, matching DEFAULT_EMAIL_INSTRUCTIONS in email_instructions.ts
* line for line so the starter labels exist before the user ever saves, and
* the next save's extraction round-trips to the same set.
*/
const DEFAULT_CUSTOM_LABELS: EmailLabelDef[] = [
{ id: 'investor', name: 'Investor', kind: 'custom', description: 'Emails from investors and VCs — current, prospective, or in an active raise.' },
{ id: 'candidate', name: 'Candidate', kind: 'custom', description: 'Emails from job applicants and people interviewing with the user\'s company.' },
{ id: 'customer', name: 'Customer', kind: 'custom', description: 'Emails from paying customers and active pilots.' },
];
let cachedCustom: EmailLabelDef[] | null = null;
let cachedMtimeMs: number | null = null;
function loadCustomLabels(): EmailLabelDef[] {
try {
if (!fs.existsSync(LABELS_PATH)) {
// First run: seed the starter custom set (mirrors the seeded
// instructions). A user who deletes those instruction lines gets
// the labels removed by the next save's sync — this seed only
// ever fires when no labels file exists at all.
saveCustomLabels(DEFAULT_CUSTOM_LABELS);
}
const stats = fs.statSync(LABELS_PATH);
if (cachedCustom && cachedMtimeMs === stats.mtimeMs) return cachedCustom;
const parsed = JSON.parse(fs.readFileSync(LABELS_PATH, 'utf-8'));
const custom: EmailLabelDef[] = Array.isArray(parsed.custom)
? parsed.custom
.filter((l: EmailLabelDef) => l && typeof l.id === 'string' && typeof l.name === 'string' && !BUILTIN_IDS.has(l.id))
.map((l: EmailLabelDef) => ({ id: l.id, name: l.name, description: String(l.description ?? ''), kind: 'custom' as const }))
: [];
cachedCustom = custom.slice(0, MAX_CUSTOM_LABELS);
cachedMtimeMs = stats.mtimeMs;
return cachedCustom;
} catch {
cachedCustom = null;
cachedMtimeMs = null;
return [];
}
}
function saveCustomLabels(custom: EmailLabelDef[]): void {
const dir = path.dirname(LABELS_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = LABELS_PATH + '.tmp';
fs.writeFileSync(tmp, JSON.stringify({ custom }, null, 2) + '\n');
fs.renameSync(tmp, LABELS_PATH);
cachedCustom = null;
cachedMtimeMs = null;
}
/** Built-ins followed by the user's custom labels. Never empty. */
export function getEmailLabels(): EmailLabelDef[] {
return [...BUILTIN_EMAIL_LABELS, ...loadCustomLabels()];
}
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 40);
}
const ExtractedLabels = z.object({
labels: z.array(z.object({
name: z.string().describe('Short display name for the label, e.g. "Investor", "Portfolio", "Legal".'),
description: z.string().describe('One or two sentences defining what belongs under this label, written as classifier guidance, derived from the user\'s instructions.'),
})).max(MAX_CUSTOM_LABELS).describe('The custom email labels the user\'s instructions define. Empty when the instructions define none.'),
});
/**
* Extract user-defined labels from the free-text agent instructions and
* persist them as the custom label set (full replace: labels the user has
* removed from their instructions disappear from the registry; threads
* already stamped with a removed id keep it and render via a fallback).
*
* Steering rules for existing labels ("investor updates are Direct, not
* News") are NOT labels and must not create one the instructions text
* itself is injected into every classification call and handles steering.
*/
export async function syncCustomLabelsFromInstructions(instructions: string): Promise<{ labels: EmailLabelDef[] }> {
const text = instructions.trim();
if (!text) {
saveCustomLabels([]);
return { labels: getEmailLabels() };
}
const { model: modelId, provider } = await getKgModel();
const config = await resolveProviderConfig(provider);
const model = createLanguageModel(config, modelId);
const builtinList = BUILTIN_EMAIL_LABELS.map(l => `- ${l.id} ("${l.name}"): ${l.description}`).join('\n');
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_label_extractor' }, () => generateObjectSafe({
model,
system: [
`You read a user's written instructions for their email agent and extract any CUSTOM CATEGORY LABELS the instructions define.`,
``,
`A custom label exists only when the user clearly wants emails grouped under a name of their own — "create a label Portfolio for...", "tag anything about the Series A as Fundraise", "I want an Investors label".`,
``,
`These are NOT custom labels:`,
`- Steering rules that map mail onto an existing built-in label ("investor updates are Direct, not News").`,
`- Importance rules ("emails from my board are always important").`,
`- Drafting preferences ("keep replies short").`,
``,
`Built-in labels that already exist (never re-create these or near-synonyms of them):`,
builtinList,
``,
`Return an empty list when the instructions define no custom labels.`,
].join('\n'),
prompt: text,
schema: ExtractedLabels,
retry: true,
}));
captureLlmUsage({
useCase: 'knowledge_sync',
subUseCase: 'email_label_extractor',
model: modelId,
provider,
usage: result.usage,
});
const seen = new Set<string>();
const custom: EmailLabelDef[] = [];
for (const l of result.object.labels) {
const id = slugify(l.name);
if (!id || BUILTIN_IDS.has(id) || seen.has(id)) continue;
seen.add(id);
custom.push({ id, name: l.name.trim(), description: l.description.trim(), kind: 'custom' });
}
saveCustomLabels(custom);
return { labels: getEmailLabels() };
}

View file

@ -305,13 +305,38 @@ const ipcSchemas = {
'gmail:setCategory': { 'gmail:setCategory': {
req: z.object({ req: z.object({
threadId: z.string().min(1), threadId: z.string().min(1),
category: z.enum(['correspondence', 'meeting', 'notification', 'newsletter', 'promotion', 'cold_outreach', 'receipt']), // Open string: valid ids come from the email label registry (built-ins
// plus user-defined labels), which the renderer fetches at runtime.
category: z.string().min(1).max(40),
}), }),
res: z.object({ res: z.object({
ok: z.boolean(), ok: z.boolean(),
error: z.string().optional(), error: z.string().optional(),
}), }),
}, },
// The label registry: built-in categories plus labels the user defined in
// their agent instructions. Chips, filter pills, and the correction
// dropdown all render from this list.
'gmail:getEmailLabels': {
req: z.object({}),
res: z.object({
labels: z.array(z.object({
id: z.string(),
name: z.string(),
kind: z.enum(['builtin', 'custom']),
})),
}),
},
// Free-text standing instructions injected into every classification /
// draft call. Stored at config/email_instructions.md.
'gmail:getEmailInstructions': {
req: z.object({}),
res: z.object({ instructions: z.string() }),
},
'gmail:setEmailInstructions': {
req: z.object({ instructions: z.string().max(8000) }),
res: z.object({ ok: z.boolean(), error: z.string().optional() }),
},
// Archive every "Everything else" thread of one category in a single sweep. // Archive every "Everything else" thread of one category in a single sweep.
'gmail:archiveCategory': { 'gmail:archiveCategory': {
req: z.object({ category: z.string().min(1) }), req: z.object({ category: z.string().min(1) }),