Email script2 (#462)

* script to run note agents separately

* improvements to tags and tagging

* add concurrency to labeling

* fix labeling precision

* skip spam

* higher precision

* split scheduling into meeting and noise

* remove duplicate default tags

* added last update time

* filter candidates

* only programattic skip and last update time

* remove candidates from notes
This commit is contained in:
arkml 2026-04-01 22:37:41 +05:30 committed by GitHub
parent 5e53afb670
commit 79be8fbf42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 524 additions and 201 deletions

View file

@ -153,6 +153,11 @@ function getSortValue(note: NoteEntry, column: string): string | number {
if (column === 'mtimeMs') return note.mtimeMs
const v = note.fields[column]
if (!v) return ''
if (column === 'last_update' || column === 'first_met') {
const s = Array.isArray(v) ? v[0] ?? '' : v
const ms = Date.parse(s)
return isNaN(ms) ? 0 : ms
}
return Array.isArray(v) ? v[0] ?? '' : v
}
@ -776,6 +781,17 @@ function CellRenderer({
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(note.mtimeMs)}</span>
}
// Date-like frontmatter columns — render like Last Modified
if (column === 'last_update' || column === 'first_met') {
const value = note.fields[column]
if (!value || Array.isArray(value)) return null
const ms = Date.parse(value)
if (!isNaN(ms)) {
return <span className="text-muted-foreground whitespace-nowrap truncate block">{formatDate(ms)}</span>
}
return <span className="text-muted-foreground whitespace-nowrap truncate block">{value}</span>
}
// Frontmatter column
const value = note.fields[column]
if (!value) return null

View file

@ -843,7 +843,7 @@ const NOTE_TAG_TYPE_ORDER = [
]
const EMAIL_TAG_TYPE_ORDER = [
"relationship", "topic", "email-type", "filter", "action", "status",
"relationship", "topic", "email-type", "noise", "action", "status",
]
const TAG_TYPE_LABELS: Record<string, string> = {
@ -851,73 +851,12 @@ const TAG_TYPE_LABELS: Record<string, string> = {
"relationship-sub": "Relationship Sub-Tags",
"topic": "Topic",
"email-type": "Email Type",
"filter": "Filter",
"noise": "Noise",
"action": "Action",
"status": "Status",
"source": "Source",
}
const DEFAULT_TAGS: TagDef[] = [
{ tag: "investor", type: "relationship", applicability: "both", noteEffect: "create", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
{ tag: "customer", type: "relationship", applicability: "both", noteEffect: "create", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
{ tag: "prospect", type: "relationship", applicability: "both", noteEffect: "create", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
{ tag: "partner", type: "relationship", applicability: "both", noteEffect: "create", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
{ tag: "vendor", type: "relationship", applicability: "both", noteEffect: "create", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
{ tag: "product", type: "relationship", applicability: "both", noteEffect: "skip", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
{ tag: "candidate", type: "relationship", applicability: "both", noteEffect: "create", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
{ tag: "team", type: "relationship", applicability: "both", noteEffect: "create", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
{ tag: "advisor", type: "relationship", applicability: "both", noteEffect: "create", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
{ tag: "personal", type: "relationship", applicability: "both", noteEffect: "create", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
{ tag: "press", type: "relationship", applicability: "both", noteEffect: "create", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
{ tag: "community", type: "relationship", applicability: "both", noteEffect: "create", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
{ tag: "government", type: "relationship", applicability: "both", noteEffect: "create", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
{ tag: "primary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
{ tag: "secondary", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
{ tag: "cc", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
{ tag: "former", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
{ tag: "champion", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
{ tag: "blocker", type: "relationship-sub", applicability: "notes", noteEffect: "none", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
{ tag: "sales", type: "topic", applicability: "both", noteEffect: "create", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
{ tag: "support", type: "topic", applicability: "both", noteEffect: "create", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
{ tag: "legal", type: "topic", applicability: "both", noteEffect: "create", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
{ tag: "finance", type: "topic", applicability: "both", noteEffect: "create", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
{ tag: "hiring", type: "topic", applicability: "both", noteEffect: "create", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
{ tag: "fundraising", type: "topic", applicability: "both", noteEffect: "create", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
{ tag: "travel", type: "topic", applicability: "both", noteEffect: "skip", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
{ tag: "event", type: "topic", applicability: "both", noteEffect: "create", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
{ tag: "shopping", type: "topic", applicability: "both", noteEffect: "skip", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
{ tag: "health", type: "topic", applicability: "both", noteEffect: "skip", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
{ tag: "learning", type: "topic", applicability: "both", noteEffect: "skip", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
{ tag: "research", type: "topic", applicability: "both", noteEffect: "create", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
{ tag: "intro", type: "email-type", applicability: "both", noteEffect: "create", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
{ tag: "followup", type: "email-type", applicability: "both", noteEffect: "create", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
{ tag: "scheduling", type: "email-type", applicability: "email", noteEffect: "skip", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
{ tag: "cold-outreach", type: "email-type", applicability: "email", noteEffect: "skip", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
{ tag: "newsletter", type: "email-type", applicability: "email", noteEffect: "skip", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
{ tag: "notification", type: "email-type", applicability: "email", noteEffect: "skip", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
{ tag: "spam", type: "filter", applicability: "email", noteEffect: "skip", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
{ tag: "promotion", type: "filter", applicability: "email", noteEffect: "skip", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
{ tag: "social", type: "filter", applicability: "email", noteEffect: "skip", description: "Social media notifications", example: "John Smith commented on your post." },
{ tag: "forums", type: "filter", applicability: "email", noteEffect: "skip", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
{ tag: "action-required", type: "action", applicability: "both", noteEffect: "create", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
{ tag: "fyi", type: "action", applicability: "email", noteEffect: "skip", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
{ tag: "urgent", type: "action", applicability: "both", noteEffect: "create", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
{ tag: "waiting", type: "action", applicability: "both", noteEffect: "create", description: "Waiting on a response from them" },
{ tag: "unread", type: "status", applicability: "email", noteEffect: "none", description: "Not yet processed" },
{ tag: "to-reply", type: "status", applicability: "email", noteEffect: "none", description: "Need to respond" },
{ tag: "done", type: "status", applicability: "email", noteEffect: "none", description: "Handled, can be archived" },
{ tag: "active", type: "status", applicability: "notes", noteEffect: "none", description: "Currently relevant, recent activity" },
{ tag: "archived", type: "status", applicability: "notes", noteEffect: "none", description: "No longer active, kept for reference" },
{ tag: "stale", type: "status", applicability: "notes", noteEffect: "none", description: "No activity in 60+ days, needs attention or archive" },
{ tag: "email", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from email" },
{ tag: "meeting", type: "source", applicability: "notes", noteEffect: "none", description: "Created or updated from meeting transcript" },
{ tag: "browser", type: "source", applicability: "notes", noteEffect: "none", description: "Content captured from web browsing" },
{ tag: "web-search", type: "source", applicability: "notes", noteEffect: "none", description: "Information from web search" },
{ tag: "manual", type: "source", applicability: "notes", noteEffect: "none", description: "Manually entered by user" },
{ tag: "import", type: "source", applicability: "notes", noteEffect: "none", description: "Imported from another system" },
]
function TagGroupTable({
group,
@ -1048,8 +987,8 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
setTags(parsed)
setOriginalTags(parsed)
} catch {
setTags([...DEFAULT_TAGS])
setOriginalTags([...DEFAULT_TAGS])
setTags([])
setOriginalTags([])
} finally {
setLoading(false)
}
@ -1110,7 +1049,7 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
const isEmailSection = activeSection === "email"
const applicability = isEmailSection ? "email" as const : "notes" as const
// For email-only types, always use "email"; for notes-only types, always use "notes"; otherwise use "both"
const emailOnlyTypes = ["email-type", "filter"]
const emailOnlyTypes = ["email-type", "noise"]
const notesOnlyTypes = ["relationship-sub", "source"]
let finalApplicability: "email" | "notes" | "both" = "both"
if (emailOnlyTypes.includes(type)) finalApplicability = "email"
@ -1148,11 +1087,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
}
}, [tags])
const handleReset = useCallback(() => {
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
setTags([...DEFAULT_TAGS])
}, [])
const toggleGroup = useCallback((type: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
@ -1224,9 +1158,6 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handleReset}>
Reset to defaults
</Button>
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
{saving ? "Saving..." : "Save"}
</Button>

View file

@ -4,7 +4,8 @@ import { homedir } from "os";
import { fileURLToPath } from "url";
// Resolve app root relative to compiled file location (dist/...)
export const WorkDir = path.join(homedir(), ".rowboat");
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage
export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat");
// Get the directory of this file (for locating bundled assets)
const __filename = fileURLToPath(import.meta.url);

View file

@ -15,6 +15,7 @@ import {
import { buildKnowledgeIndex, formatIndexForPrompt } from './knowledge_index.js';
import { limitEventItems } from './limit_event_items.js';
import { commitAll } from './version_history.js';
import { getTagDefinitions } from './tag_system.js';
/**
* Build obsidian-style knowledge graph by running topic extraction
@ -35,6 +36,48 @@ const SOURCE_FOLDERS = [
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
/**
* Check if email frontmatter contains any noise/skip filter tags.
* Returns true if the email should be skipped.
*/
function hasNoiseLabels(content: string): boolean {
if (!content.startsWith('---')) return false;
const endIdx = content.indexOf('---', 3);
if (endIdx === -1) return false;
const frontmatter = content.slice(3, endIdx);
const noiseTags = new Set(
getTagDefinitions()
.filter(t => t.type === 'noise')
.map(t => t.tag)
);
// Match list items under filter: key
const filterMatch = frontmatter.match(/filter:\s*\n((?:\s+-\s+.+\n?)*)/);
if (filterMatch) {
const filterLines = filterMatch[1].match(/^\s+-\s+(.+)$/gm);
if (filterLines) {
for (const line of filterLines) {
const tag = line.replace(/^\s+-\s+/, '').trim().replace(/['"]/g, '');
if (noiseTags.has(tag)) return true;
}
}
}
// Match inline array like filter: ['cold-outreach'] or filter: [cold-outreach]
const inlineMatch = frontmatter.match(/filter:\s*\[([^\]]*)\]/);
if (inlineMatch && inlineMatch[1].trim()) {
const tags = inlineMatch[1].split(',').map(t => t.trim().replace(/['"]/g, ''));
for (const tag of tags) {
if (noiseTags.has(tag)) return true;
}
}
return false;
}
function extractPathFromToolInput(input: string): string | null {
try {
const parsed = JSON.parse(input) as { path?: string };
@ -366,16 +409,23 @@ export async function buildGraph(sourceDir: string): Promise<void> {
// Get files that need processing (new or changed)
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (sourceDir.endsWith('gmail_sync')) {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[buildGraph] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length === 0) {
@ -535,7 +585,7 @@ async function processVoiceMemosForKnowledge(): Promise<boolean> {
/**
* Process all configured source directories
*/
async function processAllSources(): Promise<void> {
export async function processAllSources(): Promise<void> {
console.log('[GraphBuilder] Checking for new content in all sources...');
@ -568,16 +618,23 @@ async function processAllSources(): Promise<void> {
try {
let filesToProcess = getFilesToProcess(sourceDir, state);
// For gmail_sync, only process emails that have been labeled (have YAML frontmatter)
// For gmail_sync, only process emails that have been labeled AND don't have noise filter tags
if (folder === 'gmail_sync') {
filesToProcess = filesToProcess.filter(filePath => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return content.startsWith('---');
if (!content.startsWith('---')) return false;
if (hasNoiseLabels(content)) {
console.log(`[GraphBuilder] Skipping noise email: ${path.basename(filePath)}`);
markFileAsProcessed(filePath, state);
return false;
}
return true;
} catch {
return false;
}
});
saveState(state);
}
if (filesToProcess.length > 0) {

View file

@ -14,6 +14,7 @@ import {
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;
@ -129,7 +130,7 @@ async function labelEmailBatch(
/**
* Process all unlabeled emails in batches
*/
async function processUnlabeledEmails(): Promise<void> {
export async function processUnlabeledEmails(concurrency: number = DEFAULT_CONCURRENCY): Promise<void> {
console.log('[EmailLabeling] Checking for unlabeled emails...');
const state = loadLabelingState();
@ -140,7 +141,7 @@ async function processUnlabeledEmails(): Promise<void> {
return;
}
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails`);
console.log(`[EmailLabeling] Found ${unlabeled.length} unlabeled emails (concurrency: ${concurrency})`);
const run = await serviceLogger.startRun({
service: 'email_labeling',
@ -161,69 +162,81 @@ async function processUnlabeledEmails(): Promise<void> {
truncated: limitedFiles.truncated,
});
const totalBatches = Math.ceil(unlabeled.length / BATCH_SIZE);
let totalEdited = 0;
let hadError = false;
// 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;
try {
// Read file contents for the batch
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);
}
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) {
continue;
}
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);
totalEdited += result.filesEdited.size;
// 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);
}
}
saveLabelingState(state);
console.log(`[EmailLabeling] Batch ${batchNumber}/${totalBatches} complete, ${result.filesEdited.size} files edited`);
} catch (error) {
hadError = true;
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
context: { batchNumber },
});
}
if (files.length > 0) {
batches.push({ batchNumber, files });
}
}
const totalBatches = batches.length;
let totalEdited = 0;
let hadError = false;
// 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;
console.error(`[EmailLabeling] Error processing batch ${batchNumber}:`, error);
await serviceLogger.log({
type: 'error',
service: run.service,
runId: run.runId,
level: 'error',
message: `Error processing batch ${batchNumber}`,
error: error instanceof Error ? error.message : String(error),
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();

View file

@ -18,15 +18,147 @@ tools:
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. Be accurate and conservative only apply labels that clearly fit.
3. Use \`workspace-edit\` 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.
4. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
5. If the email already has frontmatter (starts with \`---\`), skip it.
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, candidate, etc.?
- **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
- 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 \`workspace-edit\` 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
@ -34,14 +166,14 @@ ${renderTagSystemForEmails()}
---
labels:
relationship:
- Investor
- investor
topics:
- Fundraising
- Finance
type: Intro
- fundraising
- finance
type: intro
filter:
- Promotion
action: FYI
- []
action: action-required
processed: true
labeled_at: "2026-02-28T12:00:00Z"
---
@ -50,10 +182,14 @@ 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.
- \`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.
`;
}

View file

@ -868,6 +868,7 @@ If you discovered new name variants during resolution, add them to Aliases field
- Note state changes with \`[Field → value]\` in activity
- Escape quotes properly in shell commands
- Write only one file per response (no multi-file write batches)
- **Always set \`Last update\`** in the Info section to the YYYY-MM-DD date of the source email or meeting. When updating an existing note, update this field to the new source event's date.
---

View file

@ -23,7 +23,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Email:** {email or leave blank}
**Aliases:** {comma-separated: first name, nicknames, email}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: Who they are, why you know them, what you're working on together.}
@ -56,7 +56,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Domain:** {primary email domain}
**Aliases:** {comma-separated: short names, abbreviations}
**First met:** {YYYY-MM-DD}
**Last seen:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this org is, what your relationship is.}
@ -90,7 +90,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Type:** {deal|product|initiative|hiring|other}
**Status:** {active|planning|on hold|completed|cancelled}
**Started:** {YYYY-MM-DD or leave blank}
**Last activity:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Summary
{2-3 sentences: What this project is, goal, current state.}
@ -131,7 +131,7 @@ const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
**Keywords:** {comma-separated}
**Aliases:** {other ways this topic is referenced}
**First mentioned:** {YYYY-MM-DD}
**Last mentioned:** {YYYY-MM-DD}
**Last update:** {YYYY-MM-DD}
## Related
- [[People/{Person}]] {relationship}

View file

@ -47,7 +47,7 @@ role: VP Engineering
organization: Acme Corp
email: sarah@acme.com
first_met: "2024-06-15"
last_seen: "2025-01-20"
last_update: "2025-01-20"
---
\`\`\`
@ -80,7 +80,7 @@ Use these exact keys for each tag category:
Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) section into YAML frontmatter keys:
1. **Convert keys to snake_case**: e.g. \`**First met:**\`\`first_met\`, \`**Last activity:**\`\`last_activity\`, \`**Last seen:**\`\`last_seen\`.
1. **Convert keys to snake_case**: e.g. \`**First met:**\`\`first_met\`, \`**Last update:**\`\`last_update\`.
2. **Strip wiki-link syntax**: \`[[Organizations/Acme Corp]]\`\`Acme Corp\`. Extract just the display name (last path segment).
3. **Skip blank/placeholder values**: If a field says "leave blank", is empty, or contains only template placeholders like \`{role}\`, omit it from the frontmatter.
4. **Quote dates**: Wrap date values in quotes, e.g. \`first_met: "2024-06-15"\`.
@ -93,10 +93,10 @@ Extract all \`**Key:** value\` fields from the \`## Info\` (or \`## About\`) sec
**Per note type, extract these fields:**
- **People**: role, organization, email, aliases, first_met, last_seen
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_seen
- **Projects**: type, status, started, last_activity
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_mentioned
- **People**: role, organization, email, aliases, first_met, last_update
- **Organizations**: type, industry, relationship, domain, aliases, first_met, last_update
- **Projects**: type, status, started, last_update
- **Topics** (from \`## About\`): keywords, aliases, first_mentioned, last_update
- **Meetings**: Extract from the note content and file path:
- \`date\`: meeting date (from the file path \`Meetings/{source}/YYYY/MM/DD/\` or from \`created_at\`/\`Date:\` in content)
- \`source\`: \`granola\` or \`fireflies\` (from the file path)

View file

@ -0,0 +1,164 @@
#!/usr/bin/env node
/**
* Standalone pipeline runner for email labeling, 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
*
* The workdir should contain a gmail_sync/ folder with email markdown files.
* 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
*/
import fs from 'fs';
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;
type Step = typeof VALID_STEPS[number];
function parseArgs(): { workdir: string; steps: Step[]; concurrency: number } {
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 <path> [--steps label,graph,tag] [--concurrency N]
Options:
--workdir <path> Working directory containing gmail_sync/ folder (required)
--steps <list> Comma-separated steps to run: label, graph, tag (default: all)
--concurrency <N> Number of parallel batches for labeling (default: 3)
--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,tag
`);
process.exit(0);
}
}
if (!workdir) {
console.error('Error: --workdir is required');
process.exit(1);
}
// Resolve to absolute path
workdir = path.resolve(workdir);
if (!fs.existsSync(workdir)) {
console.error(`Error: workdir does not exist: ${workdir}`);
process.exit(1);
}
// Parse steps
let steps: Step[];
if (stepsRaw) {
const requested = stepsRaw.split(',').map(s => s.trim().toLowerCase());
const invalid = requested.filter(s => !VALID_STEPS.includes(s as Step));
if (invalid.length > 0) {
console.error(`Error: invalid steps: ${invalid.join(', ')}. Valid steps: ${VALID_STEPS.join(', ')}`);
process.exit(1);
}
steps = requested as Step[];
} else {
steps = [...VALID_STEPS];
}
return { workdir, steps, concurrency };
}
const { workdir, steps, concurrency } = parseArgs();
// Set env BEFORE importing core modules (WorkDir is read at module load time)
process.env.ROWBOAT_WORKDIR = workdir;
// --- Now import core modules ---
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
const gmailSyncDir = path.join(workdir, 'gmail_sync');
if ((steps.includes('label') || 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 ===');
const { processAllSources } = await import('./build_graph.js');
await processAllSources();
console.log();
}
if (steps.includes('tag')) {
console.log('[Pipeline] === Step 3: Note Tagging ===');
const { processUntaggedNotes } = await import('./tag_notes.js');
await processUntaggedNotes();
console.log();
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`[Pipeline] Done in ${elapsed}s`);
// Output summary
const knowledgeDir = path.join(workdir, 'knowledge');
if (fs.existsSync(knowledgeDir)) {
const countFiles = (dir: string): number => {
let count = 0;
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const stat = fs.statSync(full);
if (stat.isDirectory()) count += countFiles(full);
else if (entry.endsWith('.md')) count++;
}
return count;
};
console.log(`[Pipeline] Output: ${countFiles(knowledgeDir)} notes in ${knowledgeDir}`);
}
}
main().then(() => {
process.exit(0);
}).catch((err) => {
console.error('[Pipeline] Fatal error:', err);
process.exit(1);
});

View file

@ -143,7 +143,7 @@ async function tagNoteBatch(
/**
* Process all untagged notes in batches
*/
async function processUntaggedNotes(): Promise<void> {
export async function processUntaggedNotes(): Promise<void> {
console.log('[NoteTagging] Checking for untagged notes...');
const state = loadNoteTaggingState();

View file

@ -9,7 +9,7 @@ export type TagType =
| 'relationship-sub'
| 'topic'
| 'email-type'
| 'filter'
| 'noise'
| 'action'
| 'status'
| 'source';
@ -29,22 +29,21 @@ export interface TagDefinition {
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
// ── Relationship (both) ──────────────────────────────────────────────
// ── Relationship — who is this from/about (all create) ────────────────
{ tag: 'investor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Investors, VCs, or angels', example: 'Following up on our meeting — we\'d like to move forward with the Series A term sheet.' },
{ tag: 'customer', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Paying customers', example: 'We\'re seeing great results with Rowboat. Can we discuss expanding to more teams?' },
{ tag: 'prospect', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Potential customers', example: 'Thanks for the demo yesterday. We\'re interested in starting a pilot.' },
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you work with', example: 'Here are the updated employment agreements you requested.' },
{ tag: 'product', type: 'relationship', applicability: 'both', noteEffect: 'skip', description: 'Products or services you use (automated)', example: 'Your AWS bill for January 2025 is now available.' },
{ tag: 'candidate', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Job applicants', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
{ tag: 'partner', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Business partners, corp dev, or strategic contacts', example: 'Let\'s discuss how we can promote the integration to both our user bases.' },
{ tag: 'vendor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Service providers you already pay or have a contract with (legal, accounting, infra). NOT someone pitching their services to you — that is cold-outreach.', example: 'Here are the updated employment agreements you requested.' },
{ tag: 'candidate', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Job applicants, recruiters, and anyone reaching out about roles — both solicited and unsolicited', example: 'Thanks for reaching out. I\'d love to learn more about the engineering role.' },
{ tag: 'team', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Internal team members and co-founders', example: 'Here\'s the updated roadmap for Q2. Let\'s discuss in our sync.' },
{ tag: 'advisor', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Advisors, mentors, or board members', example: 'I\'ve reviewed the deck. Here are my thoughts on the GTM strategy.' },
{ tag: 'personal', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Family or friends', example: 'Are you coming to Thanksgiving this year? Let me know your travel dates.' },
{ tag: 'press', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Journalists or media', example: 'I\'m writing a piece on AI agents. Would you be available for an interview?' },
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Users, peers, or open source contributors', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
{ tag: 'community', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Peers, YC batchmates, or open source contributors with direct interaction', example: 'Love what you\'re building with Rowboat. Here\'s a bug I found...' },
{ tag: 'government', type: 'relationship', applicability: 'both', noteEffect: 'create', description: 'Government agencies', example: 'Your Delaware franchise tax is due by March 1, 2025.' },
// ── Relationship Sub-Tags (notes only) ───────────────────────────────
// ── Relationship Sub-Tags — role metadata (notes only, all none) ──────
{ tag: 'primary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Main contact or decision maker', example: 'Sarah Chen — VP Engineering, your main point of contact at Acme.' },
{ tag: 'secondary', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Supporting contact, involved but not the lead', example: 'David Kim — Engineer CC\'d on customer emails.' },
{ tag: 'executive-assistant', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'EA or admin handling scheduling and logistics', example: 'Lisa — Sarah\'s EA who schedules all her meetings.' },
@ -54,57 +53,62 @@ const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
{ tag: 'champion', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Internal advocate pushing for you', example: 'Engineer who loves your product and is selling internally.' },
{ tag: 'blocker', type: 'relationship-sub', applicability: 'notes', noteEffect: 'none', description: 'Person opposing or blocking progress', example: 'CFO resistant to spending on new tools.' },
// ── Topic (both) ─────────────────────────────────────────────────────
// ── Topic — what the email is about (all create) ──────────────────────
{ tag: 'sales', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Sales conversations, deals, and revenue', example: 'Here\'s the pricing proposal we discussed. Let me know if you have questions.' },
{ tag: 'support', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Help requests, issues, and customer support', example: 'We\'re seeing an error when trying to export. Can you help?' },
{ tag: 'legal', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Contracts, terms, compliance, and legal matters', example: 'Legal has reviewed the MSA. Attached are our requested changes.' },
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Money, invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
{ tag: 'finance', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Actionable money matters: invoices, payments, banking, and taxes', example: 'Your invoice #1234 for $5,000 is attached. Payment due in 30 days.' },
{ tag: 'hiring', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Recruiting, interviews, and employment', example: 'We\'d like to move forward with a final round interview. Are you available Thursday?' },
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
{ tag: 'travel', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
{ tag: 'shopping', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
{ tag: 'health', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
{ tag: 'learning', type: 'topic', applicability: 'both', noteEffect: 'skip', description: 'Courses, education, and skill-building', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
{ tag: 'fundraising', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Raising money, SAFEs, term sheets, and investor relations', example: 'Thanks for sending the deck. We\'d like to schedule a partner meeting.' },
{ tag: 'security', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Vulnerability disclosures, login alerts, brand impersonation, or compliance requests', example: 'We found a JWT bypass in your auth endpoint. Details attached.' },
{ tag: 'infrastructure', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Deploy failures, build errors, webhook issues, API migrations, and production alerts', example: 'Vercel deploy failed for rowboat-app. Build log attached.' },
{ tag: 'meeting', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Calendar invites and scheduling for real meetings with named people — investors, customers, partners, candidates, team members. The key signal is a specific person you have a relationship with.', example: 'Invitation: Zoom: Rowboat Labs <> Dalton Caldwell @ Sat 7 Mar 2026' },
{ tag: 'event', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Conferences, meetups, and gatherings you are attending or invited to', example: 'You\'re invited to speak at TechCrunch Disrupt. Can you confirm your availability?' },
{ tag: 'research', type: 'topic', applicability: 'both', noteEffect: 'create', description: 'Research requests and information gathering', example: 'Here\'s the market analysis you requested on the AI agent space.' },
// ── Email Type ───────────────────────────────────────────────────────
// ── Email Type — high-signal email formats (all create) ───────────────
{ tag: 'intro', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Warm introduction from someone you know', example: 'I\'d like to introduce you to Sarah Chen, VP Engineering at Acme.' },
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous conversation', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
{ tag: 'scheduling', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Meeting and calendar scheduling', example: 'Are you available for a call next Tuesday at 2pm?' },
{ tag: 'cold-outreach', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you don\'t know', example: 'Hi, I noticed your company is growing fast. I\'d love to show you how we can help with...' },
{ tag: 'newsletter', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, marketing emails, and subscriptions', example: 'This week in AI: The latest developments in agent frameworks...' },
{ tag: 'notification', type: 'email-type', applicability: 'email', noteEffect: 'skip', description: 'Automated alerts, receipts, and system notifications', example: 'Your password was changed successfully. If this wasn\'t you, contact support.' },
{ tag: 'followup', type: 'email-type', applicability: 'both', noteEffect: 'create', description: 'Following up on a previous two-way conversation (both parties have engaged). A cold sender bumping their own unanswered email is NOT a followup — it is cold-outreach.', example: 'Following up on our call last week. Have you had a chance to review the proposal?' },
// ── Filter (email only) ──────────────────────────────────────────────
{ tag: 'spam', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email', example: 'Congratulations! You\'ve won $1,000,000...' },
{ tag: 'promotion', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers and sales pitches', example: '50% off all items this weekend only!' },
{ tag: 'social', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
{ tag: 'forums', type: 'filter', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists and group discussions', example: 'Re: [dev-list] Question about API design' },
// ── Noise — all skip signals in one place ─────────────────────────────
// NOTE: Noise tags override relationship/topic tags. An email can have
// relationship: team AND filter: receipt — the noise tag wins and skips note creation.
{ tag: 'spam', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Junk and unwanted email, including Google Groups spam moderation digests (from noreply-spamdigest)', example: 'Congratulations! You\'ve won $1,000,000...' },
{ tag: 'promotion', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Marketing offers, sales pitches, product launches, event invitations you did not register for, startup program upsells, vendor upgrade campaigns, and webinar/workshop invitations from companies', example: 'Register Now! Experts talk live: AI, Marketplace, Architecture & GTM Sessions Coming Up' },
{ tag: 'cold-outreach', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Unsolicited contact from someone you have no prior engagement with — includes design agencies, compliance firms, content/copy writers, dev shops, freelancers offering free work, trademark services, company closure services, hiring platforms, and anyone pitching a service with "exclusive YC deal" or referencing your YC batch. Even if they mention your company by name or offer something free.', example: 'Ramnique, $2000 worth YC Design deal every month — we work with 230+ YC founders' },
{ tag: 'newsletter', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Newsletters, industry reports, subscription emails, product tips/tutorials from vendors, and research digests — even from platforms you actively use', example: 'Report: $1.2T in combined enterprise AI value — but what\'s actually built to last?' },
{ tag: 'notification', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Automated system messages requiring no decision: email verifications, meeting recording uploads, platform policy/permission changes, billing console updates, password resets, and expired OTPs', example: 'Meeting records: your recording has been uploaded to Google Drive.' },
{ tag: 'digest', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Community digests, forum roundups, and aggregated updates', example: 'YC Bookface Weekly: 12 new posts this week...' },
{ tag: 'product-update', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Product changelogs, feature announcements, and vendor marketing disguised as tips', example: 'Discover more with your Upstash free account — popular use cases inside' },
{ tag: 'receipt', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Completed transaction confirmations with no decision remaining: payment receipts, salary/payroll disbursements, tax payment acknowledgements (challans), GST/VAT filing confirmations (GSTR1 ARNs), TDS workings, recurring invoice-sharing threads, and transfer-initiated confirmations', example: 'Challan payment under section 200 for TAN BLXXXXXX4B has been successfully paid.' },
{ tag: 'social', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Social media notifications', example: 'John Smith commented on your post.' },
{ tag: 'forums', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Mailing lists, group discussions, and Google Groups moderation digests that are not spam digests', example: 'Re: [dev-list] Question about API design' },
{ tag: 'scheduling', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Automated meeting reminders, scheduling tool confirmations, and calendar system notifications with no named person or context. NOT real meeting invites with specific people — those are topic: meeting.', example: 'Reminder: your meeting is about to start. Join with Google Meet.' },
{ tag: 'travel', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Flights, hotels, trips, and travel logistics', example: 'Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123.' },
{ tag: 'shopping', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Purchases, orders, and returns', example: 'Your order #12345 has shipped. Track it here.' },
{ tag: 'health', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Medical, wellness, and health-related matters', example: 'Your appointment with Dr. Smith is confirmed for Monday at 2pm.' },
{ tag: 'learning', type: 'noise', applicability: 'email', noteEffect: 'skip', description: 'Courses, webinars, workshops, knowledge sessions, and education marketing — even from platforms you are enrolled in', example: 'Welcome to the Advanced Python course. Here\'s your access link.' },
// ── Action ───────────────────────────────────────────────────────────
// ── Action — urgency signals (all create) ─────────────────────────────
{ tag: 'action-required', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Needs a response or action from you', example: 'Can you send me the pricing by Friday?' },
{ tag: 'fyi', type: 'action', applicability: 'email', noteEffect: 'skip', description: 'Informational only, no action needed', example: 'Just wanted to let you know the deal closed. Thanks for your help!' },
{ tag: 'urgent', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Time-sensitive, needs immediate attention', example: 'We need your signature on the contract by EOD today or we lose the deal.' },
{ tag: 'waiting', type: 'action', applicability: 'both', noteEffect: 'create', description: 'Waiting on a response from them' },
// ── Status (email) ───────────────────────────────────────────────────
// ── Status — workflow state (all none) ────────────────────────────────
{ tag: 'unread', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Not yet processed' },
{ tag: 'to-reply', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Need to respond' },
{ tag: 'done', type: 'status', applicability: 'email', noteEffect: 'none', description: 'Handled, can be archived' },
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
// ── Source (notes only) ──────────────────────────────────────────────
// ── Source — origin metadata (notes only, all none) ───────────────────
{ tag: 'email', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from email' },
{ tag: 'meeting', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Created or updated from meeting transcript' },
{ tag: 'browser', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Content captured from web browsing' },
{ tag: 'web-search', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Information from web search' },
{ tag: 'manual', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Manually entered by user' },
{ tag: 'import', type: 'source', applicability: 'notes', noteEffect: 'none', description: 'Imported from another system' },
// ── Status (notes) ──────────────────────────────────────────────────
{ tag: 'active', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'Currently relevant, recent activity' },
{ tag: 'archived', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No longer active, kept for reference' },
{ tag: 'stale', type: 'status', applicability: 'notes', noteEffect: 'none', description: 'No activity in 60+ days, needs attention or archive' },
];
// ── Disk-backed config with mtime caching ──────────────────────────────────
@ -146,7 +150,7 @@ export function getTagDefinitions(): TagDefinition[] {
const TYPE_ORDER: TagType[] = [
'relationship', 'relationship-sub', 'topic', 'email-type',
'filter', 'action', 'status', 'source',
'noise', 'action', 'status', 'source',
];
const TYPE_LABELS: Record<TagType, string> = {
@ -154,7 +158,7 @@ const TYPE_LABELS: Record<TagType, string> = {
'relationship-sub': 'Relationship Sub-Tags',
'topic': 'Topic',
'email-type': 'Email Type',
'filter': 'Filter',
'noise': 'Noise',
'action': 'Action',
'status': 'Status',
'source': 'Source',