diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b65dcc5c..0a3c548e 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -18,6 +18,7 @@ import z from 'zod'; import { RunEvent } from 'packages/shared/dist/runs.js'; import container from '@x/core/dist/di/container.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; +import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; type InvokeChannels = ipc.InvokeChannels; type IPCChannels = ipc.IPCChannels; @@ -316,6 +317,12 @@ export function setupIpcHandlers() { 'granola:setConfig': async (_event, args) => { const repo = container.resolve('granolaConfigRepo'); await repo.setConfig({ enabled: args.enabled }); + + // Trigger sync immediately when enabled + if (args.enabled) { + triggerGranolaSync(); + } + return { success: true }; }, }); diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 353a86e3..2911fda5 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, protocol, net } from "electron"; +import { app, BrowserWindow, protocol, net, shell } from "electron"; import path from "node:path"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -80,6 +80,24 @@ function createWindow() { }, }); + // Open external links in system browser (not sandboxed Electron window) + // This handles window.open() and target="_blank" links + win.webContents.setWindowOpenHandler(({ url }) => { + // Open all URLs in system browser + shell.openExternal(url); + return { action: 'deny' }; // Prevent Electron from opening a new window + }); + + // Handle navigation to external URLs (e.g., clicking a link without target="_blank") + win.webContents.on('will-navigate', (event, url) => { + // Allow internal navigation (app protocol or dev server) + const isInternal = url.startsWith('app://') || url.startsWith('http://localhost:5173'); + if (!isInternal) { + event.preventDefault(); + shell.openExternal(url); + } + }); + // #region agent log const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173'; fetch('http://127.0.0.1:7242/ingest/dd33b297-24f6-4846-82f9-02599308a13a',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'main.ts:65',message:'createWindow called',data:{isPackaged:app.isPackaged,loadURL,preloadPath},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'E'})}).catch(()=>{}); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 9201bac1..006c383d 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,4 +1,4 @@ -import { BrowserWindow } from 'electron'; +import { shell } from 'electron'; import { createAuthServer } from './auth-server.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import type { Configuration } from '@x/core/dist/auth/oauth-client.js'; @@ -6,6 +6,9 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov import container from '@x/core/dist/di/container.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; 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'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; @@ -110,6 +113,17 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Store flow state activeFlows.set(state, { codeVerifier, provider, config }); + // Build authorization URL + const authUrl = oauthClient.buildAuthorizationUrl(config, { + redirectUri: REDIRECT_URI, + scope: scopes.join(' '), + codeChallenge, + state, + }); + + // Declare timeout variable (will be set after server is created) + let cleanupTimeout: NodeJS.Timeout; + // Create callback server const { server } = await createAuthServer(8080, async (code, receivedState) => { // Validate state @@ -138,6 +152,14 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Save tokens console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.saveTokens(provider, tokens); + + // Trigger immediate sync for relevant providers + if (provider === 'google') { + triggerGmailSync(); + triggerCalendarSync(); + } else if (provider === 'fireflies-ai') { + triggerFirefliesSync(); + } } catch (error) { console.error('OAuth token exchange failed:', error); throw error; @@ -145,35 +167,22 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Clean up activeFlows.delete(state); server.close(); + clearTimeout(cleanupTimeout); } }); - // Build authorization URL - const authUrl = oauthClient.buildAuthorizationUrl(config, { - redirectUri: REDIRECT_URI, - scope: scopes.join(' '), - codeChallenge, - state, - }); + // Set timeout to clean up abandoned flows (5 minutes) + // This prevents memory leaks if user never completes the OAuth flow + cleanupTimeout = setTimeout(() => { + if (activeFlows.has(state)) { + console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`); + activeFlows.delete(state); + server.close(); + } + }, 5 * 60 * 1000); // 5 minutes - // Open browser window - const authWindow = new BrowserWindow({ - width: 600, - height: 700, - show: true, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - }, - }); - - authWindow.loadURL(authUrl.toString()); - - // Clean up on window close - authWindow.on('closed', () => { - activeFlows.delete(state); - server.close(); - }); + // Open in system browser (shares cookies/sessions with user's regular browser) + shell.openExternal(authUrl.toString()); // Wait for callback (server will handle it) return { success: true }; diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index ceb65be0..b7836949 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -22,21 +22,66 @@ You're an insightful, encouraging assistant who combines meticulous clarity with ## What Rowboat Is Rowboat is an agentic assistant for everyday work - emails, meetings, projects, and people. Users give you tasks like "draft a follow-up email," "prep me for this meeting," or "summarize where we are with this project." You figure out what context you need, pull from emails and meetings, and get it done. +**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. + ## 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. When a user asks you to prep them for a call with someone, you already know every prior decision, concerns they've raised, and commitments on both sides - because memory has been accumulating across every email and call, not reconstructed on demand. ## The Knowledge Graph -The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`~/.rowboat/knowledge/\`. The folder is organized into four categories: -- **Organizations/** - Notes on companies and teams +The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories: - **People/** - Notes on individuals, tracking relationships, decisions, and commitments +- **Organizations/** - Notes on companies and teams - **Projects/** - Notes on ongoing initiatives and workstreams - **Topics/** - Notes on recurring themes and subject areas Users can interact with the knowledge graph through you, open it directly in Obsidian, or use other AI tools with it. +## How to Access the Knowledge Graph + +**CRITICAL PATH REQUIREMENT:** +- The workspace root is \`~/.rowboat/\` +- The knowledge base is in the \`knowledge/\` subfolder +- When using workspace tools, ALWAYS include \`knowledge/\` in the path +- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or \`path: "~/.rowboat"\` +- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\` + +Use the builtin workspace tools to search and read the knowledge base: + +**Finding notes:** +\`\`\` +# List all people notes +workspace-readdir("knowledge/People") + +# Search for a person by name - MUST include knowledge/ in path +workspace-grep({ pattern: "Sarah Chen", path: "knowledge/" }) + +# Find notes mentioning a company - MUST include knowledge/ in path +workspace-grep({ pattern: "Acme Corp", path: "knowledge/" }) +\`\`\` + +**Reading notes:** +\`\`\` +# Read a specific person's note +workspace-readFile("knowledge/People/Sarah Chen.md") + +# Read an organization note +workspace-readFile("knowledge/Organizations/Acme Corp.md") +\`\`\` + +**When a user mentions someone by name:** +1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\` +2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\` +3. Use the context (role, organization, past interactions, commitments) in your response + +**NEVER use an empty path or root path. ALWAYS set path to \`knowledge/\` or a subfolder like \`knowledge/People/\`.** + ## When to Access the Knowledge Graph + +**CRITICAL: When the user mentions ANY person, organization, project, or topic by name, you MUST look them up in the knowledge base FIRST before responding.** Do not provide generic responses. Do not guess. Look up the context first, then respond with that knowledge. + +- **Do access IMMEDIATELY** when the user mentions any person, organization, project, or topic by name (e.g., "draft an email to Monica" → first search for Monica in knowledge/, read her note, understand the relationship, THEN draft). - **Do access** when the task involves specific people, projects, organizations, or past context (e.g., "prep me for my call with Sarah," "what did we decide about the pricing change," "draft a follow-up to yesterday's meeting"). - **Do access** when the user references something implicitly expecting you to know it (e.g., "send the usual update to the team," "where did we land on that?"). - **Do access first** for anything related to meetings, emails, or calendar - your knowledge graph already has this context extracted and organized. Check memory before looking for MCP tools. diff --git a/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts new file mode 100644 index 00000000..4e4322af --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/draft-emails/skill.ts @@ -0,0 +1,252 @@ +export const skill = String.raw` +# Email Draft Skill + +You are helping the user draft email responses. Use their calendar and knowledge base for context. + +## CRITICAL: Always Look Up Context First + +**BEFORE drafting any email, you MUST look up the person/organization 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 says "draft an email to Monica" or mentions ANY person, organization, project, or topic: + +1. **STOP** - Do not draft anything yet +2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`): + \`\`\` + workspace-grep({ pattern: "Monica", path: "knowledge/" }) + \`\`\` +3. **READ** - Read their note to understand who they are: + \`\`\` + workspace-readFile("knowledge/People/Monica Smith.md") + \`\`\` +4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items +5. **THEN DRAFT** - Only now draft the email, using this context + +**DO NOT** skip this step. **DO NOT** provide generic templates. 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 what the email should be about +- If a person has multiple contexts (e.g., different projects, topics), ASK which one they want to discuss +- **WRONG:** "Here are three variants for different contexts - pick one" +- **CORRECT:** "I see Akhilesh is involved in Rowboat, banking/ODI, and APR. Which topic would you like to discuss in this email?" + +**Be decisive, not generic:** +- Once you know the context, draft ONE email - no multiple versions or options +- Do NOT provide generic templates - every draft should be personalized based on knowledge base context +- Infer the right tone, content, and approach from the context you gather +- Do NOT hedge with "here are a few options" or "you could say X or Y" - either ask for clarification OR make a decision and draft ONE email + +## State Management + +All state is stored in \`pre-built/email-draft/\`: + +- \`state.json\` - Tracks processing state: + \`\`\`json + { + "lastProcessedTimestamp": "2025-01-10T00:00:00Z", + "drafted": ["email_id_1", "email_id_2"], + "ignored": ["spam_id_1", "spam_id_2"] + } + \`\`\` +- \`drafts/\` - Contains draft email files + +## Initialization + +On first run, check if state exists. If not, create it: + +1. Check if \`pre-built/email-draft/state.json\` exists +2. If not, create \`pre-built/email-draft/\` and \`pre-built/email-draft/drafts/\` +3. Initialize \`state.json\` with empty arrays and a timestamp of "1970-01-01T00:00:00Z" + +## Processing Flow + +### Step 1: Load State + +Read \`pre-built/email-draft/state.json\` to get: +- \`lastProcessedTimestamp\` - Only process emails newer than this +- \`drafted\` - List of email IDs already drafted (skip these) +- \`ignored\` - List of email IDs marked as ignored (skip these) + +### Step 2: Scan for New Emails + +List emails in \`gmail_sync/\` folder. + +For each email file: +1. Extract the email ID from filename (e.g., \`19048cf9c0317981.md\` -> \`19048cf9c0317981\`) +2. Skip if ID is in \`drafted\` or \`ignored\` lists +3. Read the email content + +### Step 3: Parse Email + +Each email file contains: +\`\`\`markdown +# Subject Line + +**Thread ID:** +**Message Count:** + +--- + +### From: Name +**Date:** + + +\`\`\` + +Extract: +- Thread ID (this is the email ID) +- From (sender name and email) +- Date +- Subject (from the # heading) +- Body content +- Message count (to understand if it's a thread) + +### Step 4: Classify Email + +Determine the email type and action: + +**IGNORE these (add to \`ignored\` list):** +- Newsletters (unsubscribe links, "View in browser", bulk sender indicators) +- Marketing emails (promotional language, no-reply senders) +- Automated notifications (GitHub, Jira, Slack, shipping updates) +- Spam or cold outreach that's clearly irrelevant +- Emails where you (the user) are the sender and it's outbound with no reply + +**DRAFT response for:** +- Meeting requests or scheduling emails +- Personal emails from known contacts +- Business inquiries that seem legitimate +- Follow-ups on existing conversations +- Emails requesting information or action + +### Step 5: Gather Context + +Before drafting, gather relevant context. **Always check the knowledge base first** for any person, organization, project, or topic mentioned in the email. + +**Knowledge Base Context (REQUIRED):** + +First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`): +\`\`\` +# Search for the sender by name or email +workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" }) + +# List all people to find potential matches +workspace-readdir("knowledge/People") +\`\`\` + +Then read the relevant notes: +\`\`\` +# Read the sender's note +workspace-readFile("knowledge/People/Sender Name.md") + +# Read their organization's note +workspace-readFile("knowledge/Organizations/Company Name.md") +\`\`\` + +Extract from these notes: +- Their role, title, and organization +- History of past interactions and meetings +- Commitments made (by them or to them) +- Open items and pending actions +- Relationship context and rapport + +Use this context to provide informed, personalized responses that demonstrate you remember past interactions. + +**Calendar Context** (for scheduling emails): +- Read calendar events from \`calendar_sync/\` folder +- Look for events in the relevant time period +- Check for conflicts, availability + +### Step 6: Create Draft + +For emails that need a response, create a draft file in \`pre-built/email-draft/drafts/\`: + +**Filename:** \`{email_id}_draft.md\` + +**Content format:** +\`\`\`markdown +# Draft Response + +**Original Email ID:** {email_id} +**Original Subject:** {subject} +**From:** {sender} +**Date Processed:** {current_date} + +--- + +## Context Used + +- Calendar: {relevant calendar info or "N/A"} +- Memory: {relevant notes or "N/A"} + +--- + +## Draft Response + +Subject: Re: {original_subject} + +{draft email body} + +--- + +## Notes + +{any notes about why this response was crafted this way} +\`\`\` + +**Drafting Guidelines:** +- Draft ONE email - do not offer multiple versions or options unless explicitly asked +- Be concise and professional +- For scheduling: propose specific times based on calendar availability +- For inquiries: answer directly or indicate what info is needed +- Reference any relevant context from memory naturally - show you remember past interactions +- Match the tone of the incoming email +- If it's a thread with multiple messages, read the full context +- Do NOT use generic templates or placeholder language - personalize based on knowledge base +- If you're unsure about the user's intent, ask a clarifying question first + +### Step 7: Update State + +After processing each email: +1. Add the email ID to either \`drafted\` or \`ignored\` list +2. Update \`lastProcessedTimestamp\` to the current time +3. Write updated state to \`pre-built/email-draft/state.json\` + +## Output + +After processing all new emails, provide a summary: + +\`\`\` +## Processing Summary + +**Emails Scanned:** X +**Drafts Created:** Y +**Ignored:** Z + +### Drafts Created: +- {email_id}: {subject} - {brief reason} + +### Ignored: +- {email_id}: {subject} - {reason for ignoring} +\`\`\` + +## Error Handling + +- If an email file is malformed, log it and continue +- If calendar/notes folders don't exist, proceed without that context +- Always save state after each email to avoid reprocessing on failure + +## Important Notes + +- Never actually send emails - only create drafts +- The user will review and send drafts manually +- Be conservative with ignore - when in doubt, create a draft +- For ambiguous emails, create a draft with a note explaining the ambiguity +`; + +export default skill; 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 e8c4f808..6a43dc4e 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; 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 workflowAuthoringSkill from "./workflow-authoring/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; @@ -25,6 +26,13 @@ type ResolvedSkill = { }; const definitions: SkillDefinition[] = [ + { + id: "draft-emails", + title: "Draft Emails", + folder: "draft-emails", + summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.", + content: draftEmailsSkill, + }, { id: "workflow-authoring", title: "Workflow Authoring", diff --git a/apps/x/packages/core/src/knowledge/granola/sync.ts b/apps/x/packages/core/src/knowledge/granola/sync.ts index ee873928..6c736085 100644 --- a/apps/x/packages/core/src/knowledge/granola/sync.ts +++ b/apps/x/packages/core/src/knowledge/granola/sync.ts @@ -23,6 +23,30 @@ const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit const MAX_RETRIES = 3; // Maximum retries for rate-limited requests const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync +// --- Wake Signal for Immediate Sync Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerSync(): void { + if (wakeResolve) { + console.log('[Granola] Triggered - waking up immediately'); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + // --- Token Extraction --- interface WorkosTokens { @@ -404,7 +428,7 @@ async function syncNotes(): Promise { export async function init(): Promise { console.log('[Granola] Starting Granola Sync...'); - console.log(`[Granola] Will check every ${SYNC_INTERVAL_MS / 60000} minutes.`); + console.log(`[Granola] Will sync every ${SYNC_INTERVAL_MS / 60000} minutes.`); console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`); while (true) { @@ -414,9 +438,9 @@ export async function init(): Promise { console.error('[Granola] Error in sync loop:', error); } - // Sleep before next check + // Sleep before next check (can be interrupted by triggerSync) console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 60000} minutes...`); - await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + await interruptibleSleep(SYNC_INTERVAL_MS); } } diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 0499b8e7..f2719357 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -17,6 +17,30 @@ const REQUIRED_SCOPES = [ const nhm = new NodeHtmlMarkdown(); +// --- Wake Signal for Immediate Sync Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerSync(): void { + if (wakeResolve) { + console.log('[Calendar] Triggered - waking up immediately'); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + // --- Helper Functions --- function cleanFilename(name: string): string { @@ -211,7 +235,7 @@ async function performSync(syncDir: string, lookbackDays: number) { export async function init() { console.log("Starting Google Calendar & Notes Sync (TS)..."); - console.log(`Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`); + console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { @@ -228,8 +252,8 @@ export async function init() { console.error("Error in main loop:", error); } - // Sleep for N minutes before next check + // Sleep for N minutes before next check (can be interrupted by triggerSync) console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); - await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + await interruptibleSleep(SYNC_INTERVAL_MS); } } \ No newline at end of file diff --git a/apps/x/packages/core/src/knowledge/sync_fireflies.ts b/apps/x/packages/core/src/knowledge/sync_fireflies.ts index bb74eaf2..e65529f6 100644 --- a/apps/x/packages/core/src/knowledge/sync_fireflies.ts +++ b/apps/x/packages/core/src/knowledge/sync_fireflies.ts @@ -12,6 +12,30 @@ const API_DELAY_MS = 2000; // 2 second delay between API calls const RATE_LIMIT_RETRY_DELAY_MS = 60 * 1000; // Wait 1 minute on rate limit const MAX_RETRIES = 3; // Maximum retries for rate-limited requests +// --- Wake Signal for Immediate Sync Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerSync(): void { + if (wakeResolve) { + console.log('[Fireflies] Triggered - waking up immediately'); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + // --- Types for Fireflies API responses --- interface FirefliesMeeting { @@ -553,7 +577,7 @@ async function syncMeetings() { */ export async function init() { console.log('[Fireflies] Starting Fireflies Sync...'); - console.log(`[Fireflies] Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`); + console.log(`[Fireflies] Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); console.log(`[Fireflies] Syncing transcripts from the last ${LOOKBACK_DAYS} days.`); while (true) { @@ -571,9 +595,9 @@ export async function init() { console.error('[Fireflies] Error in main loop:', error); } - // Sleep before next check + // Sleep before next check (can be interrupted by triggerSync) console.log(`[Fireflies] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); - await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + await interruptibleSleep(SYNC_INTERVAL_MS); } } diff --git a/apps/x/packages/core/src/knowledge/sync_gmail.ts b/apps/x/packages/core/src/knowledge/sync_gmail.ts index ecf8a4e8..d1782a96 100644 --- a/apps/x/packages/core/src/knowledge/sync_gmail.ts +++ b/apps/x/packages/core/src/knowledge/sync_gmail.ts @@ -13,6 +13,30 @@ const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly'; const nhm = new NodeHtmlMarkdown(); +// --- Wake Signal for Immediate Sync Trigger --- +let wakeResolve: (() => void) | null = null; + +export function triggerSync(): void { + if (wakeResolve) { + console.log('[Gmail] Triggered - waking up immediately'); + wakeResolve(); + wakeResolve = null; + } +} + +function interruptibleSleep(ms: number): Promise { + return new Promise(resolve => { + const timeout = setTimeout(() => { + wakeResolve = null; + resolve(); + }, ms); + wakeResolve = () => { + clearTimeout(timeout); + resolve(); + }; + }); +} + // --- Helper Functions --- function cleanFilename(name: string): string { @@ -287,7 +311,7 @@ async function performSync() { export async function init() { console.log("Starting Gmail Sync (TS)..."); - console.log(`Will check for credentials every ${SYNC_INTERVAL_MS / 1000} seconds.`); + console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { @@ -304,8 +328,8 @@ export async function init() { console.error("Error in main loop:", error); } - // Sleep for N minutes before next check + // Sleep for N minutes before next check (can be interrupted by triggerSync) console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); - await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); + await interruptibleSleep(SYNC_INTERVAL_MS); } }