diff --git a/apps/x/apps/main/forge.config.cjs b/apps/x/apps/main/forge.config.cjs index 01ba724c..060791cb 100644 --- a/apps/x/apps/main/forge.config.cjs +++ b/apps/x/apps/main/forge.config.cjs @@ -158,7 +158,7 @@ module.exports = { // Read version from source package.json (updated by CI from git tag) const sourcePackageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); const packageJson = { - name: '@x/main', + name: 'Rowboat', version: sourcePackageJson.version, main: 'dist-bundle/main.js', }; @@ -227,7 +227,7 @@ module.exports = { // Read version from source package.json (updated by CI from git tag) const sourcePackageJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); const packageJson = { - name: '@x/main', + name: 'Rowboat', version: sourcePackageJson.version, main: 'dist-bundle/main.js', // Note: No "type": "module" since we bundle as CommonJS diff --git a/apps/x/apps/main/package.json b/apps/x/apps/main/package.json index 4cc4a3c6..46f3fe79 100644 --- a/apps/x/apps/main/package.json +++ b/apps/x/apps/main/package.json @@ -1,5 +1,5 @@ { - "name": "@x/main", + "name": "Rowboat", "type": "module", "version": "0.1.0", "main": "dist/main.js", diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 0a3c548e..a557107d 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -210,6 +210,15 @@ function emitRunEvent(event: z.infer): void { } } +export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('oauth:didConnect', event); + } + } +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 006c383d..3e694daa 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -9,6 +9,7 @@ import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; +import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -160,8 +161,13 @@ export async function connectProvider(provider: string): Promise<{ success: bool } else if (provider === 'fireflies-ai') { triggerFirefliesSync(); } + + // Emit success event to renderer + emitOAuthEvent({ provider, success: true }); } catch (error) { console.error('OAuth token exchange failed:', error); + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + emitOAuthEvent({ provider, success: false, error: errorMessage }); throw error; } finally { // Clean up @@ -178,6 +184,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); activeFlows.delete(state); server.close(); + emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' }); } }, 5 * 60 * 1000); // 5 minutes diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index cc8ddc28..145997dd 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -127,6 +127,32 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, [open, providers, refreshAllStatuses]) + // Listen for OAuth completion events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + const { provider, success, error } = event + + setProviderStates(prev => ({ + ...prev, + [provider]: { + isConnected: success, + isLoading: false, + isConnecting: false, + } + })) + + if (success) { + toast(`Successfully connected to ${provider}`, 'success') + // Refresh status to ensure consistency + refreshAllStatuses() + } else { + toast(error || `Failed to connect to ${provider}`, 'error') + } + }) + + return cleanup + }, [refreshAllStatuses]) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -138,18 +164,10 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const result = await window.ipc.invoke('oauth:connect', { provider }) if (result.success) { - toast(`Successfully connected to ${provider}`, 'success') - // Refresh the status after successful connection - const checkResult = await window.ipc.invoke('oauth:is-connected', { provider }) - setProviderStates(prev => ({ - ...prev, - [provider]: { - isConnected: checkResult.isConnected, - isLoading: false, - isConnecting: false, - } - })) + // OAuth flow started - keep isConnecting state, wait for event + // Event listener will handle the actual completion } else { + // Immediate failure (e.g., couldn't start flow) toast(result.error || `Failed to connect to ${provider}`, 'error') setProviderStates(prev => ({ ...prev, diff --git a/apps/x/apps/renderer/src/hooks/useOAuth.ts b/apps/x/apps/renderer/src/hooks/useOAuth.ts index 10d69e6b..b2777c6c 100644 --- a/apps/x/apps/renderer/src/hooks/useOAuth.ts +++ b/apps/x/apps/renderer/src/hooks/useOAuth.ts @@ -9,11 +9,6 @@ export function useOAuth(provider: string) { const [isLoading, setIsLoading] = useState(true); const [isConnecting, setIsConnecting] = useState(false); - // Check connection status on mount and when provider changes - useEffect(() => { - checkConnection(); - }, [provider]); - const checkConnection = useCallback(async () => { try { setIsLoading(true); @@ -27,23 +22,52 @@ export function useOAuth(provider: string) { } }, [provider]); + // Check connection status on mount and when provider changes + useEffect(() => { + checkConnection(); + }, [provider, checkConnection]); + + // Listen for OAuth completion events + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider !== provider) { + return; // Ignore events for other providers + } + + setIsConnected(event.success); + setIsConnecting(false); + setIsLoading(false); + + if (event.success) { + toast(`Successfully connected to ${provider}`, 'success'); + // Refresh connection status to ensure consistency + checkConnection(); + } else { + toast(event.error || `Failed to connect to ${provider}`, 'error'); + } + }); + + return cleanup; + }, [provider, checkConnection]); + const connect = useCallback(async () => { try { setIsConnecting(true); const result = await window.ipc.invoke('oauth:connect', { provider }); if (result.success) { - toast(`Successfully connected to ${provider}`, 'success'); - await checkConnection(); + // OAuth flow started - keep isConnecting state, wait for event + // Event listener will handle the actual completion } else { + // Immediate failure (e.g., couldn't start flow) toast(result.error || `Failed to connect to ${provider}`, 'error'); + setIsConnecting(false); } } catch (error) { console.error('Failed to connect:', error); toast(`Failed to connect to ${provider}`, 'error'); - } finally { setIsConnecting(false); } - }, [provider, checkConnection]); + }, [provider]); const disconnect = useCallback(async () => { try { diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index b7836949..9429d849 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -24,6 +24,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **Email Drafting:** When users ask you to draft emails or respond to emails, load the \`draft-emails\` skill first. It provides structured guidance for processing emails, gathering context from calendar and knowledge base, and creating well-informed draft responses. +**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs. + ## Memory That Compounds Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 6a43dc4e..f71b1991 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -4,6 +4,7 @@ import builtinToolsSkill from "./builtin-tools/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import draftEmailsSkill from "./draft-emails/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js"; +import meetingPrepSkill from "./meeting-prep/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; @@ -33,6 +34,13 @@ const definitions: SkillDefinition[] = [ summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.", content: draftEmailsSkill, }, + { + id: "meeting-prep", + title: "Meeting Prep", + folder: "meeting-prep", + summary: "Prepare for meetings by gathering context about attendees from the knowledge base.", + content: meetingPrepSkill, + }, { id: "workflow-authoring", title: "Workflow Authoring", diff --git a/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts new file mode 100644 index 00000000..3a38e715 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/meeting-prep/skill.ts @@ -0,0 +1,165 @@ +export const skill = String.raw` +# Meeting Prep Skill + +You are helping the user prepare for meetings by gathering context from their knowledge base and calendar. + +## CRITICAL: Always Look Up Context First + +**BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.** + +**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`). +- **WRONG:** \`path: ""\` or \`path: "."\` +- **CORRECT:** \`path: "knowledge/"\` + +When the user asks to prep for a meeting or mentions attendees: + +1. **STOP** - Do not create a generic brief +2. **SEARCH** - Look up each attendee in the knowledge base: + \`\`\` + workspace-grep({ pattern: "Attendee Name", path: "knowledge/" }) + \`\`\` +3. **READ** - Read their notes to understand who they are: + \`\`\` + workspace-readFile("knowledge/People/Attendee Name.md") + workspace-readFile("knowledge/Organizations/Their Company.md") + \`\`\` +4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items +5. **THEN BRIEF** - Only now create the meeting brief, using this context + +**DO NOT** skip this step. **DO NOT** provide generic briefs. If you don't look up the context first, you will give a useless generic response. + +## Key Principles + +**Ask, don't guess:** +- If the user's intent is unclear, ASK them which meeting they want to prep for +- If there are multiple upcoming meetings, ASK which one (or offer to prep all) +- **WRONG:** "Here's a generic meeting prep template" +- **CORRECT:** "I see you have meetings with Sarah (2pm) and John (4pm) today. Which one would you like me to prep?" + +**Be thorough, not generic:** +- Once you know the meeting, gather ALL relevant context from knowledge base +- Include specific history, open items, and context - not generic talking points +- Reference actual past interactions and commitments + +## Processing Flow + +### Step 1: Identify the Meeting + +If the user specifies a meeting: +- Look it up in \`calendar_sync/\` folder +- Parse the event details + +If the user says "prep me for my next meeting" or similar: +- List upcoming events from \`calendar_sync/\` +- Find the next meeting with external attendees +- Confirm with the user if unclear + +### Step 2: Parse Calendar Event + +Read the calendar event to extract: +- Meeting title (summary) +- Start/end time +- Attendees (names and emails) +- Description/agenda if available + +### Step 3: Gather Context from Knowledge Base + +For each attendee, search the knowledge base (path MUST be \`knowledge/\`): + +**Search People notes:** +\`\`\` +workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" }) +workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" }) +\`\`\` + +If a person note exists, read it: +\`\`\` +workspace-readFile("knowledge/People/Attendee Name.md") +\`\`\` + +Extract: +- Their role/title +- Company/organization +- Key facts about them +- Previous interactions +- Open items + +**Search Organization notes:** +\`\`\` +workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" }) +\`\`\` + +**Search Projects:** +\`\`\` +workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" }) +workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" }) +\`\`\` + +### Step 4: Create Meeting Brief + +Create a brief with this format: + +\`\`\`markdown +๐Ÿ“‹ +Meeting Brief: {Attendee Name} +{Time} today ยท {Company} + +About {First Name} +{Role at company}. {Key background - 1-2 sentences}. {What they care about or focus on}. + +Your History +- {Date}: {Brief description of interaction/outcome} +- {Date}: {Brief description} +- {Date}: {Brief description} + +Open Items +- {Action item} (they asked {date}) +- {Action item} + +Suggested Talking Points +- {Concrete suggestion based on history} +- {Reference relevant entities with [[wiki-links]]} +\`\`\` + +**Example:** +\`\`\`markdown +๐Ÿ“‹ +Meeting Brief: Sarah Chen +2:00 PM today ยท Horizon Ventures + +About Sarah +Partner at Horizon Ventures. Led investments in WorkOS and Segment. Very focused on unit economics. + +Your History +- Jan 15: Partner meeting โ€” positive reception +- Jan 12: Sent updated deck with cohort analysis +- Jan 8: First pitch โ€” she loved the 125% NRR + +Open Items +- Send updated financial model (she asked Jan 15) +- Discuss term sheet timeline + +Suggested Talking Points +- Address her question about CAC by channel +- Mention [[TechFlow]] expansion closed ($120K ARR) +\`\`\` + +**Briefing Guidelines:** +- Use \`[[Name]]\` wiki-link syntax for cross-references to people, projects, orgs +- Keep "About" section concise - 2-3 sentences max +- History should be reverse chronological (most recent first) +- Limit to 3-5 most relevant history items +- Open items should be actionable and specific +- Talking points should be concrete, not generic +- If no notes exist for a person, mention that and offer to create one + +## Important Notes + +- Only prep for meetings with external attendees +- Skip internal calendar blocks (DND, Focus Time, Lunch, etc.) +- For meetings with multiple attendees, create sections for each key person +- Prioritize recent interactions (last 30 days) in the history section +- If an attendee has no notes, suggest what you'd want to capture about them +`; + +export default skill; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 2f6e2f49..8e3d2c1f 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -209,6 +209,14 @@ const ipcSchemas = { providers: z.array(z.string()), }), }, + 'oauth:didConnect': { + req: z.object({ + provider: z.string(), + success: z.boolean(), + error: z.string().optional(), + }), + res: z.null(), + }, 'granola:getConfig': { req: z.null(), res: z.object({