From b2158f320f637cb6c6f3bc491b71b83d60cd959f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:02:22 +0530 Subject: [PATCH 1/2] add email improvements --- apps/x/ANALYTICS.md | 1 - apps/x/apps/main/src/ipc.ts | 11 +- apps/x/apps/main/src/main.ts | 4 - apps/x/apps/renderer/src/App.css | 108 ++++++ .../renderer/src/components/email-view.tsx | 341 +++++++++++++++--- apps/x/packages/core/src/knowledge/README.md | 2 +- .../core/src/knowledge/build_graph.test.ts | 52 +++ .../core/src/knowledge/build_graph.ts | 41 ++- .../knowledge/classification_stamp.test.ts | 91 +++++ .../core/src/knowledge/classify_thread.ts | 74 +++- .../src/knowledge/email_category_feedback.ts | 91 +++++ .../core/src/knowledge/label_emails.ts | 257 ------------- .../core/src/knowledge/labeling_agent.ts | 195 ---------- .../core/src/knowledge/labeling_state.ts | 48 --- .../core/src/knowledge/note_creation.ts | 45 +-- .../core/src/knowledge/run_pipeline.ts | 50 +-- .../packages/core/src/knowledge/sync_gmail.ts | 317 +++++++++++++++- .../packages/core/src/knowledge/tag_system.ts | 34 -- apps/x/packages/core/src/models/defaults.ts | 4 +- .../src/runtime/assembly/registry.test.ts | 2 - .../core/src/runtime/assembly/registry.ts | 2 - apps/x/packages/shared/src/blocks.ts | 4 + apps/x/packages/shared/src/ipc.ts | 27 ++ 23 files changed, 1117 insertions(+), 684 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/build_graph.test.ts create mode 100644 apps/x/packages/core/src/knowledge/classification_stamp.test.ts create mode 100644 apps/x/packages/core/src/knowledge/email_category_feedback.ts delete mode 100644 apps/x/packages/core/src/knowledge/label_emails.ts delete mode 100644 apps/x/packages/core/src/knowledge/labeling_agent.ts delete mode 100644 apps/x/packages/core/src/knowledge/labeling_state.ts diff --git a/apps/x/ANALYTICS.md b/apps/x/ANALYTICS.md index 56a077ac..0f4b3155 100644 --- a/apps/x/ANALYTICS.md +++ b/apps/x/ANALYTICS.md @@ -53,7 +53,6 @@ Every `llm_usage` emit point in the codebase: | `knowledge_sync` | `agent_notes` | yes | Agent notes learning service | `packages/core/src/knowledge/agent_notes.ts:309` (createRun) | | `knowledge_sync` | `tag_notes` | yes | Note tagging | `packages/core/src/knowledge/tag_notes.ts:86` (createRun) | | `knowledge_sync` | `build_graph` | yes | Knowledge graph note creation | `packages/core/src/knowledge/build_graph.ts:253` (createRun) | -| `knowledge_sync` | `label_emails` | yes | Email labeling | `packages/core/src/knowledge/label_emails.ts:73` (createRun) | | `knowledge_sync` | `inline_task_run` | yes | Inline `@rowboat` task execution (two call sites) | `packages/core/src/knowledge/inline_tasks.ts:471, 552` (createRun) | | `knowledge_sync` | `inline_task_classify` | no | Inline task scheduling classifier (`generateText`) | `packages/core/src/knowledge/inline_tasks.ts:673` | | `knowledge_sync` | `pre_built` | yes | Pre-built scheduled agents | `packages/core/src/pre_built/runner.ts:43` (createRun) | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index ef530ff0..f43fa3e7 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -98,7 +98,7 @@ import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js'; import { getAccessToken } from '@x/core/dist/auth/tokens.js'; import { getRowboatConfig } from '@x/core/dist/config/rowboat.js'; import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js'; -import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } 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 { 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'; @@ -880,7 +880,7 @@ export function setupIpcHandlers() { return listImportantThreads({ cursor: args.cursor, limit: args.limit }); }, 'gmail:getEverythingElse': async (_event, args) => { - return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit }); + return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit, category: args.category }); }, 'gmail:triggerSync': async () => { triggerGmailSync(); @@ -914,6 +914,13 @@ export function setupIpcHandlers() { const result = setThreadImportance(args.threadId, args.importance); return { ok: result.success, previous: result.previous, error: result.error }; }, + 'gmail:setCategory': async (_event, args) => { + const result = setThreadCategory(args.threadId, args.category); + return { ok: result.success, error: result.error }; + }, + 'gmail:archiveCategory': async (_event, args) => { + return archiveCategoryThreads(args.category); + }, 'gmail:archiveThread': async (_event, args) => { return archiveThread(args.threadId); }, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 1e1d3874..30cc9886 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -23,7 +23,6 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; -import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js"; import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js"; import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js"; import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js"; @@ -522,9 +521,6 @@ app.whenReady().then(async () => { // start knowledge graph builder initGraphBuilder(); - // start email labeling service - initEmailLabeling(); - // start note tagging service initNoteTagging(); diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 252a145f..94ac02df 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -473,6 +473,114 @@ font-variant-numeric: tabular-nums; } +/* Category / waiting-age pill at the right edge of the content column. */ +.gmail-row-chip { + flex-shrink: 0; + margin-left: auto; + padding: 1px 8px; + border: 1px solid var(--gm-border); + border-radius: 999px; + color: var(--gm-text-faint); + font-size: 11px; + line-height: 16px; + white-space: nowrap; +} + +/* Two chips on one row (category + status): only the first takes the auto + margin; the second sits right next to it, so the pair clusters at the + right edge instead of spreading across the free space. */ +.gmail-row-chip + .gmail-row-chip { + margin-left: 6px; +} + +.gmail-row-chip-waiting { + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-muted); + font-variant-numeric: tabular-nums; +} + +.gmail-row-chip-ready { + border-color: transparent; + background: var(--gm-bg-row-selected); + color: var(--gm-accent); +} + +/* Shown in place of the "Needs you" section when it's empty. */ +.gmail-caughtup { + padding: 18px 24px; + color: var(--gm-text-faint); + font-size: 13px; +} + +/* Category filter pills under the "Everything else" header. */ +.gmail-category-pills { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 8px 24px; + border-bottom: 1px solid var(--gm-border); +} + +.gmail-category-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 10px; + border: 1px solid var(--gm-border); + border-radius: 999px; + background: transparent; + color: var(--gm-text-muted); + font-family: inherit; + font-size: 12px; + line-height: 18px; + cursor: pointer; + transition: background 120ms ease, color 120ms ease, border-color 120ms ease; +} + +.gmail-category-pill:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-active { + background: var(--gm-bg-row-selected); + border-color: var(--gm-accent); + color: var(--gm-text-strong); +} + +.gmail-category-pill-count { + color: var(--gm-text-faint); + font-variant-numeric: tabular-nums; +} + +.gmail-category-pill-archive { + margin-left: auto; + border-color: transparent; + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + +.gmail-category-pill-archive:disabled { + opacity: 0.6; + cursor: default; +} + +/* The category chip in the thread-detail toolbar is a button (dropdown trigger). */ +.gmail-category-chip { + flex-shrink: 0; + background: transparent; + cursor: pointer; + font-family: inherit; + transition: background 120ms ease, color 120ms ease; +} + +.gmail-category-chip:hover { + background: var(--gm-bg-pill-hover); + color: var(--gm-text-strong); +} + .gmail-detail { display: flex; min-width: 0; diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 173e22ee..93e3734d 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -13,6 +13,12 @@ import { SettingsDialog } from '@/components/settings-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' type GmailThread = blocks.GmailThread type GmailThreadMessage = blocks.GmailThreadMessage @@ -90,6 +96,59 @@ 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 = { + meeting: 'Meeting', + notification: 'Notification', + newsletter: 'Newsletter', + promotion: 'Promotion', + cold_outreach: 'Cold outreach', + receipt: 'Receipt', +} + +type EmailCategory = 'correspondence' | 'meeting' | 'notification' | 'newsletter' | 'promotion' | 'cold_outreach' | 'receipt' + +// 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 = { + correspondence: 'Correspondence', + ...CATEGORY_CHIP_LABELS, +} as Record + +// 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'] + +function categoryPillLabel(category: string): string { + if (category === 'unclassified') return 'Uncategorized' + return ALL_CATEGORY_LABELS[category as EmailCategory] ?? category +} + +// The user sent the latest message and someone else is in the conversation — +// the ball is in their court. Derived from the messages on every render (never +// stored) so it can't go stale the way an arrival-time label would. +function isAwaitingThem(thread: GmailThread, selfEmail: string | null | undefined): boolean { + const self = (selfEmail || '').trim().toLowerCase() + if (!self) return false + const latest = latestMessage(thread) + if (!(latest?.from || '').toLowerCase().includes(self)) return false + return thread.messages.some((m) => m.from && !m.from.toLowerCase().includes(self)) +} + +function daysSince(value?: string): number | null { + if (!value) return null + const ms = Date.parse(value) + if (!Number.isFinite(ms)) return null + return Math.max(0, Math.floor((Date.now() - ms) / 86_400_000)) +} + +function waitingChip(thread: GmailThread): string { + const days = daysSince(latestMessage(thread)?.date || thread.date) + return days && days > 0 ? `Waiting ${days}d` : 'Waiting' +} + // Split a raw header recipient string (e.g. `"Jo Bloggs" , b@y.com`) into // individual address tokens, respecting commas inside quotes/angle brackets. function splitAddresses(raw?: string): string[] { @@ -1910,6 +1969,7 @@ function ThreadDetail({ hidden, keysDisabled, onComposingChange, + onSetCategory, }: { thread: GmailThread onClose: () => void @@ -1918,6 +1978,8 @@ function ThreadDetail({ keysDisabled?: boolean /** Reports whether the inline composer is open, so list shortcuts pause. */ 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 }) { const [composeMode, setComposeMode] = useState(null) const [selfEmail, setSelfEmail] = useState('') @@ -1999,6 +2061,30 @@ function ThreadDetail({ ) : hasAny ? (
- {important.threads.length > 0 && ( + {visibleNeedsYou.length > 0 ? (
- Important + Needs you - {important.threads.length}{important.hasReachedEnd ? '' : '+'} thread{important.threads.length === 1 ? '' : 's'} + {visibleNeedsYou.length}{important.hasReachedEnd ? '' : '+'} thread{visibleNeedsYou.length === 1 ? '' : 's'}
- {visibleImportant.map((t) => renderRow(t, 'important'))} - {!important.hasReachedEnd && ( - loadNextPage('important')} - loading={important.loadingPage} - /> - )} + {visibleNeedsYou.map((t) => renderRow(t, 'important', t.draft_response ? 'Reply ready' : null))} +
+ ) : important.hasReachedEnd && !important.loadingPage ? ( +
You’re caught up — nothing needs a reply.
+ ) : null} + {visibleWaiting.length > 0 && ( +
+
+ Waiting on them + + {visibleWaiting.length}{important.hasReachedEnd ? '' : '+'} thread{visibleWaiting.length === 1 ? '' : 's'} + +
+ {visibleWaiting.map((t) => renderRow(t, 'important', waitingChip(t), true))}
)} - {important.hasReachedEnd && other.threads.length > 0 && ( + {/* Pages of "Important" feed both sections above, so the sentinel + lives after them rather than inside either one. */} + {important.threads.length > 0 && !important.hasReachedEnd && ( + loadNextPage('important')} + loading={important.loadingPage} + /> + )} + {/* Loading stays lazy (chained on Important exhausting), but once + loaded the section never unmounts: silent live reloads reset + Important to page 1 (hasReachedEnd → false), and gating the + render on it made this whole section vanish on every sync. */} + {(other.threads.length > 0 || otherCategory !== null) && (
Everything else @@ -3322,6 +3522,35 @@ export function EmailView({ initialThreadId, threadIdVersion, initialSearchQuery {other.threads.length}{other.hasReachedEnd ? '' : '+'} thread{other.threads.length === 1 ? '' : 's'}
+ {Object.keys(categoryCounts).length > 0 && ( +
+ {CATEGORY_PILL_ORDER.filter((c) => (categoryCounts[c] ?? 0) > 0).map((cat) => ( + + ))} + {otherCategory && otherCategory !== 'unclassified' && ( + + )} +
+ )} + {other.threads.length === 0 && otherCategory !== null && !other.loadingPage && ( +
Nothing filed as {categoryPillLabel(otherCategory).toLowerCase()}.
+ )} {visibleOther.map((t) => renderRow(t, 'other'))} {!other.hasReachedEnd && ( + frontmatter === null ? body : `---\n${frontmatter}\n---\n\n${body}`; + +describe("emailAdmission", () => { + it("holds files with no frontmatter until the classifier stamps a verdict", () => { + expect(emailAdmission(email(null))).toBe("wait"); + }); + + it("admits knowledge: extract", () => { + expect( + emailAdmission(email("importance: important\ncategory: correspondence\nknowledge: extract\nclassified_at: \"2026-07-11T00:00:00Z\"")), + ).toBe("process"); + }); + + it("skips knowledge: skip", () => { + expect( + emailAdmission(email("importance: other\ncategory: newsletter\nknowledge: skip\nclassified_at: \"2026-07-11T00:00:00Z\"")), + ).toBe("skip"); + }); + + it("importance never decides admission — an unimportant thread can still carry knowledge", () => { + expect( + emailAdmission(email("importance: other\ncategory: newsletter\nknowledge: extract\nclassified_at: \"2026-07-11T00:00:00Z\"")), + ).toBe("process"); + }); + + it("falls back to noise-tag matching for legacy labeling-agent frontmatter", () => { + // `newsletter` is a noise tag in the default taxonomy → skip. + expect( + emailAdmission(email("labels:\n relationship: []\n topics: []\n type: Newsletter\n filter:\n - newsletter\n action: FYI\nprocessed: true")), + ).toBe("skip"); + // No noise tags → process. + expect( + emailAdmission(email("labels:\n relationship:\n - investor\n topics:\n - fundraising\n filter: []\nprocessed: true")), + ).toBe("process"); + }); + + it("matches legacy noise tags anywhere in the labels block, not just under filter:", () => { + // The old labeling agent sometimes mis-filed noise tags (observed: + // `candidate` under `relationship:`). + expect( + emailAdmission(email("labels:\n relationship:\n - candidate\n topics: []\n filter: []\nprocessed: true")), + ).toBe("skip"); + }); + + it("does not mistake a message-body '---' separator for frontmatter", () => { + expect(emailAdmission("# Subject\n\n---\n\nknowledge: skip\n")).toBe("wait"); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index 98d2c52e..d2ce9949 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -93,6 +93,27 @@ function hasNoiseLabels(content: string): boolean { return false; } +/** + * Admission decision for a gmail_sync email file: + * - 'wait' — no frontmatter yet; the inbox classifier hasn't stamped a + * verdict. Hold the file (don't mark processed) and try again + * next tick. + * - 'skip' — stamped `knowledge: skip` (or, for legacy labeling-agent + * frontmatter, carries a noise tag). Mark processed, never extract. + * - 'process' — admitted for knowledge extraction. + * + * Exported for tests. + */ +export function emailAdmission(content: string): 'wait' | 'skip' | 'process' { + if (!content.startsWith('---')) return 'wait'; + const endIdx = content.indexOf('\n---', 3); + const frontmatter = endIdx === -1 ? '' : content.slice(3, endIdx); + const verdict = frontmatter.match(/^knowledge:\s*(extract|skip)\s*$/m); + if (verdict) return verdict[1] === 'skip' ? 'skip' : 'process'; + // Legacy labeling-agent frontmatter (labels: block) — noise tags decide. + return hasNoiseLabels(content) ? 'skip' : 'process'; +} + function ensureSuggestedTopicsFileLocation(): string { if (fs.existsSync(SUGGESTED_TOPICS_PATH)) { @@ -537,18 +558,18 @@ export async function buildGraph(sourceDir: string): Promise { // Get files that need processing (new or changed) let filesToProcess = getFilesToProcess(sourceDir, state); - // For gmail_sync, only process emails that have been labeled AND don't have noise filter tags + // For gmail_sync, only process emails the classifier admitted: hold files + // with no stamped verdict yet, permanently skip `knowledge: skip`. if (sourceDir.endsWith('gmail_sync')) { filesToProcess = filesToProcess.filter(filePath => { try { const content = fs.readFileSync(filePath, 'utf-8'); - if (!content.startsWith('---')) return false; - if (hasNoiseLabels(content)) { + const admission = emailAdmission(content); + if (admission === 'skip') { console.log(`[buildGraph] Skipping noise email: ${path.basename(filePath)}`); markFileAsProcessed(filePath, state); - return false; } - return true; + return admission === 'process'; } catch { return false; } @@ -756,18 +777,18 @@ export async function processAllSources(): Promise { try { let filesToProcess = getFilesToProcess(sourceDir, state); - // For gmail_sync, only process emails that have been labeled AND don't have noise filter tags + // For gmail_sync, only process emails the classifier admitted: hold + // files with no stamped verdict yet, permanently skip `knowledge: skip`. if (source.provider === 'gmail') { filesToProcess = filesToProcess.filter(filePath => { try { const content = fs.readFileSync(filePath, 'utf-8'); - if (!content.startsWith('---')) return false; - if (hasNoiseLabels(content)) { + const admission = emailAdmission(content); + if (admission === 'skip') { console.log(`[GraphBuilder] Skipping noise email: ${path.basename(filePath)}`); markFileAsProcessed(filePath, state); - return false; } - return true; + return admission === 'process'; } catch { return false; } diff --git a/apps/x/packages/core/src/knowledge/classification_stamp.test.ts b/apps/x/packages/core/src/knowledge/classification_stamp.test.ts new file mode 100644 index 00000000..e461a628 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/classification_stamp.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +// WorkDir is read from the env at module load, so it must be set before +// sync_gmail (→ config.ts) is imported — hence dynamic imports (not hoisted). +// This is why these tests don't live in sync_gmail.test.ts, whose static +// import of sync_gmail would lock in the default WorkDir first. +process.env.ROWBOAT_WORKDIR = fs.mkdtempSync(path.join(os.tmpdir(), "x-classification-stamp-test-")); +const { stampClassificationFrontmatter } = await import("./sync_gmail.js"); +const { emailAdmission } = await import("./build_graph.js"); +type GmailThreadSnapshot = import("./sync_gmail.js").GmailThreadSnapshot; + +const SYNC_DIR = path.join(process.env.ROWBOAT_WORKDIR, "gmail_sync"); + +const BODY = "# Pricing discussion\n\n**Thread ID:** t1\n\n---\n\n### From: Sarah \n**Date:** Fri, 10 Jul 2026\n\nHere is the proposal.\n\n---\n"; + +function snapshot(overrides: Partial = {}): GmailThreadSnapshot { + return { + threadId: "t1", + threadUrl: "https://mail.google.com/mail/#inbox/t1", + importance: "important", + category: "correspondence", + knowledge: "extract", + messages: [], + ...overrides, + }; +} + +function writeMd(threadId: string, content: string): string { + fs.mkdirSync(SYNC_DIR, { recursive: true }); + const p = path.join(SYNC_DIR, `${threadId}.md`); + fs.writeFileSync(p, content); + return p; +} + +describe("stampClassificationFrontmatter", () => { + it("stamps a verdict the graph builder admits, preserving the body", () => { + const p = writeMd("t1", BODY); + stampClassificationFrontmatter("t1", snapshot()); + const stamped = fs.readFileSync(p, "utf-8"); + expect(stamped.startsWith("---\nimportance: important\ncategory: correspondence\nknowledge: extract\n")).toBe(true); + expect(stamped.endsWith(BODY)).toBe(true); + expect(emailAdmission(stamped)).toBe("process"); + }); + + it("a knowledge: skip stamp is what excludes the thread", () => { + const p = writeMd("t1", BODY); + stampClassificationFrontmatter("t1", snapshot({ importance: "other", category: "newsletter", knowledge: "skip" })); + expect(emailAdmission(fs.readFileSync(p, "utf-8"))).toBe("skip"); + }); + + it("does not stamp a verdict that was never made (classify failure)", () => { + const p = writeMd("t1", BODY); + stampClassificationFrontmatter("t1", snapshot({ category: undefined, knowledge: undefined })); + const content = fs.readFileSync(p, "utf-8"); + expect(content).toBe(BODY); + expect(emailAdmission(content)).toBe("wait"); + }); + + it("replaces legacy labeling-agent frontmatter instead of stacking on top", () => { + const legacy = `---\nlabels:\n relationship:\n - investor\n filter: []\nprocessed: true\n---\n\n${BODY}`; + const p = writeMd("t1", legacy); + stampClassificationFrontmatter("t1", snapshot()); + const stamped = fs.readFileSync(p, "utf-8"); + expect(stamped).not.toContain("labels:"); + expect(stamped.endsWith(BODY)).toBe(true); + expect(emailAdmission(stamped)).toBe("process"); + }); + + it("is idempotent — an unchanged verdict does not rewrite the file", () => { + const p = writeMd("t1", BODY); + stampClassificationFrontmatter("t1", snapshot()); + // Pin classified_at to a sentinel; a rewrite would replace it. + const pinned = fs.readFileSync(p, "utf-8").replace(/^classified_at: .*$/m, 'classified_at: "sentinel"'); + fs.writeFileSync(p, pinned); + stampClassificationFrontmatter("t1", snapshot()); + expect(fs.readFileSync(p, "utf-8")).toContain('classified_at: "sentinel"'); + // ...but a changed verdict does restamp. + stampClassificationFrontmatter("t1", snapshot({ importance: "other" })); + const restamped = fs.readFileSync(p, "utf-8"); + expect(restamped).toContain("importance: other"); + expect(restamped).not.toContain('classified_at: "sentinel"'); + expect(restamped.endsWith(BODY)).toBe(true); + }); + + it("is a no-op when the thread has no markdown mirror", () => { + expect(() => stampClassificationFrontmatter("missing-thread", snapshot())).not.toThrow(); + }); +}); diff --git a/apps/x/packages/core/src/knowledge/classify_thread.ts b/apps/x/packages/core/src/knowledge/classify_thread.ts index 95728255..6714dc37 100644 --- a/apps/x/packages/core/src/knowledge/classify_thread.ts +++ b/apps/x/packages/core/src/knowledge/classify_thread.ts @@ -14,6 +14,7 @@ import { captureLlmUsage } from '../analytics/usage.js'; 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'; const STYLE_GUIDE_PATH = path.join(WorkDir, 'knowledge', 'Agent Notes', 'style', 'email.md'); const CALENDAR_DIR = path.join(WorkDir, 'calendar_sync'); @@ -100,14 +101,35 @@ export async function getUserEmail(auth: OAuth2Client): Promise { return 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. + */ +export type EmailCategory = + | 'correspondence' + | 'meeting' + | 'notification' + | 'newsletter' + | 'promotion' + | 'cold_outreach' + | 'receipt'; + export interface Classification { importance: 'important' | 'other'; + /** Absent when the LLM call failed (fail-open) — callers must not stamp a verdict they don't have. */ + category?: EmailCategory; + /** Whether the knowledge-graph pipeline should extract from this thread. Absent on LLM failure. */ + knowledge?: 'extract' | 'skip'; summary?: string; 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).'), }); @@ -120,6 +142,25 @@ 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. +# 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 + +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). +- skip: nothing durable to learn — spam, promotions, cold outreach, newsletters, notifications, digests, product updates, receipts, social media, mailing lists, automated scheduling reminders, travel and shopping confirmations, and unsolicited job applicants or recruiter outreach. + +Importance and knowledge are independent judgments. A board member's long FYI may need no reply yet be knowledge-rich; a quick "can we move to 3pm?" needs action but adds little durable knowledge. When a message from a real relationship arrives wrapped in a bulk format (an investor update sent as a newsletter), knowledge is still extract. + # Summary (important only) When the thread is important, write a 1-2 sentence summary that captures the gist and any action implied. Omit when "other". @@ -227,14 +268,18 @@ export async function classifyThread( // first-touch outreach, self-test sends) are not inbox-important — // when a recipient replies, the thread updates and is re-classified, // and this shortcut then correctly marks it important. + // + // Either way the user wrote the latest message themselves, so the + // knowledge pipeline always extracts: their own words carry their + // commitments, decisions, and relationships. const needle = (userEmail ?? '').toLowerCase(); const othersParticipated = needle ? snapshot.messages.some((m) => m.from && !m.from.toLowerCase().includes(needle)) : false; if (othersParticipated) { - return { importance: 'important' }; + return { importance: 'important', category: 'correspondence', knowledge: 'extract' }; } - return { importance: 'other' }; + return { importance: 'other', category: 'correspondence', knowledge: 'extract' }; } try { @@ -253,12 +298,16 @@ export async function classifyThread( ? `${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; - // The user's learned importance preferences override the generic - // criteria — appended last so they take precedence. + // The user's learned preferences override the generic criteria — + // appended last so they take precedence. const feedback = formatImportanceFeedbackForPrompt(); if (feedback) { systemPrompt = `${systemPrompt}\n\n${feedback}`; } + const categoryFeedback = formatCategoryFeedbackForPrompt(); + if (categoryFeedback) { + systemPrompt = `${systemPrompt}\n\n${categoryFeedback}`; + } const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'email_classifier' }, () => generateObjectSafe({ model, @@ -276,7 +325,19 @@ export async function classifyThread( usage: result.usage, }); - const out: Classification = { importance: result.object.importance }; + const out: Classification = { + importance: result.object.importance, + category: result.object.category, + knowledge: result.object.knowledge, + }; + // Guardrail, enforced in code rather than the prompt: if the user ever + // wrote in this thread, the knowledge pipeline must see it — their own + // messages carry commitments and decisions regardless of how the LLM + // categorized the thread. + const needle = (userEmail ?? '').toLowerCase(); + if (needle && snapshot.messages.some((m) => (m.from || '').toLowerCase().includes(needle))) { + out.knowledge = 'extract'; + } if (result.object.importance === 'important') { if (result.object.summary) out.summary = result.object.summary; if (!options.skipDraft && result.object.draftResponse) out.draftResponse = result.object.draftResponse; @@ -284,6 +345,9 @@ export async function classifyThread( return out; } catch (err) { console.warn(`[Email classifier] LLM call failed for thread ${snapshot.threadId}:`, err); + // Fail open on importance so real mail is never hidden, but leave + // category/knowledge absent — callers must not stamp a verdict that + // was never made (the sync sweep retries these threads later). return { importance: 'important' }; } } diff --git a/apps/x/packages/core/src/knowledge/email_category_feedback.ts b/apps/x/packages/core/src/knowledge/email_category_feedback.ts new file mode 100644 index 00000000..e07aa3d5 --- /dev/null +++ b/apps/x/packages/core/src/knowledge/email_category_feedback.ts @@ -0,0 +1,91 @@ +import fs from 'fs'; +import path from 'path'; +import { WorkDir } from '../config/config.js'; +import type { EmailCategory } from './classify_thread.js'; + +/** + * User-feedback loop for the email category classifier — the sibling of + * email_importance_feedback.ts, deliberately lighter: corrections are stored + * and injected as few-shot examples on every classification call, but there + * is no distillation pass yet (categories are far less personal than + * importance; the few-shot examples carry most of the signal). + * + * The user's explicit category on a specific thread is always sticky: it is + * stored on the inbox_lists entry (categorySource: 'user') and + * re-classification never overrides it. + */ + +const FEEDBACK_PATH = path.join(WorkDir, 'config', 'email_category_feedback.json'); +const MAX_CORRECTIONS = 200; +const FEW_SHOT_COUNT = 20; + +export interface CategoryCorrection { + threadId: string; + subject: string; + from: string; + /** What the classifier had said before the user changed it. */ + agentCategory: EmailCategory | 'unknown'; + /** What the user says it actually is. */ + userCategory: EmailCategory; + at: string; // ISO +} + +interface CategoryFeedback { + corrections: CategoryCorrection[]; +} + +export function loadCategoryFeedback(): CategoryFeedback { + try { + if (!fs.existsSync(FEEDBACK_PATH)) return { corrections: [] }; + const parsed = JSON.parse(fs.readFileSync(FEEDBACK_PATH, 'utf-8')); + return { corrections: Array.isArray(parsed.corrections) ? parsed.corrections : [] }; + } catch (err) { + console.warn('[CategoryFeedback] Failed to load, starting fresh:', err); + return { corrections: [] }; + } +} + +function saveCategoryFeedback(fb: CategoryFeedback): void { + const dir = path.dirname(FEEDBACK_PATH); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + const tmp = FEEDBACK_PATH + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(fb, null, 2)); + fs.renameSync(tmp, FEEDBACK_PATH); +} + +/** + * Record a user correction. One entry per thread — re-picking keeps only the + * latest choice, and picking the classifier's original verdict drops the + * correction (no disagreement left to learn). + */ +export function recordCategoryCorrection(correction: CategoryCorrection): void { + const fb = loadCategoryFeedback(); + const existing = fb.corrections.find(c => c.threadId === correction.threadId); + // The verdict the agent originally produced is the stable "before". + const agentCategory = existing ? existing.agentCategory : correction.agentCategory; + fb.corrections = fb.corrections.filter(c => c.threadId !== correction.threadId); + if (correction.userCategory !== agentCategory) { + fb.corrections.push({ ...correction, agentCategory }); + if (fb.corrections.length > MAX_CORRECTIONS) { + fb.corrections = fb.corrections.slice(-MAX_CORRECTIONS); + } + } + saveCategoryFeedback(fb); +} + +/** + * Render recent category corrections for injection into the classifier + * prompt. Returns null when there is nothing learned yet. + */ +export function formatCategoryFeedbackForPrompt(): string | null { + const fb = loadCategoryFeedback(); + if (fb.corrections.length === 0) return null; + + const lines: string[] = []; + lines.push(`# This user's category corrections (ground truth — these OVERRIDE the generic category definitions above)`); + lines.push(''); + for (const c of fb.corrections.slice(-FEW_SHOT_COUNT)) { + lines.push(`- From: ${c.from} | Subject: "${c.subject}" → user says ${c.userCategory}${c.agentCategory !== 'unknown' ? ` (classifier had said ${c.agentCategory})` : ''}`); + } + return lines.join('\n'); +} diff --git a/apps/x/packages/core/src/knowledge/label_emails.ts b/apps/x/packages/core/src/knowledge/label_emails.ts deleted file mode 100644 index 548c5b52..00000000 --- a/apps/x/packages/core/src/knowledge/label_emails.ts +++ /dev/null @@ -1,257 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { WorkDir } from '../config/config.js'; -import { runWhenPossible, toolInputPaths } from '../runtime/assembly/headless-app.js'; -import { getKgModel } from '../models/defaults.js'; -import { getErrorDetails } from '../application/lib/errors.js'; -import { serviceLogger } from '../services/service_logger.js'; -import { limitEventItems } from './limit_event_items.js'; -import { - loadLabelingState, - saveLabelingState, - markFileAsLabeled, - type LabelingState, -} from './labeling_state.js'; - -const SYNC_INTERVAL_MS = 15 * 1000; // 15 seconds -const BATCH_SIZE = 15; -const DEFAULT_CONCURRENCY = 3; -const LABELING_AGENT = 'labeling_agent'; -const GMAIL_SYNC_DIR = path.join(WorkDir, 'gmail_sync'); -const MAX_CONTENT_LENGTH = 8000; - -/** - * Find email files that haven't been labeled yet - */ -function getUnlabeledEmails(state: LabelingState): string[] { - if (!fs.existsSync(GMAIL_SYNC_DIR)) { - return []; - } - - const unlabeled: string[] = []; - - function traverse(dir: string) { - const entries = fs.readdirSync(dir); - for (const entry of entries) { - const fullPath = path.join(dir, entry); - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) { - traverse(fullPath); - } else if (stat.isFile() && entry.endsWith('.md')) { - // Skip if already tracked in state - if (state.processedFiles[fullPath]) { - continue; - } - - // Skip if file already has frontmatter - try { - const content = fs.readFileSync(fullPath, 'utf-8'); - if (content.startsWith('---')) { - continue; - } - } catch { - continue; - } - - unlabeled.push(fullPath); - } - } - } - - traverse(GMAIL_SYNC_DIR); - return unlabeled; -} - -/** - * Label a batch of email files using the labeling agent - */ -async function labelEmailBatch( - files: { path: string; content: string }[] -): Promise<{ runId: string; filesEdited: Set }> { - let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`; - message += `**Important:** Use workspace-relative paths with file-editText (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`; - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - const relativePath = path.relative(WorkDir, file.path); - const truncated = file.content.length > MAX_CONTENT_LENGTH - ? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use file-readText for full content ...]' - : file.content; - - message += `## File ${i + 1}: ${relativePath}\n\n`; - message += truncated; - message += `\n\n---\n\n`; - } - - const { turnId, state } = await runWhenPossible({ - agentId: LABELING_AGENT, - message, - ...(await getKgModel()), - throwOnError: true, - }); - - // Edited paths come from the durable turn state instead of streaming - // bus subscriptions. - return { runId: turnId, filesEdited: toolInputPaths(state, ['file-editText']) }; -} - -/** - * Process all unlabeled emails in batches - */ -export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCURRENCY): Promise { - console.log('[EmailLabeling] Checking for unlabeled emails...'); - - const state = loadLabelingState(); - const unlabeled = getUnlabeledEmails(state); - - if (unlabeled.length === 0) { - console.log('[EmailLabeling] No unlabeled emails found'); - return; - } - - console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails (concurrency: ${concurrency})`); - - const run = await serviceLogger.startRun({ - service: 'email_labeling', - message: `Labeling ${unlabeled.length} email${unlabeled.length === 1 ? '' : 's'}`, - trigger: 'timer', - }); - - const relativeFiles = unlabeled.map(f => path.relative(WorkDir, f)); - const limitedFiles = limitEventItems(relativeFiles); - await serviceLogger.log({ - type: 'changes_identified', - service: run.service, - runId: run.runId, - level: 'info', - message: `Found ${unlabeled.length} unlabeled email${unlabeled.length === 1 ? '' : 's'}`, - counts: { emails: unlabeled.length }, - items: limitedFiles.items, - truncated: limitedFiles.truncated, - }); - - // Build all batches upfront - const batches: { batchNumber: number; files: { path: string; content: string }[] }[] = []; - for (let i = 0; i < unlabeled.length; i += BATCH_SIZE) { - const batchPaths = unlabeled.slice(i, i + BATCH_SIZE); - const batchNumber = Math.floor(i / BATCH_SIZE) + 1; - const files: { path: string; content: string }[] = []; - for (const filePath of batchPaths) { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - files.push({ path: filePath, content }); - } catch (error) { - console.error(`[EmailLabeling] Error reading ${filePath}:`, error); - } - } - if (files.length > 0) { - batches.push({ batchNumber, files }); - } - } - - const totalBatches = batches.length; - let totalEdited = 0; - let hadError = false; - let failedBatches = 0; - - // Process batches with concurrency limit - for (let i = 0; i < batches.length; i += concurrency) { - const chunk = batches.slice(i, i + concurrency); - - const promises = chunk.map(async ({ batchNumber, files }) => { - try { - console.log(`[EmailLabeling] Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`); - await serviceLogger.log({ - type: 'progress', - service: run.service, - runId: run.runId, - level: 'info', - message: `Processing batch ${batchNumber}/${totalBatches} (${files.length} files)`, - step: 'batch', - current: batchNumber, - total: totalBatches, - details: { filesInBatch: files.length }, - }); - - const result = await labelEmailBatch(files); - - // Only mark files that were actually edited by the agent - for (const file of files) { - const relativePath = path.relative(WorkDir, file.path); - if (result.filesEdited.has(relativePath)) { - markFileAsLabeled(file.path, state); - } - } - - console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`); - return result.filesEdited.size; - } catch (error) { - hadError = true; - failedBatches++; - const errorDetails = getErrorDetails(error); - console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error); - await serviceLogger.log({ - type: 'error', - service: run.service, - runId: run.runId, - level: 'error', - message: `Email labeling batch ${batchNumber}/${totalBatches} failed`, - error: errorDetails, - context: { batchNumber }, - }); - return 0; - } - }); - - const results = await Promise.all(promises); - totalEdited += results.reduce((sum, n) => sum + n, 0); - - // Save state after each concurrent chunk completes - saveLabelingState(state); - } - - state.lastRunTime = new Date().toISOString(); - saveLabelingState(state); - - await serviceLogger.log({ - type: 'run_complete', - service: run.service, - runId: run.runId, - level: hadError ? 'error' : 'info', - message: hadError - ? `Email labeling finished with errors: ${totalEdited} files labeled` - : `Email labeling complete: ${totalEdited} files labeled`, - durationMs: Date.now() - run.startedAt, - outcome: hadError ? 'error' : 'ok', - summary: { - totalEmails: unlabeled.length, - filesLabeled: totalEdited, - failedBatches, - }, - }); - - console.log(`[EmailLabeling] Done. ${totalEdited} emails labeled.`); -} - -/** - * Main entry point - runs as independent polling service - */ -export async function init() { - console.log('[EmailLabeling] Starting Email Labeling Service...'); - console.log(`[EmailLabeling] Will check for unlabeled emails every ${SYNC_INTERVAL_MS / 1000} seconds`); - - // Initial run - await processUnlabeledEmails(); - - // Periodic polling - while (true) { - await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); - - try { - await processUnlabeledEmails(); - } catch (error) { - console.error('[EmailLabeling] Error in main loop:', error); - } - } -} diff --git a/apps/x/packages/core/src/knowledge/labeling_agent.ts b/apps/x/packages/core/src/knowledge/labeling_agent.ts deleted file mode 100644 index 8b99d5e3..00000000 --- a/apps/x/packages/core/src/knowledge/labeling_agent.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { renderTagSystemForEmails } from './tag_system.js'; - -export function getRaw(): string { - return `--- -tools: - file-readText: - type: builtin - name: file-readText - file-editText: - type: builtin - name: file-editText - file-list: - type: builtin - name: file-list ---- -# Task - -You are an email labeling agent. Given a batch of email files, you will classify each email and prepend YAML frontmatter with structured labels. - -# Email File Format - -Each email is a markdown file with this structure: - -\`\`\` -# {Subject line} - -**Thread ID:** {hex_id} -**Message Count:** {n} - ---- - -### From: {Display Name} <{email@address}> -**Date:** {RFC 2822 date} - -{Plain-text body of the message} - ---- - -### From: {Another Sender} <{email@address}> -**Date:** {RFC 2822 date} - -{Next message in thread} - ---- -\`\`\` - -- The \`# Subject\` heading is always the first line. -- Multi-message threads have multiple \`### From:\` blocks in chronological order, separated by \`---\`. -- Single-message threads have \`Message Count: 1\` and one \`### From:\` block. -- The body is plain text extracted from the email (HTML converted to markdown-ish text). - -Use the **Subject**, **From** addresses, **Message Count**, and **body content** to classify the email. - -${renderTagSystemForEmails()} - -# Instructions - -1. For each email file provided in the message, read its content carefully. -2. Classify the email using the taxonomy above. Think like a **YC startup founder** triaging their inbox — your time is your scarcest resource: - - **Relationship**: Who is this from? An investor, customer, team member, vendor, etc.? (\`candidate\` is a NOISE tag, not a relationship — see Filter below.) - - **Topic**: What is this about? Legal, finance, hiring, fundraising, security, infrastructure, etc.? - - **Email Type**: Is this a warm intro or a followup on an existing conversation? - - **Filter (Noise)**: Is this email noise? **Apply ALL applicable filter tags.** If even one noise tag is present the email is skipped — noise overrides everything. Common noise: - - Cold outreach / unsolicited service pitches / "YC exclusive" deals / freelancers offering free work - - Job applications, role inquiries, and recruiter mail → \`filter: ['candidate']\` (candidate is a noise tag and MUST go under filter, never under relationship) - - Newsletters, industry reports, webinar invitations, product tips from vendors - - Promotions, marketing, event invitations you did not register for, startup program upsells - - Automated notifications (email verifications, recording uploads, platform policy changes, expired OTPs) - - Transactional confirmations (salary disbursements, tax payments, GST filings, TDS workings, invoice-sharing threads) - - Spam and spam moderation digests - - **Action**: Does this need a response (\`action-required\`), is it time-sensitive (\`urgent\`), or are you waiting on them (\`waiting\`)? Use \`""\` if none apply. **Do NOT use \`fyi\` as an action value** — it is not a valid action tag. -3. **Apply noise tags aggressively.** Noise tags can and should coexist with relationship and topic tags. A salary confirmation from your finance team should have BOTH \`relationship: ['team']\` AND \`filter: ['receipt']\`. The noise tag determines whether a note is created — it overrides relationship and topic signals. -4. Be accurate — only apply labels that clearly fit. But when an email IS noise, always add the noise tag even when other tags are present. -5. Use \`file-editText\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line. -6. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp. -7. If the email already has frontmatter (starts with \`---\`), skip it. - -# The Founder Signal Test - -Before finalizing labels, ask: **"Would a busy YC founder want a note about this in their knowledge system?"** - -**YES — create a note** if the email: -- Requires a decision or response from the founder -- Updates an active business relationship (customer deal, investor conversation, partner integration) -- Contains information that will be referenced later (pricing, terms, deadlines, compliance requirements) -- Has action items for the team (e.g. standup notes, meeting notes with to-dos) -- Presents a genuine opportunity worth evaluating (accelerator, partnership, relevant hire) -- Flags a risk that needs attention (security vulnerability, legal issue, compliance blocker) -- Is from a vendor you are actively engaged with on an ongoing process (e.g. your compliance assessor following up after a call you participated in) - -**NO — skip it** if the email: -- Confirms a transaction that already happened with no open decision (payment received, tax filed, salary disbursed, invoice shared) -- Is a system-generated notification with no decision needed (email verification, recording uploaded, policy update, expired OTP) -- Is unsolicited outreach from someone you have never engaged with — regardless of how personalized it sounds -- Is a newsletter, industry report, webinar invitation, or product tips email -- Is marketing or promotional content, including from vendors you use -- Is a spam digest or Google Groups moderation report -- Is routine operational correspondence where the transaction is complete and no follow-up remains - -# Cold Outreach Detection (Critical for Precision) - -Many emails disguise themselves as real relationships. Before assigning \`vendor\`, \`candidate\`, \`partner\`, or \`followup\`, apply these tests: - -**It's \`cold-outreach\` (noise), NOT a real relationship, if:** -- The sender is pitching their own product or service — design agencies, compliance firms, content/copy writers, dev shops, freelancers, trademark services, company closure/winding-down services, hiring platforms, etc. — even if they reference your company by name, your YC batch, or offer something "free" or "exclusive for YC founders." -- The thread consists entirely of the same sender following up on their own unanswered messages. A real followup requires prior two-way engagement. -- A student, job-seeker, freelancer, or founder cold-emails asking for your time, feedback, or offering free work/trials. These are NOT \`candidate\` — they are \`cold-outreach\`. -- Someone invites you to an event you didn't sign up for, especially if the email has marketing formatting (tracking links, unsubscribe footers, HTML banners). This is \`promotion\`, not \`event\`. - -**It IS a real relationship (not noise) if:** -- You (the inbox owner) are a participant in the thread (you sent a reply, or someone on your team did). -- The sender is from a company you are already paying, or they are providing a service under contract (e.g., your law firm, your accountant, your cloud provider support). -- The sender was introduced to you by someone you know (warm intro present in the thread). -- The sender references a specific ongoing engagement with concrete details — e.g., they are your assigned compliance assessor for an audit you initiated, or they are following up after a call you participated in. This is NOT the same as a generic "I noticed your company uses X" pitch. - -**Key heuristic:** If every message in the thread is FROM the same external person and the inbox owner never replied, it's almost certainly cold outreach — regardless of how personalized it sounds. Label it \`cold-outreach\`. - -# Routine Operations & Finance (Often Missed as Noise) - -These emails involve real relationships (team, vendor) and real topics (finance) but are **noise** because the transaction is complete and no decision remains. They MUST get a filter tag even though they also have relationship/topic tags: - -- **Salary/payroll confirmations**: "Total salary disbursement is INR X, transfer initiated" → \`filter: ['receipt']\` -- **Tax payment acknowledgements**: Income tax challan confirmations, TDS workings sent for processing → \`filter: ['receipt']\` -- **GST/compliance filing confirmations**: GSTR1 ARN generated, GST OTPs (expired or used) → \`filter: ['receipt']\` -- **Recurring invoice sharing**: Monthly cloud/SaaS invoices shared between team and finance dept → \`filter: ['receipt']\` -- **Payment transfer confirmations**: "Transfer initiated", "Payment confirmed" → \`filter: ['receipt']\` - -# Automated Notifications (Often Missed as Noise) - -System-generated messages that require no decision: - -- **Email verifications**: "Confirm your email address on Slack" → \`filter: ['notification']\` -- **Meeting recordings**: "Your meeting recording is ready in Google Drive" → \`filter: ['notification']\` -- **Platform policy updates**: "Billing permissions are changing starting next month" → \`filter: ['notification']\` -- **Expired OTPs**: One-time passwords for completed actions → \`filter: ['notification']\` - -# Meeting vs Scheduling (Critical Distinction) - -- **topic: meeting** (CREATE) — A calendar invite or scheduling email for a real meeting with a **named person** you have a relationship with: an investor, customer, partner, candidate, advisor, team member. Examples: "Invitation: Zoom: Rowboat Labs <> Dalton Caldwell", "YC between Peer Richelsen and Arjun", "Rowboat <> Smash Capital". The key signal is a specific person or company in the subject/body. -- **filter: scheduling** (SKIP) — Automated reminders and scheduling tool notifications with **no named person or meaningful context**: "Reminder: your meeting is about to start", "Our meeting in an hour", generic ChiliPiper/Calendly confirmations. These are system-generated noise. - -**Rule of thumb:** If the email names who you're meeting with, it's \`topic: meeting\`. If it's just a system ping about a time slot, it's \`filter: scheduling\`. - -# Newsletter & Promotion Detection (Often Missed as Noise) - -These are noise even from a vendor you recognize or a platform you use: - -- **Industry reports**: "Report: $1.2T in combined enterprise AI value" → \`filter: ['newsletter']\` -- **Webinar/workshop invitations**: "Register for our knowledge sessions", "5 Slots Left. Pitch Tomorrow." → \`filter: ['promotion']\` -- **Product tips and tutorials**: "Discover more with your free account" → \`filter: ['newsletter']\` -- **Startup program marketing**: "Reminder - Register for AI Architecture sessions" → \`filter: ['promotion']\` - -**Exception:** If a tool your team actively uses is expiring and you need to make an upgrade/cancellation decision, that is NOT noise — it requires action. - -# Spam Digests Are Always Spam - -If the sender is \`noreply-spamdigest\` (Google Groups spam moderation reports), label it \`filter: ['spam']\`. Google already flagged these as spam. Do not evaluate the held messages inside — the digest itself is noise. - -# Filter array must only contain tags from the Noise category - -Do not put topic or relationship tags into the filter array. If an email is an event promotion, use \`promotion\` in filter — not \`event\`. - -# Frontmatter Format - -\`\`\`yaml ---- -labels: - relationship: - - investor - topics: - - fundraising - - finance - type: intro - filter: - - [] - action: action-required -processed: true -labeled_at: "2026-02-28T12:00:00Z" ---- -\`\`\` - -# Rules - -- Every label category must be present in the frontmatter, even if empty (use \`[]\` for empty arrays). -- \`type\` and \`action\` are single values (strings), not arrays. Use empty string \`""\` if not applicable. -- \`relationship\`, \`topics\`, and \`filter\` are arrays. -- The \`action\` field only accepts: \`action-required\`, \`urgent\`, \`waiting\`, or \`""\`. Never use \`fyi\` as an action value. -- Use the exact label values from the taxonomy — do not invent new ones. -- The \`labeled_at\` timestamp should be the current time in ISO 8601 format. -- Process all files in the batch. Do not skip any unless they already have frontmatter. -- **Noise labels are skip signals.** If an email is clearly a newsletter, cold outreach, promotion, digest, receipt, notification, or other noise — label it in the \`filter\` array. These emails will NOT create notes. -- **Noise tags coexist with other tags.** An email from your team about salary (\`relationship: ['team']\`, \`topics: ['finance']\`) that is just a payroll confirmation should ALSO have \`filter: ['receipt']\`. The noise tag overrides — it ensures the email is skipped even when relationship/topic tags are present. -- **When in doubt, ask:** "Does this email change any decision, require any follow-up, or update a relationship I need to track?" If no, it's noise — add the appropriate filter tag. -`; -} diff --git a/apps/x/packages/core/src/knowledge/labeling_state.ts b/apps/x/packages/core/src/knowledge/labeling_state.ts deleted file mode 100644 index ced922af..00000000 --- a/apps/x/packages/core/src/knowledge/labeling_state.ts +++ /dev/null @@ -1,48 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { WorkDir } from '../config/config.js'; - -const STATE_FILE = path.join(WorkDir, 'labeling_state.json'); - -export interface LabelingState { - processedFiles: Record; - lastRunTime: string; -} - -export function loadLabelingState(): LabelingState { - if (fs.existsSync(STATE_FILE)) { - try { - return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); - } catch (error) { - console.error('Error loading labeling state:', error); - } - } - - return { - processedFiles: {}, - lastRunTime: new Date(0).toISOString(), - }; -} - -export function saveLabelingState(state: LabelingState): void { - try { - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); - } catch (error) { - console.error('Error saving labeling state:', error); - throw error; - } -} - -export function markFileAsLabeled(filePath: string, state: LabelingState): void { - state.processedFiles[filePath] = { - labeledAt: new Date().toISOString(), - }; -} - -export function resetLabelingState(): void { - const emptyState: LabelingState = { - processedFiles: {}, - lastRunTime: new Date().toISOString(), - }; - saveLabelingState(emptyState); -} diff --git a/apps/x/packages/core/src/knowledge/note_creation.ts b/apps/x/packages/core/src/knowledge/note_creation.ts index 198fb9ee..78bc1cfa 100644 --- a/apps/x/packages/core/src/knowledge/note_creation.ts +++ b/apps/x/packages/core/src/knowledge/note_creation.ts @@ -1,5 +1,4 @@ import { renderNoteTypesBlock } from './note_system.js'; -import { renderNoteEffectRules } from './tag_system.js'; export function getRaw(): string { return `--- @@ -193,15 +192,13 @@ Either: --- -# The Core Rule: Label-Based Filtering +# The Core Rule: Pre-Filtered Emails -**Emails now have YAML frontmatter with labels.** Use these labels to decide whether to process or skip. +**Emails reaching you have already passed a noise filter.** The inbox classifier stamps every email's YAML frontmatter with a \`knowledge: extract | skip\` verdict, and \`skip\` emails (marketing, newsletters, notifications, receipts, cold outreach) never reach this agent. Do not re-litigate that decision — an email in front of you is presumed worth reading. -**Meetings and voice memos always create notes** — no label check needed. +**Meetings and voice memos always create notes** — no check needed. -**For emails, read the YAML frontmatter labels and apply these rules:** - -${renderNoteEffectRules()} +**For emails, you still apply judgment about WHAT to extract:** an admitted email can still contain nothing durable (a bare "thanks!", a scheduling ping with no new facts). In that case output SKIP with a reason rather than inventing a note. --- @@ -284,7 +281,7 @@ Emails containing calendar invites (\`.ics\` attachments or inline calendar data --- -# Step 1: Source Filtering (Label-Based) +# Step 1: Source Filtering ## For Meetings and Voice Memos Always process — no filtering needed. @@ -307,33 +304,31 @@ Skip routine metadata churn and duplicated notifications unless they change dura ## For Emails — Read YAML Frontmatter -Emails have YAML frontmatter with labels prepended by the labeling agent: +Emails carry YAML frontmatter stamped by the inbox classifier: \`\`\`yaml --- -labels: - relationship: - - Investor - topics: - - Fundraising - type: Intro - filter: [] - action: FYI -processed: true -labeled_at: "2026-02-28T12:00:00Z" +importance: important +category: correspondence +knowledge: extract +classified_at: "2026-07-11T12:00:00Z" --- \`\`\` +- \`category\` (correspondence, meeting, notification, newsletter, promotion, cold_outreach, receipt) tells you what kind of email this is — use it as context, e.g. \`meeting\` emails usually yield a person/meeting note. +- \`knowledge: extract\` is why the file reached you; \`skip\` emails are filtered out upstream. +- Older files may instead carry a legacy \`labels:\` block from the retired labeling agent — treat those the same way: the file reached you, so it passed filtering. + ## Decision Rules -Apply the label rules from "The Core Rule: Label-Based Filtering" above. +Apply "The Core Rule: Pre-Filtered Emails" above: process the email, but SKIP if it genuinely contains nothing durable. ## Filter Decision Output If skipping: \`\`\` SKIP -Reason: Labels indicate skip-only categories: {list the labels} +Reason: {why nothing durable — e.g. bare acknowledgement, no new facts} \`\`\` If processing, continue to Step 2. @@ -1303,15 +1298,15 @@ ${renderNoteTypesBlock()} --- -# Summary: Label-Based Rules +# Summary: Filtering Rules | Source Type | Creates Notes? | Updates Notes? | Detects State Changes? | |-------------|---------------|----------------|------------------------| | Meeting | Yes | Yes | Yes | | Voice memo | Yes | Yes | Yes | -| Email (create label + user replied in thread) | Yes | Yes | Yes | -| Email (create label, purely inbound — no user reply) | Update-only (no new People/Org notes) | Yes | Yes | -| Email (only skip labels) | No (SKIP) | No | No | +| Email (user replied in thread) | Yes | Yes | Yes | +| Email (purely inbound — no user reply) | Update-only (no new People/Org notes) | Yes | Yes | +| Email (nothing durable in it) | No (SKIP) | No | No | **Email Reply Gate:** New canonical People/Organization notes from an email require the user to have replied at least once in the thread (a \`### From:\` matching \`user.email\` or \`@user.domain\`). Purely inbound threads update existing notes only. Calendar invites for a scheduled meeting are exempt. diff --git a/apps/x/packages/core/src/knowledge/run_pipeline.ts b/apps/x/packages/core/src/knowledge/run_pipeline.ts index 3bde77c2..124575ec 100644 --- a/apps/x/packages/core/src/knowledge/run_pipeline.ts +++ b/apps/x/packages/core/src/knowledge/run_pipeline.ts @@ -1,22 +1,23 @@ #!/usr/bin/env node /** - * Standalone pipeline runner for email labeling, graph building, and note tagging. + * Standalone pipeline runner for graph building and note tagging. * * Usage: * npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir - * npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps label,graph,tag - * npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps label * npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps graph,tag + * npx tsx packages/core/src/knowledge/run_pipeline.ts --workdir /path/to/workdir --steps graph * * The workdir should contain a gmail_sync/ folder with email markdown files. + * Email classification frontmatter is stamped by the Gmail sync (see + * sync_gmail.ts / classify_thread.ts) — files without it are held by the + * graph step until the app's sync stamps them. * Output notes are written to workdir/knowledge/. * * Steps: - * label - Classify emails with YAML frontmatter labels * graph - Extract entities and create/update knowledge notes * tag - Add YAML frontmatter tags to knowledge notes * - * If --steps is omitted, all three steps run in order: label → graph → tag + * If --steps is omitted, both steps run in order: graph → tag */ import fs from 'fs'; @@ -24,40 +25,31 @@ import path from 'path'; // --- Parse CLI args before any core imports (WorkDir reads env at import time) --- -const VALID_STEPS = ['label', 'graph', 'tag'] as const; +const VALID_STEPS = ['graph', 'tag'] as const; type Step = typeof VALID_STEPS[number]; -function parseArgs(): { workdir: string; steps: Step[]; concurrency: number } { +function parseArgs(): { workdir: string; steps: Step[] } { const args = process.argv.slice(2); let workdir: string | undefined; let stepsRaw: string | undefined; - let concurrency = 3; for (let i = 0; i < args.length; i++) { if (args[i] === '--workdir' && args[i + 1]) { workdir = args[++i]; } else if (args[i] === '--steps' && args[i + 1]) { stepsRaw = args[++i]; - } else if (args[i] === '--concurrency' && args[i + 1]) { - concurrency = parseInt(args[++i], 10); - if (isNaN(concurrency) || concurrency < 1) { - console.error('Error: --concurrency must be a positive integer'); - process.exit(1); - } } else if (args[i] === '--help' || args[i] === '-h') { console.log(` -Usage: run_pipeline --workdir [--steps label,graph,tag] [--concurrency N] +Usage: run_pipeline --workdir [--steps graph,tag] Options: --workdir Working directory containing gmail_sync/ folder (required) - --steps Comma-separated steps to run: label, graph, tag (default: all) - --concurrency Number of parallel batches for labeling (default: 3) + --steps Comma-separated steps to run: graph, tag (default: all) --help, -h Show this help message Examples: run_pipeline --workdir ./my-emails - run_pipeline --workdir ./my-emails --steps label --concurrency 5 - run_pipeline --workdir ./my-emails --steps label,graph + run_pipeline --workdir ./my-emails --steps graph run_pipeline --workdir ./my-emails --steps graph,tag `); process.exit(0); @@ -91,10 +83,10 @@ Examples: steps = [...VALID_STEPS]; } - return { workdir, steps, concurrency }; + return { workdir, steps }; } -const { workdir, steps, concurrency } = parseArgs(); +const { workdir, steps } = parseArgs(); // Set env BEFORE importing core modules (WorkDir is read at module load time) process.env.ROWBOAT_WORKDIR = workdir; @@ -104,33 +96,25 @@ process.env.ROWBOAT_WORKDIR = workdir; async function main() { console.log(`[Pipeline] Working directory: ${workdir}`); console.log(`[Pipeline] Steps to run: ${steps.join(', ')}`); - console.log(`[Pipeline] Concurrency: ${concurrency}`); console.log(); - // Verify gmail_sync exists if label or graph step is requested + // Verify gmail_sync exists if the graph step is requested const gmailSyncDir = path.join(workdir, 'gmail_sync'); - if ((steps.includes('label') || steps.includes('graph')) && !fs.existsSync(gmailSyncDir)) { + if (steps.includes('graph') && !fs.existsSync(gmailSyncDir)) { console.warn(`[Pipeline] Warning: gmail_sync/ folder not found in ${workdir}`); } const startTime = Date.now(); - if (steps.includes('label')) { - console.log('[Pipeline] === Step 1: Email Labeling ==='); - const { processUnlabeledEmails } = await import('./label_emails.js'); - await processUnlabeledEmails(concurrency); - console.log(); - } - if (steps.includes('graph')) { - console.log('[Pipeline] === Step 2: Graph Building ==='); + console.log('[Pipeline] === Step 1: Graph Building ==='); const { processAllSources } = await import('./build_graph.js'); await processAllSources(); console.log(); } if (steps.includes('tag')) { - console.log('[Pipeline] === Step 3: Note Tagging ==='); + console.log('[Pipeline] === Step 2: Note Tagging ==='); const { processUntaggedNotes } = await import('./tag_notes.js'); await processUntaggedNotes(); console.log(); diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index 876827bd..e4dd956b 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -9,8 +9,9 @@ import { GoogleClientFactory } from './google-client-factory.js'; import { serviceLogger, type ServiceRunContext } from '../services/service_logger.js'; import { limitEventItems } from './limit_event_items.js'; import { createEvent } from '../events/producer.js'; -import { classifyThread, getUserEmail } from './classify_thread.js'; +import { classifyThread, getUserEmail, type EmailCategory } from './classify_thread.js'; import { recordImportanceCorrection } from './email_importance_feedback.js'; +import { recordCategoryCorrection } from './email_category_feedback.js'; import { notifyIfEnabled } from '../application/notification/notifier.js'; // Configuration @@ -110,9 +111,47 @@ export function setThreadImportance( userVerdict: importance, at: new Date().toISOString(), }); + // Keep the markdown mirror's stamped importance truthful. The knowledge + // verdict is deliberately untouched — "stop showing me this" must never + // silently starve the knowledge graph. + stampClassificationFrontmatter(threadId, cached.snapshot); return { success: true, previous }; } +/** + * User explicitly picks a thread's category in the UI. Sticky on the thread + * (categorySource: 'user'; re-classification never overrides it) and recorded + * as a correction the classifier learns from as few-shot examples. Like + * importance flips, this never touches the knowledge verdict. + */ +export function setThreadCategory( + threadId: string, + category: EmailCategory, +): { success: boolean; error?: string } { + const cached = readCachedSnapshot(threadId); + if (!cached) { + return { success: false, error: `No inbox entry found for thread ${threadId}` }; + } + const previous = cached.snapshot.category; + cached.snapshot.category = category; + cached.snapshot.categorySource = 'user'; + try { + fs.writeFileSync(cachePath(threadId), JSON.stringify(cached), 'utf-8'); + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) }; + } + recordCategoryCorrection({ + threadId, + subject: cached.snapshot.subject || '(no subject)', + from: cached.snapshot.from || 'unknown', + agentCategory: previous ?? 'unknown', + userCategory: category, + at: new Date().toISOString(), + }); + stampClassificationFrontmatter(threadId, cached.snapshot); + return { success: true }; +} + export function saveMessageBodyHeight(threadId: string, messageId: string, height: number): void { const cached = readCachedSnapshot(threadId); if (!cached) return; @@ -245,6 +284,12 @@ export interface GmailThreadSnapshot { importance?: 'important' | 'other'; /** 'user' when the user explicitly set importance in the UI — sticky; re-classification never overrides it. */ importanceSource?: 'user'; + /** What kind of email this is (chip in the inbox UI, context for the knowledge pipeline). */ + category?: EmailCategory; + /** 'user' when the user explicitly picked the category in the UI — sticky; re-classification never overrides it. */ + categorySource?: 'user'; + /** Knowledge-graph admission verdict, stamped into the gmail_sync markdown frontmatter. */ + knowledge?: 'extract' | 'skip'; draft_response?: string; gmail_draft?: string; /** Gmail-side draft id, present on entries from listDraftThreads. */ @@ -659,11 +704,19 @@ export interface InboxPageOptions { section: InboxSection; cursor?: string; limit?: number; + /** Restrict the page to threads of this category (used by the "Everything else" filter pills). */ + category?: string; } export interface InboxPageResult { threads: GmailThreadSnapshot[]; nextCursor: string | null; + /** + * Threads per category across the WHOLE section (ignoring `category` and + * pagination) — drives the filter pills. Threads with no verdict yet count + * under 'unclassified'. + */ + categoryCounts: Record; } interface IndexedEntry { @@ -702,7 +755,7 @@ export function listImportantThreads(opts: { cursor?: string; limit?: number } = return listInboxPage({ section: 'important', ...opts }); } -export function listEverythingElseThreads(opts: { cursor?: string; limit?: number } = {}): InboxPageResult { +export function listEverythingElseThreads(opts: { cursor?: string; limit?: number; category?: string } = {}): InboxPageResult { return listInboxPage({ section: 'other', ...opts }); } @@ -724,17 +777,18 @@ const listCache = new Map(); export function listInboxPage(opts: InboxPageOptions): InboxPageResult { const limit = Math.max(1, Math.min(100, opts.limit ?? 25)); const cursor = parseCursor(opts.cursor); + const categoryCounts: Record = {}; if (!fs.existsSync(CACHE_DIR)) { listCache.clear(); - return { threads: [], nextCursor: null }; + return { threads: [], nextCursor: null, categoryCounts }; } let names: string[]; try { names = fs.readdirSync(CACHE_DIR); } catch { - return { threads: [], nextCursor: null }; + return { threads: [], nextCursor: null, categoryCounts }; } const seen = new Set(); @@ -777,6 +831,9 @@ export function listInboxPage(opts: InboxPageOptions): InboxPageResult { } if (cached.section !== opts.section) continue; + const cat = cached.snapshot.category ?? 'unclassified'; + categoryCounts[cat] = (categoryCounts[cat] ?? 0) + 1; + if (opts.category && cat !== opts.category) continue; entries.push({ threadId: cached.snapshot.threadId, dateMs: cached.dateMs, @@ -812,9 +869,48 @@ export function listInboxPage(opts: InboxPageOptions): InboxPageResult { return { threads: slice.map((e) => e.snapshot), nextCursor: hasMore && last ? encodeCursor({ dateMs: last.dateMs, threadId: last.threadId }) : null, + categoryCounts, }; } +/** + * Archive every "Everything else" thread of the given category in one sweep — + * the bulk gesture behind the category filter pills. Walks the local cache + * (never the live mailbox), so it can only touch threads the user can see in + * the section. Threads whose category was user-corrected are matched on the + * corrected value, like everywhere else. + * + * Returns per-thread failures rather than aborting: one 404 (thread deleted + * elsewhere) shouldn't strand the other 80 promotions in the inbox. + */ +export async function archiveCategoryThreads(category: string): Promise<{ archived: number; failed: number; error?: string }> { + if (!fs.existsSync(CACHE_DIR)) return { archived: 0, failed: 0 }; + let names: string[]; + try { + names = fs.readdirSync(CACHE_DIR); + } catch (err) { + return { archived: 0, failed: 0, error: err instanceof Error ? err.message : String(err) }; + } + const targets: string[] = []; + for (const name of names) { + if (!name.endsWith('.json')) continue; + const threadId = decodeURIComponent(name.replace(/\.json$/, '')); + const snapshot = readCachedSnapshot(threadId)?.snapshot; + if (!snapshot) continue; + if (snapshotImportance(snapshot) !== 'other') continue; + if ((snapshot.category ?? 'unclassified') !== category) continue; + targets.push(threadId); + } + let archived = 0; + let failed = 0; + for (const threadId of targets) { + const result = await archiveThread(threadId); + if (result.ok) archived += 1; + else failed += 1; + } + return { archived, failed }; +} + export async function listRecentThreadIds(daysAgo: number = 2): Promise { const auth = await GoogleClientFactory.getClient(); if (!auth) { @@ -850,6 +946,61 @@ export async function listRecentThreadIds(daysAgo: number = 2): Promise 0; const classification = await classifyThread(snapshot, userEmail, { skipDraft }); snapshot.importance = classification.importance; + if (classification.category) snapshot.category = classification.category; + if (classification.knowledge) snapshot.knowledge = classification.knowledge; if (classification.summary) snapshot.summary = classification.summary; if (classification.draftResponse) { const draftResponse = stripGmailQuotedReplyText(classification.draftResponse); @@ -910,10 +1073,15 @@ async function buildAndCacheSnapshot( snapshot.importance = userOverride; snapshot.importanceSource = 'user'; } + if (userCategoryOverride) { + snapshot.category = userCategoryOverride; + snapshot.categorySource = 'user'; + } if (threadData.historyId) { writeCachedSnapshot(threadId, threadData.historyId, snapshot); } + stampClassificationFrontmatter(threadId, snapshot); return snapshot; } @@ -1634,6 +1802,137 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: } } +// Threads the sweep failed on (deleted in Gmail, persistent classify failure). +// In-memory so they retry on the next app launch but don't burn an LLM call +// every 30s tick in the meantime. +const sweepSkip = new Set(); + +/** + * Classify-and-stamp any gmail_sync markdown that has no frontmatter yet. + * Covers three cases the main sync path can miss: + * - a classify call failed mid-sync (fail-open importance, no verdict) + * - files from before the unified classifier that the old labeling agent + * never got to + * - threads whose inbox cache was pruned (archived) before a verdict landed + * + * The knowledge-graph builder holds unstamped files indefinitely, so without + * this sweep those emails would silently never produce knowledge notes. + * LLM work is bounded per tick; cache-backed stamps are free and unbounded. + */ +async function sweepUnclassifiedMarkdown(auth: OAuth2Client, llmBudget: number = 15): Promise { + if (!fs.existsSync(SYNC_DIR)) return; + let names: string[]; + try { + names = fs.readdirSync(SYNC_DIR); + } catch { + return; + } + const gmailClient = google.gmail({ version: 'v1', auth }); + let classified = 0; + let stamped = 0; + for (const name of names) { + if (!name.endsWith('.md')) continue; + const threadId = name.slice(0, -3); + if (sweepSkip.has(threadId)) continue; + let content: string; + try { + content = fs.readFileSync(path.join(SYNC_DIR, name), 'utf-8'); + } catch { + continue; + } + // Anything with frontmatter is either already stamped or carries the + // legacy labeling-agent verdict the graph builder still understands. + if (content.startsWith('---')) continue; + + const cached = readCachedSnapshot(threadId)?.snapshot; + if (cached?.category && cached.knowledge) { + stampClassificationFrontmatter(threadId, cached); + stamped += 1; + continue; + } + + if (classified >= llmBudget) continue; + classified += 1; + try { + const res = await gmailClient.users.threads.get({ userId: 'me', id: threadId }); + if (cached) { + // In the inbox — reuse the full path (classify, cache, stamp). + await buildAndCacheSnapshot(threadId, res.data, gmailClient, auth); + if (!readCachedSnapshot(threadId)?.snapshot.category) sweepSkip.add(threadId); + } else { + // Not in the inbox cache (archived/pruned). Classify and stamp + // only — writing the cache would resurrect the thread in the + // inbox UI. + const snapshot = await parseThreadSnapshot(threadId, res.data, gmailClient); + if (!snapshot) { + sweepSkip.add(threadId); + continue; + } + const userEmail = await getUserEmail(auth); + const classification = await classifyThread(snapshot, userEmail, { skipDraft: true }); + snapshot.importance = classification.importance; + if (classification.category) snapshot.category = classification.category; + if (classification.knowledge) snapshot.knowledge = classification.knowledge; + stampClassificationFrontmatter(threadId, snapshot); + if (!classification.category) sweepSkip.add(threadId); + } + } catch (err) { + console.warn(`[Gmail] classification sweep failed for ${threadId}:`, err); + sweepSkip.add(threadId); + } + } + + // Enrich pre-upgrade inbox caches (importance but no category) so old + // threads get chips and a knowledge verdict. The cached snapshot already + // holds the messages — no Gmail fetch needed. Deliberately does NOT + // re-stamp markdown that carries legacy labels: frontmatter — rewriting + // those files would make the graph builder reprocess emails it already + // extracted (mtime+hash change detection). + if (classified < llmBudget && fs.existsSync(CACHE_DIR)) { + let cacheNames: string[] = []; + try { + cacheNames = fs.readdirSync(CACHE_DIR); + } catch { /* ignore */ } + const userEmail = await getUserEmail(auth); + for (const name of cacheNames) { + if (classified >= llmBudget) break; + if (!name.endsWith('.json')) continue; + const threadId = decodeURIComponent(name.replace(/\.json$/, '')); + if (sweepSkip.has(threadId)) continue; + const entry = readCachedSnapshot(threadId); + if (!entry || !entry.snapshot.importance || entry.snapshot.category) continue; + classified += 1; + try { + const classification = await classifyThread(entry.snapshot, userEmail, { skipDraft: true }); + if (!classification.category || !classification.knowledge) { + sweepSkip.add(threadId); + continue; + } + entry.snapshot.category = classification.category; + entry.snapshot.knowledge = classification.knowledge; + // The user's sticky verdict (and the previously shown one) win — + // this pass is about adding the missing fields, not re-judging. + writeCachedSnapshot(threadId, entry.historyId, entry.snapshot); + const mdPath = path.join(SYNC_DIR, `${threadId}.md`); + let mdContent: string | null = null; + try { + mdContent = fs.readFileSync(mdPath, 'utf-8'); + } catch { /* no markdown mirror */ } + if (mdContent !== null && !mdContent.startsWith('---')) { + stampClassificationFrontmatter(threadId, entry.snapshot); + } + } catch (err) { + console.warn(`[Gmail] cache enrichment failed for ${threadId}:`, err); + sweepSkip.add(threadId); + } + } + } + + if (classified > 0 || stamped > 0) { + console.log(`[Gmail] classification sweep: ${classified} classified, ${stamped} stamped from cache`); + } +} + async function performSync() { const LOOKBACK_DAYS = 7; // Default to 1 week const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); @@ -1685,6 +1984,10 @@ async function performSync() { // remove cache files for threads that were archived/trashed elsewhere. await pruneInboxCache(auth); + // Backfill classification verdicts onto any markdown the main sync + // paths missed — the knowledge graph holds unstamped files forever. + await sweepUnclassifiedMarkdown(auth); + console.log("Sync completed."); } catch (error) { console.error("Error during sync:", error); diff --git a/apps/x/packages/core/src/knowledge/tag_system.ts b/apps/x/packages/core/src/knowledge/tag_system.ts index e525655a..f355cfb2 100644 --- a/apps/x/packages/core/src/knowledge/tag_system.ts +++ b/apps/x/packages/core/src/knowledge/tag_system.ts @@ -194,41 +194,7 @@ function renderTagGroups(tags: TagDefinition[]): string { return `# Tag System Reference\n\n${sections.join('\n\n')}`; } -export function renderNoteEffectRules(): string { - const tags = getTagDefinitions(); - const skipByType = new Map(); - const createByType = new Map(); - - for (const t of tags) { - const effect = t.noteEffect ?? 'none'; - if (effect === 'none') continue; - const label = TYPE_LABELS[t.type] ?? t.type; - const map = effect === 'skip' ? skipByType : createByType; - const list = map.get(label) ?? []; - list.push(t.tag.split('-').map(w => w[0].toUpperCase() + w.slice(1)).join(' ')); - map.set(label, list); - } - - const formatList = (map: Map) => - Array.from(map.entries()).map(([type, tags]) => `- **${type}:** ${tags.join(', ')}`).join('\n'); - - return [ - `**SKIP if the email has ANY of these labels (skip labels override everything):**`, - formatList(skipByType), - ``, - `**CREATE/UPDATE notes if the email has ANY of these labels (and no skip labels present):**`, - formatList(createByType), - ``, - `**Logic:** If even one label falls in the "skip" list, skip the email — skip labels are hard filters that override create labels.`, - ].join('\n'); -} - export function renderTagSystemForNotes(): string { const tags = getTagDefinitions().filter(t => t.applicability !== 'email'); return renderTagGroups(tags); } - -export function renderTagSystemForEmails(): string { - const tags = getTagDefinitions().filter(t => t.applicability !== 'notes'); - return renderTagGroups(tags); -} diff --git a/apps/x/packages/core/src/models/defaults.ts b/apps/x/packages/core/src/models/defaults.ts index d145ccca..183faeac 100644 --- a/apps/x/packages/core/src/models/defaults.ts +++ b/apps/x/packages/core/src/models/defaults.ts @@ -128,8 +128,8 @@ async function getCategoryModel( } /** - * Model used by knowledge-graph agents (note_creation, labeling_agent, etc.) - * when they're the top-level of a run. + * Model used by knowledge-graph agents (note_creation, the email classifier, + * etc.) when they're the top-level of a run. */ export async function getKgModel(): Promise { return getCategoryModel("knowledgeGraphModel", SIGNED_IN_KG_MODEL); diff --git a/apps/x/packages/core/src/runtime/assembly/registry.test.ts b/apps/x/packages/core/src/runtime/assembly/registry.test.ts index d89a66ba..fdca0ed3 100644 --- a/apps/x/packages/core/src/runtime/assembly/registry.test.ts +++ b/apps/x/packages/core/src/runtime/assembly/registry.test.ts @@ -16,7 +16,6 @@ describe("agent registry", () => { "background-task-agent", "note_creation", "note_curation", - "labeling_agent", "note_tagging_agent", "inline_task_agent", "agent_notes_agent", @@ -41,7 +40,6 @@ describe("agent registry", () => { for (const id of [ "note_creation", "note_curation", - "labeling_agent", "note_tagging_agent", "inline_task_agent", "agent_notes_agent", diff --git a/apps/x/packages/core/src/runtime/assembly/registry.ts b/apps/x/packages/core/src/runtime/assembly/registry.ts index a874893a..048501ec 100644 --- a/apps/x/packages/core/src/runtime/assembly/registry.ts +++ b/apps/x/packages/core/src/runtime/assembly/registry.ts @@ -6,7 +6,6 @@ import { buildBackgroundTaskAgent } from "../../background-tasks/agent.js"; import { buildLiveNoteAgent } from "../../knowledge/live-note/agent.js"; import { getRaw as getNoteCreationRaw } from "../../knowledge/note_creation.js"; import { getRaw as getNoteCurationRaw } from "../../knowledge/note_curation.js"; -import { getRaw as getLabelingAgentRaw } from "../../knowledge/labeling_agent.js"; import { getRaw as getNoteTaggingAgentRaw } from "../../knowledge/note_tagging_agent.js"; import { getRaw as getInlineTaskAgentRaw } from "../../knowledge/inline_task_agent.js"; import { getRaw as getAgentNotesAgentRaw } from "../../knowledge/agent_notes_agent.js"; @@ -60,7 +59,6 @@ const builtinAgents: Record = { "background-task-agent": { build: buildBackgroundTaskAgent }, note_creation: promptFileAgent("note_creation", getNoteCreationRaw), note_curation: promptFileAgent("note_curation", getNoteCurationRaw), - labeling_agent: promptFileAgent("labeling_agent", getLabelingAgentRaw), note_tagging_agent: promptFileAgent("note_tagging_agent", getNoteTaggingAgentRaw), inline_task_agent: promptFileAgent("inline_task_agent", getInlineTaskAgentRaw), agent_notes_agent: promptFileAgent("agent_notes_agent", getAgentNotesAgentRaw), diff --git a/apps/x/packages/shared/src/blocks.ts b/apps/x/packages/shared/src/blocks.ts index 7f456bab..6e6c75c2 100644 --- a/apps/x/packages/shared/src/blocks.ts +++ b/apps/x/packages/shared/src/blocks.ts @@ -145,6 +145,10 @@ export const GmailThreadSchema = EmailBlockSchema.extend({ threadUrl: z.string().url(), unread: z.boolean().optional(), importance: z.enum(['important', 'other']).optional(), + // What kind of email this is (correspondence, meeting, notification, + // newsletter, promotion, cold_outreach, receipt). Loose string so the + // classifier's taxonomy can evolve without a lockstep schema change. + category: z.string().optional(), gmail_draft: z.string().optional(), // Gmail-side draft id, present on entries returned by the Drafts list so the // composer can update/delete that exact draft. diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 17872e88..72149834 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -167,16 +167,21 @@ const ipcSchemas = { res: z.object({ threads: z.array(GmailThreadSchema), nextCursor: z.string().nullable(), + categoryCounts: z.record(z.string(), z.number()).optional(), }), }, 'gmail:getEverythingElse': { req: z.object({ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).optional(), + // Restrict to one category (filter pills). Whole-section categoryCounts + // are returned regardless, so the pills stay populated while filtered. + category: z.string().optional(), }), res: z.object({ threads: z.array(GmailThreadSchema), nextCursor: z.string().nullable(), + categoryCounts: z.record(z.string(), z.number()).optional(), }), }, 'gmail:triggerSync': { @@ -294,6 +299,28 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // User explicitly picks a thread's category. Sticky on the thread + // (re-classification never overrides) and recorded as a correction the + // classifier learns from. Never affects the knowledge-graph verdict. + 'gmail:setCategory': { + req: z.object({ + threadId: z.string().min(1), + category: z.enum(['correspondence', 'meeting', 'notification', 'newsletter', 'promotion', 'cold_outreach', 'receipt']), + }), + 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) }), + res: z.object({ + archived: z.number(), + failed: z.number(), + error: z.string().optional(), + }), + }, 'gmail:archiveThread': { req: z.object({ threadId: z.string().min(1) }), res: z.object({ ok: z.boolean(), error: z.string().optional() }), From 8436e0e245df76e16c16422b283f44692fea1916 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:40:17 +0530 Subject: [PATCH 2/2] allow labels and drafts to be steered by the user in the app --- apps/x/apps/main/src/ipc.ts | 22 ++ apps/x/apps/renderer/src/App.css | 14 +- .../renderer/src/components/email-view.tsx | 214 ++++++++++++++---- .../core/src/knowledge/classify_thread.ts | 92 +++++--- .../core/src/knowledge/email_instructions.ts | 69 ++++++ .../core/src/knowledge/email_labels.ts | 183 +++++++++++++++ apps/x/packages/shared/src/ipc.ts | 27 ++- 7 files changed, 543 insertions(+), 78 deletions(-) create mode 100644 apps/x/packages/core/src/knowledge/email_instructions.ts create mode 100644 apps/x/packages/core/src/knowledge/email_labels.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index f43fa3e7..095d6e24 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -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); }, diff --git a/apps/x/apps/renderer/src/App.css b/apps/x/apps/renderer/src/App.css index 94ac02df..6a7b644a 100644 --- a/apps/x/apps/renderer/src/App.css +++ b/apps/x/apps/renderer/src/App.css @@ -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); diff --git a/apps/x/apps/renderer/src/components/email-view.tsx b/apps/x/apps/renderer/src/components/email-view.tsx index 93e3734d..8301ac55 100644 --- a/apps/x/apps/renderer/src/components/email-view.tsx +++ b/apps/x/apps/renderer/src/components/email-view.tsx @@ -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 = { - 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 = { - correspondence: 'Correspondence', - ...CATEGORY_CHIP_LABELS, -} as Record +// 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 + /** The label registry (built-ins + user-defined) for the chip and correction dropdown. */ + labels?: EmailLabelInfo[] }) { const [composeMode, setComposeMode] = useState(null) const [selfEmail, setSelfEmail] = useState('') @@ -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'} - {(Object.keys(ALL_CATEGORY_LABELS) as EmailCategory[]).map((cat) => ( + {labels.map((l) => ( { 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} ))} @@ -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 + /** Label registry — stable reference from EmailView state (this row is memoized). */ + labels: EmailLabelInfo[] onToggle: (thread: GmailThread) => void onMarkRead: (threadId: string, read?: boolean) => Promise onArchive: (threadId: string) => Promise @@ -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} /> )}
) }) +// 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 ( + + + Email agent instructions +

+ 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.” +

+