mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-06 20:42:15 +02:00
Merge branch 'dev' of github.com:rowboatlabs/rowboat into dev
This commit is contained in:
commit
c0efa3329a
10 changed files with 476 additions and 41 deletions
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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(()=>{});
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue