diff --git a/README.md b/README.md index 46be6f49..361b87a0 100644 --- a/README.md +++ b/README.md @@ -35,18 +35,18 @@ Rowboat connects to your email and meeting notes, builds a long-lived knowledge You can do things like: - `Build me a deck about our next quarter roadmap` → generates a PDF using context from your knowledge graph - `Prep me for my meeting with Alex` → pulls past decisions, open questions, and relevant threads into a crisp brief (or a voice note) +- Track a person, company or topic through live notes - Visualize, edit, and update your knowledge graph anytime (it’s just Markdown) - Record voice memos that automatically capture and update key takeaways in the graph Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/downloads) +⭐ If you find Rowboat useful, please star the repo. It helps more people find it. ## Demo +[![Demo](https://github.com/user-attachments/assets/8b9a859b-d4f1-47ca-9d1d-9d26d982e15d)](https://www.youtube.com/watch?v=7xTpciZCfpw) - -[![Demo](https://github.com/user-attachments/assets/3f560bcf-d93c-4064-81eb-75a9fae31742)](https://www.youtube.com/watch?v=5AWoGo-L16I) - -[Watch the full video](https://www.youtube.com/watch?v=5AWoGo-L16I) +[Watch the full video](https://www.youtube.com/watch?v=7xTpciZCfpw) --- @@ -60,23 +60,27 @@ Download latest for Mac/Windows/Linux: [Download](https://www.rowboatlabs.com/do To connect Google services (Gmail, Calendar, and Drive), follow [Google setup](https://github.com/rowboatlabs/rowboat/blob/main/google-setup.md). ### Voice input -To enable voice input and voice notes (optional), add a Deepgram API key in ~/.rowboat/config/deepgram.json: +To enable voice input and voice notes (optional), add a Deepgram API key in `~/.rowboat/config/deepgram.json` + +### Voice output + +To enable voice output (optional), add an ElevenLabs API key in `~/.rowboat/config/elevenlabs.json` + +### Web search + +To use Exa research search (optional), add the Exa API key in `~/.rowboat/config/exa-search.json` + +### External tools + +To enable external tools (optional), you can add any MCP server or use Composio tools by adding an API key in `~/.rowboat/config/composio.json` + +All API key files use the same format: ``` { "apiKey": "" } ``` -### Voice output - -To enable voice output (optional), add a Elevenlabs API key in ~/.rowboat/config/elevenlabs.json - -### Web search - -To use Exa research search (optional), add the Exa API key in ~/.rowboat/config/exa-search.json. - -(same format as above) - ## What it does Rowboat is a **local-first AI coworker** that can: @@ -90,8 +94,10 @@ Under the hood, Rowboat maintains an **Obsidian-compatible vault** of plain Mark Rowboat builds memory from the work you already do, including: - **Gmail** (email) -- **Granola** (meeting notes) -- **Fireflies** (meeting notes) +- **Google Calendar** +- **Rowboat meeting notes** or **Fireflies** + +It also contains a library of product integrations through Composio.dev ## How it’s different @@ -113,17 +119,15 @@ The result is memory that compounds, rather than retrieval that starts cold ever - **Follow-ups**: capture decisions, action items, and owners so nothing gets dropped - **On-your-machine help**: create files, summarize into notes, and run workflows using local tools (with explicit, reviewable actions) -## Background agents +## Live notes -Rowboat can spin up **background agents** to do repeatable work automatically - so routine tasks happen without you having to ask every time. +Live notes are notes that stay updated automatically. You can create one by typing '@rowboat' on a note. -Examples: -- Draft email replies in the background (grounded in your past context and commitments) -- Generate a daily voice note each morning (agenda, priorities, upcoming meetings) -- Create recurring project updates from the latest emails/notes -- Keep your knowledge graph up to date as new information comes in +- Track a competitor or market topic across X, Reddit, and the news +- Monitor a person, project, or deal across web or your communications +- Keep a running summary of any subject you care about -You control what runs, when it runs, and what gets written back into your local Markdown vault. +Everything is written back into your local Markdown vault. You control what runs and when. ## Bring your own model diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 78e519d0..ad184451 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -22,10 +22,11 @@ export interface AuthServerResult { /** * Create a local HTTP server to handle OAuth callback * Listens on http://localhost:8080/oauth/callback + * Passes the full callback URL (including iss, scope, etc.) so openid-client validation succeeds. */ export function createAuthServer( port: number = DEFAULT_PORT, - onCallback: (params: Record) => void | Promise + onCallback: (callbackUrl: URL) => void | Promise ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -38,8 +39,6 @@ export function createAuthServer( const url = new URL(req.url, `http://localhost:${port}`); if (url.pathname === OAUTH_CALLBACK_PATH) { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); const error = url.searchParams.get('error'); if (error) { @@ -65,9 +64,8 @@ export function createAuthServer( return; } - // Handle callback - either traditional OAuth with code/state or Composio-style notification - // Composio callbacks may not have code/state, just a notification that the flow completed - onCallback(Object.fromEntries(url.searchParams.entries())); + // Handle callback - pass full URL so params like iss (OpenID Connect) are preserved for token exchange + onCallback(url); res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(` diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index 59532373..111eb5a5 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -151,7 +151,7 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ // Set up callback server const timeoutRef: { current: NodeJS.Timeout | null } = { current: null }; let callbackHandled = false; - const { server } = await createAuthServer(8081, async () => { + const { server } = await createAuthServer(8081, async (_callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index dde2246d..288d3038 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -14,6 +14,43 @@ import { emitOAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +/** Top-level openid-client messages that often wrap a more specific cause. */ +const OPAQUE_OAUTH_TOP_MESSAGES = new Set(['invalid response encountered']); + +function firstCauseMessage(error: unknown): string | undefined { + if (error == null || typeof error !== 'object' || !('cause' in error)) { + return undefined; + } + const cause = (error as { cause?: unknown }).cause; + if (cause instanceof Error && cause.message.trim()) { + return cause.message; + } + if (typeof cause === 'string' && cause.trim()) { + return cause; + } + return undefined; +} + +/** + * User-facing message for token-exchange failures. Prefer the first cause message when + * the top-level message is opaque (common for openid-client) or when code is OAUTH_INVALID_RESPONSE. + * The catch block below still logs the full cause chain for any error; this helper stays conservative. + */ +function getOAuthErrorMessage(error: unknown): string { + const msg = error instanceof Error ? error.message : 'Unknown error'; + const code = error != null && typeof error === 'object' && 'code' in error + ? (error as { code?: string }).code + : undefined; + const causeMsg = firstCauseMessage(error); + if (code === 'OAUTH_INVALID_RESPONSE' && causeMsg) { + return causeMsg; + } + if (causeMsg && OPAQUE_OAUTH_TOP_MESSAGES.has(msg.trim().toLowerCase())) { + return causeMsg; + } + return msg; +} + // Store active OAuth flows (state -> { codeVerifier, provider, config }) const activeFlows = new Map) => { + const { server } = await createAuthServer(8080, async (callbackUrl) => { // Guard against duplicate callbacks (browser may send multiple requests) if (callbackHandled) return; callbackHandled = true; - // Validate state - if (params.state !== state) { + const receivedState = callbackUrl.searchParams.get('state'); + if (receivedState == null || receivedState === '') { + throw new Error( + 'OAuth callback missing state parameter. Complete sign-in in the browser or check the redirect URI.' + ); + } + if (receivedState !== state) { throw new Error('Invalid state parameter - possible CSRF attack'); } @@ -202,10 +249,7 @@ export async function connectProvider(provider: string, clientId?: string): Prom } try { - // Build callback URL for token exchange - const callbackUrl = new URL(`${REDIRECT_URI}?${new URLSearchParams(params).toString()}`); - - // Exchange code for tokens + // Use full callback URL (includes iss, scope, etc.) so openid-client validation succeeds console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); const tokens = await oauthClient.exchangeCodeForTokens( flow.config, @@ -234,7 +278,15 @@ export async function connectProvider(provider: string, clientId?: string): Prom emitOAuthEvent({ provider, success: true }); } catch (error) { console.error('OAuth token exchange failed:', error); - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + // Log cause chain for debugging (e.g. OAUTH_INVALID_RESPONSE -> OperationProcessingError) + let cause: unknown = error; + while (cause != null && typeof cause === 'object' && 'cause' in cause) { + cause = (cause as { cause?: unknown }).cause; + if (cause != null) { + console.error('[OAuth] Caused by:', cause); + } + } + const errorMessage = getOAuthErrorMessage(error); emitOAuthEvent({ provider, success: false, error: errorMessage }); throw error; } finally { @@ -302,8 +354,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b export async function getAccessToken(provider: string): Promise { try { const oauthRepo = getOAuthRepo(); - - const { tokens } = await oauthRepo.read(provider); + + let { tokens } = await oauthRepo.read(provider); if (!tokens) { return null; } @@ -319,11 +371,12 @@ export async function getAccessToken(provider: string): Promise { try { // Get configuration for refresh const config = await getProviderConfiguration(provider); - + // Refresh token, preserving existing scopes const existingScopes = tokens.scopes; const refreshedTokens = await oauthClient.refreshTokens(config, tokens.refresh_token, existingScopes); - await oauthRepo.upsert(provider, { tokens }); + await oauthRepo.upsert(provider, { tokens: refreshedTokens }); + tokens = refreshedTokens; } catch (error) { const message = error instanceof Error ? error.message : 'Token refresh failed'; await oauthRepo.upsert(provider, { error: message }); diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 82064205..f0e9049a 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -517,6 +517,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { isConnecting: false, } } + // Hydrate in-memory Google client ID from persisted config so Connect can skip re-entry + if (config.google?.clientId) { + setGoogleClientId(config.google.clientId) + } } catch (error) { console.error('Failed to check connection status for providers:', error) for (const provider of providers) { diff --git a/apps/x/apps/renderer/src/hooks/useConnectors.ts b/apps/x/apps/renderer/src/hooks/useConnectors.ts index cad319d7..aa2942da 100644 --- a/apps/x/apps/renderer/src/hooks/useConnectors.ts +++ b/apps/x/apps/renderer/src/hooks/useConnectors.ts @@ -426,6 +426,9 @@ export function useConnectors(active: boolean) { try { const result = await window.ipc.invoke('oauth:getState', null) const config = result.config || {} + if (config.google?.clientId) { + setGoogleClientId(config.google.clientId) + } const statusMap: Record = {} for (const provider of providers) { diff --git a/apps/x/packages/core/src/auth/repo.ts b/apps/x/packages/core/src/auth/repo.ts index a53c2dcb..70eecf0e 100644 --- a/apps/x/packages/core/src/auth/repo.ts +++ b/apps/x/packages/core/src/auth/repo.ts @@ -18,6 +18,7 @@ const OAuthConfigSchema = z.object({ const ClientFacingConfigSchema = z.record(z.string(), z.object({ connected: z.boolean(), error: z.string().nullable().optional(), + clientId: z.string().nullable().optional(), })); const LegacyOauthConfigSchema = z.record(z.string(), OAuthTokens); @@ -111,8 +112,9 @@ export class FSOAuthRepo implements IOAuthRepo { clientFacingConfig[provider] = { connected: !!providerConfig.tokens, error: providerConfig.error, + clientId: providerConfig.clientId ?? null, }; } - return clientFacingConfig; + return ClientFacingConfigSchema.parse(clientFacingConfig); } } \ No newline at end of file diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 021db404..a8709aa2 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -252,6 +252,7 @@ const ipcSchemas = { connected: z.boolean(), error: z.string().nullable().optional(), userId: z.string().optional(), + clientId: z.string().nullable().optional(), })), }), }, diff --git a/google-setup.md b/google-setup.md index 27ff7b32..5769aab8 100644 --- a/google-setup.md +++ b/google-setup.md @@ -122,6 +122,14 @@ Select: ![Create OAuth Client ID (UWP)](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/05-create-oauth-client-uwp.png) +### Authorized redirect URIs (if shown) + +If your OAuth client configuration shows **Authorized redirect URIs**, add: + +- `http://localhost:8080/oauth/callback` + +Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback. (Some client types, e.g. UWP, may not expose redirect URIs; that is fine.) + --- ## 7️⃣ Copy the Client ID @@ -136,3 +144,15 @@ Copy the **Client ID** and paste it into Rowboat where prompted. ![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png) --- + +## Troubleshooting + +**Error after "Authorization Successful"** + +If the browser shows "Authorization Successful" but the app then shows an error (e.g. "invalid response encountered" or "response parameter \"iss\" (issuer) missing"): + +1. **Check the app logs** (e.g. terminal or dev tools) for the full error. The message there will often indicate the cause (e.g. redirect URI mismatch, missing parameter). +2. **Verify redirect URI in Google Cloud Console**: Open [Credentials → your OAuth 2.0 Client ID](https://console.cloud.google.com/auth/clients). If the client type allows **Authorized redirect URIs**, ensure `http://localhost:8080/oauth/callback` is listed exactly. +3. **Client type**: Use **Desktop** or **UWP** as the application type. A "Web application" client may require the redirect URI to be set and can behave differently with localhost. + +---