Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev

This commit is contained in:
tusharmagar 2026-01-20 14:44:15 +05:30
commit c0efa3329a
10 changed files with 476 additions and 41 deletions

View file

@ -18,6 +18,7 @@ import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js'; import { RunEvent } from 'packages/shared/dist/runs.js';
import container from '@x/core/dist/di/container.js'; import container from '@x/core/dist/di/container.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.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 InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels; type IPCChannels = ipc.IPCChannels;
@ -316,6 +317,12 @@ export function setupIpcHandlers() {
'granola:setConfig': async (_event, args) => { 'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo'); const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled }); await repo.setConfig({ enabled: args.enabled });
// Trigger sync immediately when enabled
if (args.enabled) {
triggerGranolaSync();
}
return { success: true }; return { success: true };
}, },
}); });

View file

@ -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 path from "node:path";
import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js"; import { setupIpcHandlers, startRunsWatcher, startWorkspaceWatcher, stopWorkspaceWatcher } from "./ipc.js";
import { fileURLToPath, pathToFileURL } from "node:url"; 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 // #region agent log
const loadURL = app.isPackaged ? 'app://./' : 'http://localhost:5173'; 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(()=>{}); 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(()=>{});

View file

@ -1,4 +1,4 @@
import { BrowserWindow } from 'electron'; import { shell } from 'electron';
import { createAuthServer } from './auth-server.js'; import { createAuthServer } from './auth-server.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js'; import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } 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 container from '@x/core/dist/di/container.js';
import { IOAuthRepo } from '@x/core/dist/auth/repo.js'; import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-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'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
@ -110,6 +113,17 @@ export async function connectProvider(provider: string): Promise<{ success: bool
// Store flow state // Store flow state
activeFlows.set(state, { codeVerifier, provider, config }); 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 // Create callback server
const { server } = await createAuthServer(8080, async (code, receivedState) => { const { server } = await createAuthServer(8080, async (code, receivedState) => {
// Validate state // Validate state
@ -138,6 +152,14 @@ export async function connectProvider(provider: string): Promise<{ success: bool
// Save tokens // Save tokens
console.log(`[OAuth] Token exchange successful for ${provider}`); console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.saveTokens(provider, tokens); await oauthRepo.saveTokens(provider, tokens);
// Trigger immediate sync for relevant providers
if (provider === 'google') {
triggerGmailSync();
triggerCalendarSync();
} else if (provider === 'fireflies-ai') {
triggerFirefliesSync();
}
} catch (error) { } catch (error) {
console.error('OAuth token exchange failed:', error); console.error('OAuth token exchange failed:', error);
throw error; throw error;
@ -145,35 +167,22 @@ export async function connectProvider(provider: string): Promise<{ success: bool
// Clean up // Clean up
activeFlows.delete(state); activeFlows.delete(state);
server.close(); server.close();
clearTimeout(cleanupTimeout);
} }
}); });
// Build authorization URL // Set timeout to clean up abandoned flows (5 minutes)
const authUrl = oauthClient.buildAuthorizationUrl(config, { // This prevents memory leaks if user never completes the OAuth flow
redirectUri: REDIRECT_URI, cleanupTimeout = setTimeout(() => {
scope: scopes.join(' '), if (activeFlows.has(state)) {
codeChallenge, console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
state, activeFlows.delete(state);
}); server.close();
}
}, 5 * 60 * 1000); // 5 minutes
// Open browser window // Open in system browser (shares cookies/sessions with user's regular browser)
const authWindow = new BrowserWindow({ shell.openExternal(authUrl.toString());
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();
});
// Wait for callback (server will handle it) // Wait for callback (server will handle it)
return { success: true }; return { success: true };

View file

@ -22,21 +22,66 @@ You're an insightful, encouraging assistant who combines meticulous clarity with
## What Rowboat Is ## 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. 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 ## 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. 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. 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
The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`~/.rowboat/knowledge/\`. The folder is organized into four categories: The knowledge graph is stored as plain markdown with Obsidian-style backlinks in \`knowledge/\` (inside the workspace). The folder is organized into four categories:
- **Organizations/** - Notes on companies and teams
- **People/** - Notes on individuals, tracking relationships, decisions, and commitments - **People/** - Notes on individuals, tracking relationships, decisions, and commitments
- **Organizations/** - Notes on companies and teams
- **Projects/** - Notes on ongoing initiatives and workstreams - **Projects/** - Notes on ongoing initiatives and workstreams
- **Topics/** - Notes on recurring themes and subject areas - **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. 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 ## 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 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** 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. - **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.

View file

@ -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:** <id>
**Message Count:** <count>
---
### From: Name <email@example.com>
**Date:** <date string>
<email body>
\`\`\`
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;

View file

@ -2,6 +2,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import builtinToolsSkill from "./builtin-tools/skill.js"; import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js"; import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js"; import mcpIntegrationSkill from "./mcp-integration/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js"; import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js"; import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
@ -25,6 +26,13 @@ type ResolvedSkill = {
}; };
const definitions: SkillDefinition[] = [ 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", id: "workflow-authoring",
title: "Workflow Authoring", title: "Workflow Authoring",

View file

@ -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_RETRIES = 3; // Maximum retries for rate-limited requests
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync 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<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
// --- Token Extraction --- // --- Token Extraction ---
interface WorkosTokens { interface WorkosTokens {
@ -404,7 +428,7 @@ async function syncNotes(): Promise<void> {
export async function init(): Promise<void> { export async function init(): Promise<void> {
console.log('[Granola] Starting Granola Sync...'); 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}`); console.log(`[Granola] Notes will be saved to: ${SYNC_DIR}`);
while (true) { while (true) {
@ -414,9 +438,9 @@ export async function init(): Promise<void> {
console.error('[Granola] Error in sync loop:', error); 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...`); console.log(`[Granola] Sleeping for ${SYNC_INTERVAL_MS / 60000} minutes...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); await interruptibleSleep(SYNC_INTERVAL_MS);
} }
} }

