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 { 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 { 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 { 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';
@ -921,6 +923,26 @@ export function setupIpcHandlers() {
'gmail:archiveCategory': async (_event, args) => {
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) => {
return archiveThread(args.threadId);
},

View file

@ -460,12 +460,24 @@
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 {
flex-shrink: 0;
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: inherit;
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 {
justify-self: end;
color: var(--gm-text-faint);

View file

@ -1,5 +1,5 @@
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 StarterKit from '@tiptap/starter-kit'
import Link from '@tiptap/extension-link'
@ -12,6 +12,7 @@ import { useTheme } from '@/contexts/theme-context'
import { SettingsDialog } from '@/components/settings-dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import {
DropdownMenu,
@ -96,34 +97,45 @@ function latestMessage(thread: GmailThread): GmailThreadMessage | undefined {
return thread.messages[thread.messages.length - 1]
}
// Chip labels for the "Everything else" section — the reason a thread was
// filed there. Plain correspondence gets no chip: chips explain the filing,
// and correspondence is the default kind of mail.
const CATEGORY_CHIP_LABELS: Record<string, string> = {
meeting: 'Meeting',
notification: 'Notification',
newsletter: 'Newsletter',
promotion: 'Promotion',
cold_outreach: 'Cold outreach',
receipt: 'Receipt',
// The label set (chips, filter pills, correction dropdown) comes from the
// backend label registry: built-ins (display names follow Superhuman's Auto
// Label vocabulary — Marketing / Pitch / News / Calendar) plus any labels the
// user defined in their agent instructions. This static copy of the built-ins
// is the fallback used until the registry loads, and for ids the registry no
// longer knows (a custom label the user later removed).
type EmailCategory = string
interface EmailLabelInfo {
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 —
// "this newsletter is actually a real person" is the most valuable fix).
const ALL_CATEGORY_LABELS: Record<EmailCategory, string> = {
correspondence: 'Correspondence',
...CATEGORY_CHIP_LABELS,
} as Record<EmailCategory, string>
// Pill order in the "Everything else" filter row: noise first (that's what
// gets bulk-archived), then the rest of the built-ins; custom labels are
// appended in registry order at render time. 'unclassified' (threads the
// classifier hasn't reached yet) is always last and unarchivable.
const BUILTIN_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence']
// Pill order in the "Everything else" filter row. 'unclassified' (threads the
// classifier hasn't reached yet) is deliberately last and unarchivable.
const CATEGORY_PILL_ORDER = ['newsletter', 'promotion', 'notification', 'cold_outreach', 'receipt', 'meeting', 'correspondence', 'unclassified']
// Fallback for ids the registry doesn't know: "portfolio_updates" → "Portfolio updates".
function prettifyLabelId(id: string): string {
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'
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 —
@ -1303,6 +1315,15 @@ const ComposeBox = memo(function ComposeBox({
setGenerating(true)
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
// same default model/provider the Copilot chat uses (models.json).
const res = await window.ipc.invoke('llm:generate', { prompt, system })
@ -1970,6 +1991,7 @@ function ThreadDetail({
keysDisabled,
onComposingChange,
onSetCategory,
labels = BUILTIN_LABELS,
}: {
thread: GmailThread
onClose: () => void
@ -1980,6 +2002,8 @@ function ThreadDetail({
onComposingChange?: (composing: boolean) => void
/** Present for inbox threads only — search results aren't in the cache the correction writes to. */
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 [selfEmail, setSelfEmail] = useState<string>('')
@ -2069,17 +2093,17 @@ function ThreadDetail({
className="gmail-row-chip gmail-category-chip"
title="Correct the category — the classifier learns from this"
>
{thread.category ? categoryPillLabel(thread.category) : 'Categorize'}
{thread.category ? labelNameFor(labels, thread.category) : 'Categorize'}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="font-sans">
{(Object.keys(ALL_CATEGORY_LABELS) as EmailCategory[]).map((cat) => (
{labels.map((l) => (
<DropdownMenuItem
key={cat}
onSelect={() => { void onSetCategory(thread.threadId, cat) }}
className={cn(cat === thread.category && 'font-semibold')}
key={l.id}
onSelect={() => { void onSetCategory(thread.threadId, l.id) }}
className={cn(l.id === thread.category && 'font-semibold')}
>
{ALL_CATEGORY_LABELS[cat]}
{l.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@ -2179,6 +2203,7 @@ const ThreadRow = memo(function ThreadRow({
chip,
chipWaiting,
onSetCategory,
labels,
onToggle,
onMarkRead,
onArchive,
@ -2201,6 +2226,8 @@ const ThreadRow = memo(function ThreadRow({
chip?: string | null
chipWaiting?: boolean
onSetCategory?: (threadId: string, category: EmailCategory) => Promise<void>
/** Label registry — stable reference from EmailView state (this row is memoized). */
labels: EmailLabelInfo[]
onToggle: (thread: GmailThread) => void
onMarkRead: (threadId: string, read?: boolean) => Promise<void>
onArchive: (threadId: string) => Promise<void>
@ -2213,10 +2240,14 @@ const ThreadRow = memo(function ThreadRow({
}) {
const latest = latestMessage(thread)
const isUnread = thread.unread === true
// Category chip on every row so the list itself answers "worth opening?".
// Plain correspondence stays chipless — labeling normal human mail
// "Correspondence" adds noise, not information.
const categoryChip = (thread.category && CATEGORY_CHIP_LABELS[thread.category]) || null
// Category chip on classified rows so the list itself answers "worth
// opening?". Plain correspondence gets none — with granular labels
// (Investor, Customer, …) available, "Direct" is the default case and
// 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) => {
e.stopPropagation()
}
@ -2294,12 +2325,87 @@ const ThreadRow = memo(function ThreadRow({
keysDisabled={keysDisabled}
onComposingChange={onComposingChange}
onSetCategory={onSetCategory}
labels={labels}
/>
)}
</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 }) {
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">
@ -2479,6 +2585,17 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
// Gmail sync uses the native Google OAuth connection.
const [emailConnection, setEmailConnection] = useState<GmailConnectionStatus | null>(null)
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
// "?" shortcuts overlay. lastFocusedIndexRef remembers the cursor's position
// 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
})
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) {
toast(`Could not update category: ${result.error}`, 'error')
}
} catch (err) {
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) => {
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.
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.
const rowIdOf = useCallback((thread: GmailThread) => (
@ -3328,6 +3445,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
chip={chip}
chipWaiting={chipWaiting}
onSetCategory={section ? setCategoryAction : undefined}
labels={emailLabels}
onToggle={toggleThread}
onMarkRead={markThreadReadAction}
onArchive={archiveThreadAction}
@ -3423,6 +3541,15 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
onClick={() => setView('drafts')}
>Drafts{drafts.length > 0 ? ` (${drafts.length})` : ''}</button>
</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
type="button"
className="gmail-icon-button"
@ -3524,14 +3651,20 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
</div>
{Object.keys(categoryCounts).length > 0 && (
<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
key={cat}
type="button"
className={cn('gmail-category-pill', otherCategory === cat && 'gmail-category-pill-active')}
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>
))}
{otherCategory && otherCategory !== 'unclassified' && (
@ -3543,13 +3676,13 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
>
{bulkArchiving
? 'Archiving…'
: `Archive all ${categoryCounts[otherCategory] ?? ''} ${categoryPillLabel(otherCategory).toLowerCase()}`}
: `Archive all ${categoryCounts[otherCategory] ?? ''} ${labelNameFor(emailLabels, otherCategory).toLowerCase()}`}
</button>
)}
</div>
)}
{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'))}
{!other.hasReachedEnd && (
@ -3596,6 +3729,7 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery
)}
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} defaultTab="connections" />
<ShortcutsHelpDialog open={helpOpen} onOpenChange={setHelpOpen} />
<EmailInstructionsDialog open={instructionsOpen} onOpenChange={setInstructionsOpen} onSaved={() => void refreshLabels()} />
</div>
)
}

View file

@ -15,6 +15,8 @@ import { withUseCase } from '../analytics/use_case.js';
import type { GmailThreadSnapshot } from './sync_gmail.js';
import { formatImportanceFeedbackForPrompt, maybeDistillImportanceRules } from './email_importance_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 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
* else" section and stamped into the gmail_sync markdown for the knowledge
* pipeline. Orthogonal to importance: a newsletter is almost always "other",
* but an investor update arriving as a newsletter can still carry knowledge.
* What kind of email this is shown as a chip on inbox rows and stamped
* into the gmail_sync markdown for the knowledge pipeline. The value set is
* open: built-in ids plus user-defined labels from the email_labels registry
* (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 =
| 'correspondence'
| 'meeting'
| 'notification'
| 'newsletter'
| 'promotion'
| 'cold_outreach'
| 'receipt';
export type EmailCategory = string;
export interface Classification {
importance: 'important' | 'other';
@ -126,34 +123,46 @@ export interface Classification {
draftResponse?: string;
}
const ClassificationSchema = z.object({
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(['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).'),
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).'),
});
// The category enum is built per call from the label registry, so labels the
// user defines in their instructions become valid classifier outputs without
// a code change.
function buildClassificationSchema(labelIds: string[]) {
return z.object({
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
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).
- 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
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
const SYSTEM_PROMPT_TAIL = `# Knowledge
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).
@ -294,9 +303,14 @@ export async function classifyThread(
const config = await resolveProviderConfig(provider);
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
? `${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.`
: SYSTEM_PROMPT;
? `${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.`
: basePrompt;
// The user's learned preferences override the generic criteria —
// appended last so they take precedence.
@ -308,12 +322,18 @@ export async function classifyThread(
if (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({
model,
system: systemPrompt,
prompt: buildPrompt(snapshot, userEmail, styleGuide, calendar),
schema: ClassificationSchema,
schema: buildClassificationSchema(labels.map((l) => l.id)),
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': {
req: z.object({
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({
ok: z.boolean(),
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.
'gmail:archiveCategory': {
req: z.object({ category: z.string().min(1) }),