View file

@ -17,6 +17,30 @@ const REQUIRED_SCOPES = [
const nhm = new NodeHtmlMarkdown(); 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<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
// --- Helper Functions --- // --- Helper Functions ---
function cleanFilename(name: string): string { function cleanFilename(name: string): string {
@ -211,7 +235,7 @@ async function performSync(syncDir: string, lookbackDays: number) {
export async function init() { export async function init() {
console.log("Starting Google Calendar & Notes Sync (TS)..."); 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) { while (true) {
try { try {
@ -228,8 +252,8 @@ export async function init() {
console.error("Error in main loop:", error); 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...`); console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); await interruptibleSleep(SYNC_INTERVAL_MS);
} }
} }

View file

@ -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 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_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<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
// --- Types for Fireflies API responses --- // --- Types for Fireflies API responses ---
interface FirefliesMeeting { interface FirefliesMeeting {
@ -553,7 +577,7 @@ async function syncMeetings() {
*/ */
export async function init() { export async function init() {
console.log('[Fireflies] Starting Fireflies Sync...'); 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.`); console.log(`[Fireflies] Syncing transcripts from the last ${LOOKBACK_DAYS} days.`);
while (true) { while (true) {
@ -571,9 +595,9 @@ export async function init() {
console.error('[Fireflies] Error in main loop:', error); 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...`); console.log(`[Fireflies] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); await interruptibleSleep(SYNC_INTERVAL_MS);
} }
} }

View file

@ -13,6 +13,30 @@ const REQUIRED_SCOPE = 'https://www.googleapis.com/auth/gmail.readonly';
const nhm = new NodeHtmlMarkdown(); 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<void> {
return new Promise(resolve => {
const timeout = setTimeout(() => {
wakeResolve = null;
resolve();
}, ms);
wakeResolve = () => {
clearTimeout(timeout);
resolve();
};
});
}
// --- Helper Functions --- // --- Helper Functions ---
function cleanFilename(name: string): string { function cleanFilename(name: string): string {
@ -287,7 +311,7 @@ async function performSync() {
export async function init() { export async function init() {
console.log("Starting Gmail Sync (TS)..."); 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) { while (true) {
try { try {
@ -304,8 +328,8 @@ export async function init() {
console.error("Error in main loop:", error); 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...`); console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`);
await new Promise(resolve => setTimeout(resolve, SYNC_INTERVAL_MS)); await interruptibleSleep(SYNC_INTERVAL_MS);
} }
} }