Compare commits

...

20 commits

Author SHA1 Message Date
Arjun
b1bbfdbc9e resolve workdir 2026-04-10 10:33:28 +05:30
Arjun
ef7bc579a1 fix workdir everywhere 2026-04-10 09:54:23 +05:30
Ramnique Singh
44fd94d5e6 use correct paths in analytics 2026-04-10 01:21:01 +05:30
Ramnique Singh
1ac4e648da fix model-fetch error: remove experimental field handling 2026-04-10 01:07:52 +05:30
Ramnique Singh
50bce6c1d6 feat(oauth): switch Google OAuth from PKCE to authorization code flow with client secret
Previously, the Google OAuth integration used a PKCE-only flow (no client
secret). This switches to a standard authorization code flow where the user
provides both a Client ID and Client Secret from a "Web application" type
OAuth client in Google Cloud Console. PKCE is retained alongside the secret
for defense in depth.

Key changes:

- oauth-client.ts: discoverConfiguration() and createStaticConfiguration()
  now accept an optional clientSecret param. When provided, uses
  ClientSecretPost instead of None() for client authentication.

- oauth-handler.ts: connectProvider() takes a credentials object
  ({clientId, clientSecret}) instead of a bare clientId. Removed eager
  persistence of clientId before flow completion — credentials are now
  only saved after successful token exchange. Renamed resolveClientId to
  resolveClientCredentials to return both values from a single repo read.

- google-client-factory.ts: same resolveClientId → resolveCredentials
  rename. Passes clientSecret to OAuth2Client constructor and
  discoverConfiguration for token refresh.

- repo.ts: added clientSecret to ProviderConnectionSchema. Not exposed
  to renderer via ClientFacingConfigSchema (stays main-process only).

- IPC: added clientSecret to oauth:connect request schema. Handler builds
  a credentials object and passes it through.

- UI: GoogleClientIdModal now collects both Client ID and Client Secret
  (password field). Always shown on connect — no in-memory credential
  caching. Renamed google-client-id-store to google-credentials-store
  with a unified {clientId, clientSecret} object.

- google-setup.md: updated to instruct users to create a "Web application"
  type OAuth client (instead of UWP), add the localhost redirect URI, and
  copy both Client ID and Client Secret. Added credentials modal screenshot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:43:34 +05:30
Arjun
924e136505 change lookback to 7 days 2026-04-09 23:55:29 +05:30
tusharmagar
1d29ca8886 refactor(sidebar): simplify auto-collapse logic and improve sidebar toggle behavior 2026-04-09 20:58:59 +05:30
tusharmagar
79a21c715e feat(sidebar): implement auto-collapse functionality and refine sidebar toggle logic
Fixed sidebar animations and icon positioning
remove mobile sidebar
2026-04-09 14:38:54 +05:30
tusharmagar
1b81a42ed3 refactor(App): update sidebar toggle functionality and adjust button configurations 2026-04-09 13:28:26 +05:30
tusharmagar
9c010dabd8 fix(help-popover): update Discord invite link to the new URL 2026-04-09 10:09:18 +05:30
Ramnique Singh
89b6b963a2 Merge branch 'main' into dev 2026-04-09 07:06:56 +05:30
Ramnique Singh
d854b3f4f0
Merge pull request #431 from aculich/pr/google-oauth-callback
fix(oauth): preserve full callback URL for token exchange + persist Google Client ID
2026-04-09 07:05:16 +05:30
arkml
de24293cce
Merge pull request #470 from rowboatlabs/arkml-patch-18
Revise voice input/output and tools integration sections
2026-04-08 17:17:20 +05:30
arkml
52f5922495
Revise voice input/output and tools integration sections
Updated README to improve clarity and formatting for API key configuration.
2026-04-08 17:14:08 +05:30
arkml
34d64382d5
Merge pull request #469 from rowboatlabs/arkml-patch-17
Enhance README with tools integration and live notes
2026-04-08 16:58:07 +05:30
arkml
6da447ef3b
Enhance README with tools integration and live notes
Added sections for tools integration and live notes, and updated existing content for clarity and completeness.
2026-04-08 16:54:46 +05:30
arkml
4a16af6e02
Merge pull request #468 from rowboatlabs/arkml-patch-16
Replace demo video links in README
2026-04-08 11:31:17 +05:30
arkml
670b623fe6
Replace demo video links in README
Updated demo section with new video links and removed old demo images.
2026-04-08 11:30:08 +05:30
Aaron Culich
e1c6758a3f fix(oauth): full callback URL, Google clientId, refresh, and review follow-ups
- Pass full OAuth callback URL through auth-server for openid-client validation
- Composio + Google flows: duplicate-callback guard; preserve timeout cleanup
- Persist and expose Google clientId via oauth:getState; hydrate UI from useConnectors
- getAccessToken returns refreshed credentials; clearer errors and missing-state handling
- IPC schema: per-provider userId + clientId
- Docs: google-setup redirect URI and troubleshooting

Made-with: Cursor
2026-04-07 11:24:25 -07:00
Ramnique Singh
598aeb59cc
Merge pull request #467 from rowboatlabs/dev
Dev changes
2026-04-07 22:21:05 +05:30
44 changed files with 439 additions and 338 deletions

View file

@ -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 (its 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": "<key>"
}
```
### 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 its 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View file

@ -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<string, string>) => void | Promise<void>
onCallback: (callbackUrl: URL) => void | Promise<void>
): Promise<AuthServerResult> {
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(`

View file

@ -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;

View file

@ -110,6 +110,18 @@ function markdownToHtml(markdown: string, title: string): string {
</style></head><body>${html}</body></html>`
}
function resolveShellPath(filePath: string): string {
if (filePath.startsWith('~')) {
return path.join(os.homedir(), filePath.slice(1));
}
if (path.isAbsolute(filePath)) {
return filePath;
}
return workspace.resolveWorkspacePath(filePath);
}
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -271,7 +283,7 @@ function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceCh
/**
* Start workspace watcher
* Watches ~/.rowboat recursively and emits change events to renderer
* Watches the configured workspace root recursively and emits change events to renderer
*
* This should be called once when the app starts (from main.ts).
* The watcher runs as a main-process service and catches ALL filesystem changes
@ -460,7 +472,10 @@ export function setupIpcHandlers() {
return { success: true };
},
'oauth:connect': async (_event, args) => {
return await connectProvider(args.provider, args.clientId?.trim());
const credentials = args.clientId && args.clientSecret
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }
: undefined;
return await connectProvider(args.provider, credentials);
},
'oauth:disconnect': async (_event, args) => {
return await disconnectProvider(args.provider);
@ -604,24 +619,12 @@ export function setupIpcHandlers() {
},
// Shell integration handlers
'shell:openPath': async (_event, args) => {
let filePath = args.path;
if (filePath.startsWith('~')) {
filePath = path.join(os.homedir(), filePath.slice(1));
} else if (!path.isAbsolute(filePath)) {
// Workspace-relative path — resolve against ~/.rowboat/
filePath = path.join(os.homedir(), '.rowboat', filePath);
}
const filePath = resolveShellPath(args.path);
const error = await shell.openPath(filePath);
return { error: error || undefined };
},
'shell:readFileBase64': async (_event, args) => {
let filePath = args.path;
if (filePath.startsWith('~')) {
filePath = path.join(os.homedir(), filePath.slice(1));
} else if (!path.isAbsolute(filePath)) {
// Workspace-relative path — resolve against ~/.rowboat/
filePath = path.join(os.homedir(), '.rowboat', filePath);
}
const filePath = resolveShellPath(args.path);
const stat = await fs.stat(filePath);
if (stat.size > 10 * 1024 * 1024) {
throw new Error('File too large (>10MB)');

View file

@ -112,6 +112,8 @@ function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 600,
minHeight: 480,
show: false, // Don't show until ready
backgroundColor: "#252525", // Prevent white flash (matches dark mode)
titleBarStyle: "hiddenInset",

View file

@ -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<string, {
codeVerifier: string;
@ -74,19 +111,19 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
/**
* Get or create OAuth configuration for a provider
*/
async function getProviderConfiguration(provider: string, clientIdOverride?: string): Promise<Configuration> {
async function getProviderConfiguration(provider: string, credentialsOverride?: { clientId: string; clientSecret: string }): Promise<Configuration> {
const config = await getProviderConfig(provider);
const resolveClientId = async (): Promise<string> => {
const resolveClientCredentials = async (): Promise<{ clientId: string; clientSecret?: string }> => {
if (config.client.mode === 'static' && config.client.clientId) {
return config.client.clientId;
return { clientId: config.client.clientId, clientSecret: credentialsOverride?.clientSecret };
}
if (clientIdOverride) {
return clientIdOverride;
if (credentialsOverride) {
return { clientId: credentialsOverride.clientId, clientSecret: credentialsOverride.clientSecret };
}
const oauthRepo = getOAuthRepo();
const { clientId } = await oauthRepo.read(provider);
if (clientId) {
return clientId;
const connection = await oauthRepo.read(provider);
if (connection.clientId) {
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
}
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
};
@ -95,10 +132,11 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
if (config.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
const clientId = await resolveClientId();
const { clientId, clientSecret } = await resolveClientCredentials();
return await oauthClient.discoverConfiguration(
config.discovery.issuer,
clientId
clientId,
clientSecret
);
} else {
// DCR mode - check for existing registration or register new
@ -135,12 +173,13 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
}
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
const clientId = await resolveClientId();
const { clientId, clientSecret } = await resolveClientCredentials();
return oauthClient.createStaticConfiguration(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
clientId,
config.discovery.revocationEndpoint
config.discovery.revocationEndpoint,
clientSecret
);
}
}
@ -148,7 +187,7 @@ async function getProviderConfiguration(provider: string, clientIdOverride?: str
/**
* Initiate OAuth flow for a provider
*/
export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> {
export async function connectProvider(provider: string, credentials?: { clientId: string; clientSecret: string }): Promise<{ success: boolean; error?: string }> {
try {
console.log(`[OAuth] Starting connection flow for ${provider}...`);
@ -159,13 +198,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
const providerConfig = await getProviderConfig(provider);
if (provider === 'google') {
if (!clientId) {
return { success: false, error: 'Google client ID is required to connect.' };
if (!credentials?.clientId || !credentials?.clientSecret) {
return { success: false, error: 'Google client ID and client secret are required to connect.' };
}
}
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider, clientId);
const config = await getProviderConfiguration(provider, credentials);
// Generate PKCE codes
const { verifier: codeVerifier, challenge: codeChallenge } = await oauthClient.generatePKCE();
@ -187,12 +226,17 @@ export async function connectProvider(provider: string, clientId?: string): Prom
// Create callback server
let callbackHandled = false;
const { server } = await createAuthServer(8080, async (params: Record<string, string>) => {
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 +246,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,
@ -214,13 +255,13 @@ export async function connectProvider(provider: string, clientId?: string): Prom
state
);
// Save tokens
// Save tokens and credentials
console.log(`[OAuth] Token exchange successful for ${provider}`);
await oauthRepo.upsert(provider, { tokens });
if (provider === 'google' && clientId) {
await oauthRepo.upsert(provider, { clientId });
}
await oauthRepo.upsert(provider, { error: null });
await oauthRepo.upsert(provider, {
tokens,
...(credentials ? { clientId: credentials.clientId, clientSecret: credentials.clientSecret } : {}),
error: null,
});
// Trigger immediate sync for relevant providers
if (provider === 'google') {
@ -234,7 +275,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 +351,8 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
export async function getAccessToken(provider: string): Promise<string | null> {
try {
const oauthRepo = getOAuthRepo();
const { tokens } = await oauthRepo.read(provider);
let { tokens } = await oauthRepo.read(provider);
if (!tokens) {
return null;
}
@ -319,11 +368,12 @@ export async function getAccessToken(provider: string): Promise<string | null> {
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 });

View file

@ -3,7 +3,7 @@ import { bus } from '@x/core/dist/runs/bus.js';
async function main() {
const { id } = await runsCore.createRun({
// this will expect an agent file to exist at ~/.rowboat/agents/test-agent.md
// this expects an agent file to exist at WorkDir/agents/test-agent.md
agentId: 'test-agent',
});
console.log(`created run: ${id}`);
@ -16,4 +16,4 @@ async function main() {
console.log(`created message: ${msgId}`);
}
main();
main();

View file

@ -124,8 +124,8 @@ const TITLEBAR_BUTTON_PX = 32
const TITLEBAR_BUTTON_GAP_PX = 4
const TITLEBAR_HEADER_GAP_PX = 8
const TITLEBAR_TOGGLE_MARGIN_LEFT_PX = 12
const TITLEBAR_BUTTONS_COLLAPSED = 5
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 4
const TITLEBAR_BUTTONS_COLLAPSED = 4
const TITLEBAR_BUTTON_GAPS_COLLAPSED = 3
const GRAPH_TAB_PATH = '__rowboat_graph_view__'
const BASES_DEFAULT_TAB_PATH = '__rowboat_bases_default__'
@ -446,12 +446,8 @@ function viewStatesEqual(a: ViewState, b: ViewState): boolean {
return true // both graph
}
/** Sidebar toggle + back/forward nav */
/** Sidebar toggle + utility buttons (fixed position, top-left) */
function FixedSidebarToggle({
onNavigateBack,
onNavigateForward,
canNavigateBack,
canNavigateForward,
onNewChat,
onOpenSearch,
meetingState,
@ -460,10 +456,6 @@ function FixedSidebarToggle({
onToggleMeeting,
leftInsetPx,
}: {
onNavigateBack: () => void
onNavigateForward: () => void
canNavigateBack: boolean
canNavigateForward: boolean
onNewChat: () => void
onOpenSearch: () => void
meetingState: MeetingTranscriptionState
@ -472,8 +464,7 @@ function FixedSidebarToggle({
onToggleMeeting: () => void
leftInsetPx: number
}) {
const { toggleSidebar, state } = useSidebar()
const isCollapsed = state === "collapsed"
const { toggleSidebar } = useSidebar()
return (
<div className="fixed left-0 top-0 z-50 flex h-10 items-center" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
<div aria-hidden="true" className="h-10 shrink-0" style={{ width: leftInsetPx }} />
@ -536,30 +527,6 @@ function FixedSidebarToggle({
</TooltipContent>
</Tooltip>
)}
{/* Back / Forward navigation */}
{isCollapsed && (
<>
<button
type="button"
onClick={onNavigateBack}
disabled={!canNavigateBack}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
style={{ marginLeft: TITLEBAR_BUTTON_GAP_PX }}
aria-label="Go back"
>
<ChevronLeftIcon className="size-5" />
</button>
<button
type="button"
onClick={onNavigateForward}
disabled={!canNavigateForward}
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-30 disabled:pointer-events-none"
aria-label="Go forward"
>
<ChevronRightIcon className="size-5" />
</button>
</>
)}
</div>
)
}
@ -584,15 +551,14 @@ function ContentHeader({
const isCollapsed = state === "collapsed"
return (
<header
className={cn(
"titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border px-3 bg-sidebar transition-[padding] duration-200 ease-linear overflow-hidden",
// When the sidebar is collapsed the content area shifts left, so we need enough left padding
// to avoid overlapping the fixed traffic-lights/toggle/back/forward controls.
isCollapsed && !collapsedLeftPaddingPx && "pl-[196px]"
)}
style={isCollapsed && collapsedLeftPaddingPx ? { paddingLeft: collapsedLeftPaddingPx } : undefined}
className="titlebar-drag-region flex h-10 shrink-0 items-stretch border-b border-border bg-sidebar overflow-hidden"
style={{
paddingLeft: isCollapsed ? (collapsedLeftPaddingPx ?? 196) : 12,
paddingRight: 12,
transition: 'padding-left 200ms linear',
}}
>
{!isCollapsed && onNavigateBack && onNavigateForward ? (
{onNavigateBack && onNavigateForward ? (
<div className="titlebar-no-drag flex items-center gap-1 pr-2 shrink-0">
<button
type="button"
@ -4553,10 +4519,6 @@ function App() {
)}
{/* Rendered last so its no-drag region paints over the sidebar drag region */}
<FixedSidebarToggle
onNavigateBack={() => { void navigateBack() }}
onNavigateForward={() => { void navigateForward() }}
canNavigateBack={canNavigateBack}
canNavigateForward={canNavigateForward}
onNewChat={handleNewChatTab}
onOpenSearch={() => setIsSearchOpen(true)}
meetingState={meetingTranscription.state}

View file

@ -17,7 +17,7 @@ const GOOGLE_CLIENT_ID_SETUP_GUIDE_URL =
interface GoogleClientIdModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (clientId: string) => void
onSubmit: (clientId: string, clientSecret: string) => void
isSubmitting?: boolean
description?: string
}
@ -30,19 +30,22 @@ export function GoogleClientIdModal({
description,
}: GoogleClientIdModalProps) {
const [clientId, setClientId] = useState("")
const [clientSecret, setClientSecret] = useState("")
useEffect(() => {
if (!open) {
setClientId("")
setClientSecret("")
}
}, [open])
const trimmedClientId = clientId.trim()
const isValid = trimmedClientId.length > 0
const trimmedClientSecret = clientSecret.trim()
const isValid = trimmedClientId.length > 0 && trimmedClientSecret.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedClientId)
onSubmit(trimmedClientId, trimmedClientSecret)
}
return (
@ -50,9 +53,9 @@ export function GoogleClientIdModal({
<DialogContent className="w-[min(28rem,calc(100%-2rem))] max-w-md p-0 gap-0 overflow-hidden rounded-xl">
<div className="p-6 pb-0">
<DialogHeader className="space-y-1.5">
<DialogTitle className="text-lg font-semibold">Google Client ID</DialogTitle>
<DialogTitle className="text-lg font-semibold">Google OAuth Credentials</DialogTitle>
<DialogDescription className="text-sm">
{description ?? "Enter the client ID for your Google OAuth app to connect."}
{description ?? "Enter the credentials for your Google OAuth app to connect."}
</DialogDescription>
</DialogHeader>
</div>
@ -76,6 +79,25 @@ export function GoogleClientIdModal({
autoFocus
/>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1.5 block" htmlFor="google-client-secret">
Client Secret
</label>
<Input
id="google-client-secret"
type="password"
placeholder="GOCSPX-..."
value={clientSecret}
onChange={(event) => setClientSecret(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
className="font-mono text-xs"
/>
</div>
<p className="text-xs text-muted-foreground">
Need help?{" "}
<a

View file

@ -25,7 +25,7 @@ export function HelpPopover({ children, tooltip }: HelpPopoverProps) {
const [open, setOpen] = useState(false)
const handleDiscordClick = () => {
window.open("https://discord.gg/htdKpBZF", "_blank")
window.open("https://discord.com/invite/wajrgmJQ6b", "_blank")
}
return (

View file

@ -23,7 +23,7 @@ import {
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
@ -589,14 +589,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
@ -618,22 +618,17 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Step indicator - dynamic based on path

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
export interface ProviderState {
@ -576,14 +576,14 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
@ -605,22 +605,17 @@ export function useOnboardingState(open: boolean, onComplete: () => void) {
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
// Switch to rowboat path from BYOK inline callout

View file

@ -205,7 +205,7 @@ function formatRunTime(ts: string): string {
}
function SyncStatusBar() {
const { state, isMobile } = useSidebar()
const { state } = useSidebar()
const [activeServices, setActiveServices] = useState<Map<string, string>>(new Map())
const [popoverOpen, setPopoverOpen] = useState(false)
const [logEvents, setLogEvents] = useState<ServiceEventType[]>([])
@ -301,7 +301,7 @@ function SyncStatusBar() {
return (
<>
{!isMobile && isCollapsed && isSyncing && (
{isCollapsed && isSyncing && (
<div
className="fixed bottom-4 z-40 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-background shadow-sm"
style={{ left: "0.5rem" }}

View file

@ -3,18 +3,10 @@ import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
@ -28,17 +20,14 @@ const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = 256 // 16rem in pixels
const SIDEBAR_WIDTH_MIN = 200
const SIDEBAR_WIDTH_MAX = 480
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_OFFSET = "0px" // Default offset for nested sidebars
const SIDEBAR_AUTO_COLLAPSE_WIDTH = 760 // Auto-collapse when window narrower than this
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
sidebarWidth: number
setSidebarWidth: (width: number) => void
@ -70,8 +59,6 @@ function SidebarProvider({
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
const [sidebarWidth, setSidebarWidth] = React.useState(SIDEBAR_WIDTH)
const [isResizing, setIsResizing] = React.useState(false)
@ -94,10 +81,20 @@ function SidebarProvider({
[setOpenProp, open]
)
// Auto-collapse sidebar when window crosses below the threshold
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${SIDEBAR_AUTO_COLLAPSE_WIDTH - 1}px)`)
const onChange = () => {
if (mql.matches) setOpen(false)
}
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [setOpen])
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
return setOpen((open) => !open)
}, [setOpen])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
@ -108,16 +105,13 @@ function SidebarProvider({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
sidebarWidth,
setSidebarWidth,
isResizing,
setIsResizing,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar, sidebarWidth, isResizing]
[state, open, setOpen, toggleSidebar, sidebarWidth, isResizing]
)
return (
@ -161,7 +155,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
const { state } = useSidebar()
if (collapsible === "none") {
return (
@ -178,34 +172,9 @@ function Sidebar({
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
className="group peer text-sidebar-foreground block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
@ -217,7 +186,7 @@ function Sidebar({
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
"[[data-resizing=false]_&]:transition-[width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
"in-data-[resizing=false]:transition-[width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
@ -228,10 +197,10 @@ function Sidebar({
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
"[[data-resizing=false]_&]:transition-[left,right,width] [[data-resizing=false]_&]:duration-200 [[data-resizing=false]_&]:ease-linear",
"fixed inset-y-0 z-10 flex h-svh w-(--sidebar-width)",
"in-data-[resizing=false]:transition-[left,right,width] in-data-[resizing=false]:duration-200 in-data-[resizing=false]:ease-linear",
side === "left"
? "left-[var(--sidebar-offset)] group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
? "left-(--sidebar-offset) group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-offset)-var(--sidebar-width))]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
@ -347,7 +316,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"peer-data-[variant=inset]:m-2 peer-data-[variant=inset]:ml-0 peer-data-[variant=inset]:rounded-xl peer-data-[variant=inset]:shadow-sm peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
@ -546,7 +515,7 @@ function SidebarMenuButton({
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const { state } = useSidebar()
const button = (
<Comp
@ -575,7 +544,7 @@ function SidebarMenuButton({
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
hidden={state !== "collapsed"}
{...tooltip}
/>
</Tooltip>

View file

@ -30,13 +30,13 @@ export function useAnalyticsIdentity() {
// Count notes for total_notes property
try {
const entries = await window.ipc.invoke('workspace:readdir', { path: '/' })
const entries = await window.ipc.invoke('workspace:readdir', { path: '' })
let totalNotes = 0
if (entries) {
for (const entry of entries) {
if (entry.kind === 'dir') {
try {
const sub = await window.ipc.invoke('workspace:readdir', { path: `/${entry.name}` })
const sub = await window.ipc.invoke('workspace:readdir', { path: `${entry.name}` })
totalNotes += sub?.length ?? 0
} catch {
// skip inaccessible dirs

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { setGoogleCredentials, clearGoogleCredentials } from "@/lib/google-credentials-store"
import { toast } from "sonner"
export interface ProviderState {
@ -318,14 +318,14 @@ export function useConnectors(active: boolean) {
}, [startGmailConnect])
// OAuth connect/disconnect
const startConnect = useCallback(async (provider: string, clientId?: string) => {
const startConnect = useCallback(async (provider: string, credentials?: { clientId: string; clientSecret: string }) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret })
if (!result.success) {
toast.error(result.error || (provider === 'rowboat' ? 'Failed to log in to Rowboat' : `Failed to connect to ${provider}`))
@ -347,23 +347,18 @@ export function useConnectors(active: boolean) {
const handleConnect = useCallback(async (provider: string) => {
if (provider === 'google') {
setGoogleClientIdDescription(undefined)
const existingClientId = getGoogleClientId()
if (!existingClientId) {
setGoogleClientIdOpen(true)
return
}
await startConnect(provider, existingClientId)
setGoogleClientIdOpen(true)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
const handleGoogleClientIdSubmit = useCallback((clientId: string, clientSecret: string) => {
setGoogleCredentials(clientId, clientSecret)
setGoogleClientIdOpen(false)
setGoogleClientIdDescription(undefined)
startConnect('google', clientId)
startConnect('google', { clientId, clientSecret })
}, [startConnect])
const handleDisconnect = useCallback(async (provider: string) => {
@ -377,7 +372,7 @@ export function useConnectors(active: boolean) {
if (result.success) {
if (provider === 'google') {
clearGoogleClientId()
clearGoogleCredentials()
}
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(provider === 'rowboat' ? 'Logged out of Rowboat' : `Disconnected from ${displayName}`)
@ -562,7 +557,7 @@ export function useConnectors(active: boolean) {
handleDisconnect,
startConnect,
// Google client ID modal
// Google credentials modal
googleClientIdOpen,
setGoogleClientIdOpen,
googleClientIdDescription,

View file

@ -55,10 +55,10 @@ export function useOAuth(provider: string) {
return cleanup;
}, [provider, checkConnection]);
const connect = useCallback(async (clientId?: string) => {
const connect = useCallback(async (credentials?: { clientId: string; clientSecret: string }) => {
try {
setIsConnecting(true);
const result = await window.ipc.invoke('oauth:connect', { provider, clientId });
const result = await window.ipc.invoke('oauth:connect', { provider, clientId: credentials?.clientId, clientSecret: credentials?.clientSecret });
if (result.success) {
// OAuth flow started - keep isConnecting state, wait for event
// Event listener will handle the actual completion

View file

@ -1,17 +0,0 @@
let googleClientId: string | null = null;
export function getGoogleClientId(): string | null {
return googleClientId;
}
export function setGoogleClientId(clientId: string): void {
const trimmed = clientId.trim();
if (!trimmed) {
return;
}
googleClientId = trimmed;
}
export function clearGoogleClientId(): void {
googleClientId = null;
}

View file

@ -0,0 +1,23 @@
interface GoogleCredentials {
clientId: string;
clientSecret: string;
}
let credentials: GoogleCredentials | null = null;
export function getGoogleCredentials(): GoogleCredentials | null {
return credentials;
}
export function setGoogleCredentials(clientId: string, clientSecret: string): void {
const trimmedId = clientId.trim();
const trimmedSecret = clientSecret.trim();
if (!trimmedId || !trimmedSecret) {
return;
}
credentials = { clientId: trimmedId, clientSecret: trimmedSecret };
}
export function clearGoogleCredentials(): void {
credentials = null;
}

View file

@ -112,10 +112,10 @@ Users can interact with the knowledge graph through you, open it directly in Obs
## How to Access the Knowledge Graph
**CRITICAL PATH REQUIREMENT:**
- The workspace root is \`~/.rowboat/\`
- The workspace root is the configured workdir
- 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"\`
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
Use the builtin workspace tools to search and read the knowledge base:
@ -212,16 +212,16 @@ For third-party services (GitHub, Gmail, Slack, etc.), load the \`composio-integ
${runtimeContextPrompt}
## Workspace Access & Scope
- **Inside \`~/.rowboat/\`:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside \`~/.rowboat/\` (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside \`~/.rowboat/\` unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads").
**CRITICAL - When the user asks you to work with files outside ~/.rowboat:**
**CRITICAL - When the user asks you to work with files outside the workspace root:**
- Follow the detected runtime platform above for shell syntax and filesystem path style.
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside ~/.rowboat" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
- NEVER ask what OS the user is on if runtime platform is already available.
@ -244,14 +244,14 @@ ${runtimeContextPrompt}
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
- \`composio-list-toolkits\`, \`composio-search-tools\`, \`composio-execute-tool\`, \`composio-connect-toolkit\` — Composio integration tools. Load the \`composio-integration\` skill for usage guidance.
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`.
**Shell commands via \`executeCommand\`:**
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately.
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
**CRITICAL: MCP Server Configuration**
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving

View file

@ -11,7 +11,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back
- Agents configure a model, tools (in frontmatter), and instructions (in the body)
- Tools can be: builtin (like ` + "`executeCommand`" + `), MCP integrations, or **other agents**
- **"Workflows" are just agents that orchestrate other agents** by having them as tools
- **Background agents run on schedules** defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `
- **Background agents run on schedules** defined in ` + "`config/agent-schedule.json`" + ` within the workspace root
## How multi-agent workflows work
@ -22,7 +22,7 @@ Load this skill whenever a user wants to inspect, create, edit, or schedule back
## Scheduling Background Agents
Background agents run automatically based on schedules defined in ` + "`~/.rowboat/config/agent-schedule.json`" + `.
Background agents run automatically based on schedules defined in ` + "`config/agent-schedule.json`" + ` in the workspace root.
### Schedule Configuration File
@ -150,7 +150,7 @@ You can add a ` + "`description`" + ` field to describe what the agent does. Thi
**IMPORTANT: Do NOT modify ` + "`agent-schedule-state.json`" + `** - it is managed automatically by the background runner.
The runner automatically tracks execution state in ` + "`~/.rowboat/config/agent-schedule-state.json`" + `:
The runner automatically tracks execution state in ` + "`config/agent-schedule-state.json`" + ` in the workspace root:
- ` + "`status`" + `: scheduled, running, finished, failed, triggered (for once-schedules)
- ` + "`lastRunAt`" + `: When the agent last ran
- ` + "`nextRunAt`" + `: When the agent will run next
@ -410,7 +410,7 @@ Create a morning briefing:
Execute these steps in sequence. Don't ask for human input.
` + "```" + `
**4. Schedule the workflow** in ` + "`~/.rowboat/config/agent-schedule.json`" + `:
**4. Schedule the workflow** in ` + "`config/agent-schedule.json`" + `:
` + "```json" + `
{
"agents": {
@ -548,7 +548,7 @@ Use the search tool to find information on the web.
5. When creating multi-agent workflows, create an orchestrator agent
6. Add other agents as tools with ` + "`type: agent`" + ` for chaining
7. Use ` + "`listMcpServers`" + ` and ` + "`listMcpTools`" + ` when adding MCP integrations
8. Configure schedules in ` + "`~/.rowboat/config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
8. Configure schedules in ` + "`config/agent-schedule.json`" + ` (ONLY edit this file, NOT the state file)
9. Confirm work done and outline next steps once changes are complete
`;

View file

@ -10,7 +10,7 @@ Agents can use builtin tools by declaring them in the YAML frontmatter \`tools\`
### executeCommand
**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output.
**Security note:** Commands are filtered through \`.rowboat/config/security.json\`. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
**Security note:** Commands are filtered through \`config/security.json\` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
**Agent tool declaration (YAML frontmatter):**
\`\`\`yaml

View file

@ -166,7 +166,7 @@ workspace-readFile("knowledge/Projects/[Project].md")
## Document Locations
Documents are stored in \`~/.rowboat/knowledge/\` with subfolders:
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
- \`People/\` - Notes about individuals
- \`Organizations/\` - Notes about companies, teams
- \`Projects/\` - Project documentation

View file

@ -7,7 +7,7 @@ You are helping the user draft email responses. Use their calendar and knowledge
**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\`).
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`

View file

@ -7,7 +7,7 @@ You are helping the user prepare for meetings by gathering context from their kn
**BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.**
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not root, not \`~/.rowboat\`).
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`

View file

@ -7,7 +7,7 @@ You interact with Slack by running **agent-slack** commands through \`executeCom
## 1. Check Connection
Before any Slack operation, read \`~/.rowboat/config/slack.json\`. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
@ -75,7 +75,7 @@ agent-slack canvas get F01234567 --workspace https://team.slack.com
## 3. Multi-Workspace
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`~/.rowboat/config/slack.json\` to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`config/slack.json\` from the workspace root to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:

View file

@ -1092,14 +1092,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
} catch {
return {
success: false,
error: 'Exa Search API key not configured. Create ~/.rowboat/config/exa-search.json with { "apiKey": "<your-key>" }',
error: `Exa Search API key not configured. Create ${exaConfigPath} with { "apiKey": "<your-key>" }`,
};
}
if (!apiKey) {
return {
success: false,
error: 'Exa Search API key is empty. Set "apiKey" in ~/.rowboat/config/exa-search.json',
error: `Exa Search API key is empty. Set "apiKey" in ${exaConfigPath}`,
};
}

View file

@ -37,9 +37,10 @@ function toOAuthTokens(response: client.TokenEndpointResponse): OAuthTokens {
*/
export async function discoverConfiguration(
issuerUrl: string,
clientId: string
clientId: string,
clientSecret?: string
): Promise<client.Configuration> {
const cacheKey = `${issuerUrl}:${clientId}`;
const cacheKey = `${issuerUrl}:${clientId}:${clientSecret ? 'secret' : 'none'}`;
const cached = configCache.get(cacheKey);
if (cached) {
@ -50,8 +51,8 @@ export async function discoverConfiguration(
const config = await client.discovery(
new URL(issuerUrl),
clientId,
undefined, // no client_secret (PKCE flow)
client.None(), // PKCE doesn't require client authentication
clientSecret ?? undefined,
clientSecret ? client.ClientSecretPost(clientSecret) : client.None(),
{
execute: [client.allowInsecureRequests],
}
@ -69,7 +70,8 @@ export function createStaticConfiguration(
authorizationEndpoint: string,
tokenEndpoint: string,
clientId: string,
revocationEndpoint?: string
revocationEndpoint?: string,
clientSecret?: string
): client.Configuration {
console.log(`[OAuth] Creating static configuration (no discovery)`);
@ -86,8 +88,8 @@ export function createStaticConfiguration(
return new client.Configuration(
serverMetadata,
clientId,
undefined, // no client_secret
client.None() // PKCE auth
clientSecret ?? undefined,
clientSecret ? client.ClientSecretPost(clientSecret) : client.None()
);
}

View file

@ -7,6 +7,7 @@ import z from 'zod';
const ProviderConnectionSchema = z.object({
tokens: OAuthTokens.nullable().optional(),
clientId: z.string().nullable().optional(),
clientSecret: z.string().nullable().optional(),
error: z.string().nullable().optional(),
});
@ -18,6 +19,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 +113,9 @@ export class FSOAuthRepo implements IOAuthRepo {
clientFacingConfig[provider] = {
connected: !!providerConfig.tokens,
error: providerConfig.error,
clientId: providerConfig.clientId ?? null,
};
}
return clientFacingConfig;
return ClientFacingConfigSchema.parse(clientFacingConfig);
}
}

View file

@ -3,9 +3,25 @@ import fs from "fs";
import { homedir } from "os";
import { fileURLToPath } from "url";
function resolveWorkDir(): string {
const configured = process.env.ROWBOAT_WORKDIR;
if (!configured) {
return path.join(homedir(), ".rowboat");
}
const expanded = configured === "~"
? homedir()
: (configured.startsWith("~/") || configured.startsWith("~\\"))
? path.join(homedir(), configured.slice(2))
: configured;
return path.resolve(expanded);
}
// Resolve app root relative to compiled file location (dist/...)
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage
export const WorkDir = process.env.ROWBOAT_WORKDIR || path.join(homedir(), ".rowboat");
// Allow override via ROWBOAT_WORKDIR env var for standalone pipeline usage.
// Normalize to an absolute path so workspace boundary checks behave consistently.
export const WorkDir = resolveWorkDir();
// Get the directory of this file (for locating bundled assets)
const __filename = fileURLToPath(import.meta.url);

View file

@ -13,7 +13,7 @@ Main orchestrator that:
### `graph_state.ts`
State management module that tracks which files have been processed:
- Uses hybrid mtime + hash approach for change detection
- Stores state in `~/.rowboat/knowledge_graph_state.json`
- Stores state in `WorkDir/knowledge_graph_state.json`
- Provides modular functions for state operations
### `sync_gmail.ts` & `sync_fireflies.ts`
@ -39,7 +39,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
### State File Structure
`~/.rowboat/knowledge_graph_state.json`:
`WorkDir/knowledge_graph_state.json`:
```json
{
"processedFiles": {
@ -69,7 +69,7 @@ This is efficient (only hashes potentially changed files) and reliable (confirms
3. **Agent processes batch**
- Extracts entities (people, orgs, projects, topics)
- Creates/updates notes in `~/.rowboat/knowledge/`
- Creates/updates notes in `WorkDir/knowledge/`
- Merges information for entities appearing in multiple files
## Replacing the Change Detection Logic
@ -135,7 +135,7 @@ import { resetGraphState } from './build_graph.js';
resetGraphState(); // Clears the state file
```
Or manually delete: `~/.rowboat/knowledge_graph_state.json`
Or manually delete: `WorkDir/knowledge_graph_state.json`
## Note Creation Strictness
@ -143,7 +143,7 @@ The system supports three strictness levels that control how aggressively notes
### Configuration
Strictness is configured in `~/.rowboat/config/note_creation.json`:
Strictness is configured in `WorkDir/config/note_creation.json`:
```json
{
@ -218,7 +218,7 @@ Each strictness level has its own agent prompt:
Change `BATCH_SIZE` in `build_graph.ts` (currently 25 files per batch)
### State File Location
Change `STATE_FILE` in `graph_state.ts` (currently `~/.rowboat/knowledge_graph_state.json`)
Change `STATE_FILE` in `graph_state.ts` (currently `WorkDir/knowledge_graph_state.json`)
### Hash Algorithm
Change `crypto.createHash('sha256')` in `graph_state.ts` to use a different algorithm (md5, sha1, etc.)

View file

@ -18,21 +18,23 @@ export class GoogleClientFactory {
client: OAuth2Client | null;
tokens: OAuthTokens | null;
clientId: string | null;
clientSecret: string | null;
} = {
config: null,
client: null,
tokens: null,
clientId: null,
clientSecret: null,
};
private static async resolveClientId(): Promise<string> {
private static async resolveCredentials(): Promise<{ clientId: string; clientSecret?: string }> {
const oauthRepo = container.resolve<IOAuthRepo>('oauthRepo');
const { clientId } = await oauthRepo.read(this.PROVIDER_NAME);
if (!clientId) {
const connection = await oauthRepo.read(this.PROVIDER_NAME);
if (!connection.clientId) {
await oauthRepo.upsert(this.PROVIDER_NAME, { error: 'Google client ID missing. Please reconnect.' });
throw new Error('Google client ID missing. Please reconnect.');
}
return clientId;
return { clientId: connection.clientId, clientSecret: connection.clientSecret ?? undefined };
}
/**
@ -82,9 +84,11 @@ export class GoogleClientFactory {
// Update cached tokens and recreate client
this.cache.tokens = refreshedTokens;
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
}
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(refreshedTokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
console.log(`[OAuth] Token refreshed successfully`);
return this.cache.client;
} catch (error) {
@ -105,9 +109,11 @@ export class GoogleClientFactory {
console.log(`[OAuth] Creating new OAuth2Client instance`);
this.cache.tokens = tokens;
if (!this.cache.clientId) {
this.cache.clientId = await this.resolveClientId();
const creds = await this.resolveCredentials();
this.cache.clientId = creds.clientId;
this.cache.clientSecret = creds.clientSecret ?? null;
}
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId);
this.cache.client = this.createClientFromTokens(tokens, this.cache.clientId, this.cache.clientSecret ?? undefined);
return this.cache.client;
}
@ -138,19 +144,20 @@ export class GoogleClientFactory {
this.cache.client = null;
this.cache.tokens = null;
this.cache.clientId = null;
this.cache.clientSecret = null;
}
/**
* Initialize cached configuration (called once)
*/
private static async initializeConfigCache(): Promise<void> {
const clientId = await this.resolveClientId();
const { clientId, clientSecret } = await this.resolveCredentials();
if (this.cache.config && this.cache.clientId === clientId) {
return; // Already initialized for this client ID
if (this.cache.config && this.cache.clientId === clientId && this.cache.clientSecret === (clientSecret ?? null)) {
return; // Already initialized for these credentials
}
if (this.cache.clientId && this.cache.clientId !== clientId) {
if (this.cache.clientId && (this.cache.clientId !== clientId || this.cache.clientSecret !== (clientSecret ?? null))) {
this.clearCache();
}
@ -163,18 +170,19 @@ export class GoogleClientFactory {
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
clientId
clientId,
clientSecret
);
} else {
// DCR mode - need existing registration
console.log(`[OAuth] Discovery mode: issuer with DCR`);
const clientRepo = container.resolve<IClientRegistrationRepo>('clientRegistrationRepo');
const existingRegistration = await clientRepo.getClientRegistration(this.PROVIDER_NAME);
if (!existingRegistration) {
throw new Error('Google client not registered. Please connect account first.');
}
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
existingRegistration.client_id
@ -185,28 +193,29 @@ export class GoogleClientFactory {
if (providerConfig.client.mode !== 'static') {
throw new Error('DCR requires discovery mode "issuer", not "static"');
}
console.log(`[OAuth] Using static endpoints (no discovery)`);
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
clientId,
providerConfig.discovery.revocationEndpoint
providerConfig.discovery.revocationEndpoint,
clientSecret
);
}
this.cache.clientId = clientId;
this.cache.clientSecret = clientSecret ?? null;
console.log(`[OAuth] Google OAuth configuration initialized`);
}
/**
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens, clientId: string): OAuth2Client {
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
private static createClientFromTokens(tokens: OAuthTokens, clientId: string, clientSecret?: string): OAuth2Client {
const client = new OAuth2Client(
clientId,
undefined, // client_secret not needed for PKCE
clientSecret ?? undefined,
undefined // redirect_uri not needed for token usage
);

View file

@ -24,7 +24,7 @@ const API_DELAY_MS = 1000; // 1 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
const MAX_BATCH_SIZE = 10; // Process max 10 documents per folder per sync
const LOOKBACK_DAYS = 30; // Only sync documents from the last 30 days
const LOOKBACK_DAYS = 7; // Only sync documents from the last 1 week
// --- Wake Signal for Immediate Sync Trigger ---
let wakeResolve: (() => void) | null = null;

View file

@ -9,7 +9,7 @@ export interface NoteTypeDefinition {
extractionGuide: string;
}
// ── Default definitions (used to seed ~/.rowboat/config/notes.json) ──────────
// ── Default definitions (used to seed WorkDir/config/notes.json) ─────────────
const DEFAULT_NOTE_TYPE_DEFINITIONS: NoteTypeDefinition[] = [
{

View file

@ -13,8 +13,8 @@ import { composioAccountsRepo } from '../composio/repo.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'calendar_sync');
const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes
const LOOKBACK_DAYS = 14;
const COMPOSIO_LOOKBACK_DAYS = 14;
const LOOKBACK_DAYS = 7;
const COMPOSIO_LOOKBACK_DAYS = 7;
const REQUIRED_SCOPES = [
'https://www.googleapis.com/auth/calendar.events.readonly',
'https://www.googleapis.com/auth/drive.readonly'

View file

@ -9,7 +9,7 @@ import { limitEventItems } from './limit_event_items.js';
const SYNC_DIR = path.join(WorkDir, 'knowledge', 'Meetings', 'fireflies');
const SYNC_INTERVAL_MS = 30 * 60 * 1000; // Check every 30 minutes (reduced from 1 minute)
const STATE_FILE = path.join(WorkDir, 'fireflies_sync_state.json');
const LOOKBACK_DAYS = 30; // Last 1 month
const LOOKBACK_DAYS = 7; // Last 1 week
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

View file

@ -410,7 +410,7 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
}
async function performSync() {
const LOOKBACK_DAYS = 30; // Default to 1 month
const LOOKBACK_DAYS = 7; // Default to 1 week
const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments');
const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json');
@ -444,7 +444,7 @@ async function performSync() {
// --- Composio-based Sync ---
const COMPOSIO_LOOKBACK_DAYS = 30;
const COMPOSIO_LOOKBACK_DAYS = 7;
interface ComposioSyncState {
last_sync: string; // ISO string

View file

@ -26,7 +26,7 @@ export interface TagDefinition {
noteEffect?: NoteEffect;
}
// ── Default definitions (used to seed ~/.rowboat/config/tags.json) ──────────
// ── Default definitions (used to seed WorkDir/config/tags.json) ─────────────
const DEFAULT_TAG_DEFINITIONS: TagDefinition[] = [
// ── Relationship — who is this from/about (all create) ────────────────

View file

@ -6,12 +6,66 @@ import { WorkDir } from "../config/config.js";
const CACHE_PATH = path.join(WorkDir, "config", "models.dev.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
/*
"claude-opus-4-6": {
"id": "claude-opus-4-6",
"name": "Claude Opus 4.6",
"family": "claude-opus",
"attachment": true,
"reasoning": true,
"tool_call": true,
"temperature": true,
"knowledge": "2025-05",
"release_date": "2026-02-05",
"last_updated": "2026-03-13",
"modalities": {
"input": [
"text",
"image",
"pdf"
],
"output": [
"text"
]
},
"open_weights": false,
"cost": {
"input": 5,
"output": 25,
"cache_read": 0.5,
"cache_write": 6.25
},
"limit": {
"context": 1000000,
"output": 128000
},
"experimental": {
"modes": {
"fast": {
"cost": {
"input": 30,
"output": 150,
"cache_read": 3,
"cache_write": 37.5
},
"provider": {
"body": {
"speed": "fast"
},
"headers": {
"anthropic-beta": "fast-mode-2026-02-01"
}
}
}
}
}
}
*/
const ModelsDevModel = z.object({
id: z.string().optional(),
name: z.string().optional(),
release_date: z.string().optional(),
tool_call: z.boolean().optional(),
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
}).passthrough();
@ -125,7 +179,6 @@ function pickProvider(
}
function isStableModel(model: z.infer<typeof ModelsDevModel>): boolean {
if (model.experimental) return false;
if (model.status && ["alpha", "beta", "deprecated"].includes(model.status)) return false;
return true;
}
@ -141,7 +194,6 @@ function normalizeModels(models: Record<string, z.infer<typeof ModelsDevModel>>)
name: model.name,
release_date: model.release_date,
tool_call: model.tool_call,
experimental: model.experimental,
status: model.status,
}))
.filter((model) => isStableModel(model) && supportsToolCall(model))

View file

@ -2,10 +2,9 @@ import * as fs from 'fs/promises';
import * as path from 'path';
import { isSignedIn } from '../account/account.js';
import { getAccessToken } from '../auth/tokens.js';
import { WorkDir } from '../config/config.js';
import { API_URL } from '../config/env.js';
const homedir = process.env.HOME || process.env.USERPROFILE || '';
export interface VoiceConfig {
deepgram: { apiKey: string } | null;
elevenlabs: { apiKey: string; voiceId?: string } | null;
@ -13,7 +12,7 @@ export interface VoiceConfig {
async function readJsonConfig(filename: string): Promise<Record<string, unknown> | null> {
try {
const configPath = path.join(homedir, '.rowboat', 'config', filename);
const configPath = path.join(WorkDir, 'config', filename);
const raw = await fs.readFile(configPath, 'utf8');
return JSON.parse(raw);
} catch {
@ -51,7 +50,7 @@ export async function synthesizeSpeech(text: string): Promise<{ audioBase64: str
console.log('[voice] synthesizing speech via Rowboat proxy, text length:', text.length, 'voiceId:', voiceId);
} else {
if (!config.elevenlabs) {
throw new Error('ElevenLabs not configured. Create ~/.rowboat/config/elevenlabs.json with { "apiKey": "<your-key>" }');
throw new Error(`ElevenLabs not configured. Create ${path.join(WorkDir, 'config', 'elevenlabs.json')} with { "apiKey": "<your-key>" }`);
}
const voiceId = config.elevenlabs.voiceId || 'UgBBYS2sOqTuMpoF3BR0';
url = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`;

View file

@ -10,7 +10,7 @@ export type WorkspaceChangeCallback = (event: z.infer<typeof WorkspaceChangeEven
/**
* Create a workspace watcher
* Watches ~/.rowboat recursively and emits change events via callback
* Watches the configured workspace root recursively and emits change events via callback
*
* Returns a watcher instance that can be closed.
* The watcher emits events immediately without debouncing.
@ -74,4 +74,3 @@ export async function createWorkspaceWatcher(
return watcher;
}

View file

@ -225,6 +225,7 @@ const ipcSchemas = {
req: z.object({
provider: z.string(),
clientId: z.string().optional(),
clientSecret: z.string().optional(),
}),
res: z.object({
success: z.boolean(),
@ -252,6 +253,7 @@ const ipcSchemas = {
connected: z.boolean(),
error: z.string().nullable().optional(),
userId: z.string().optional(),
clientId: z.string().nullable().optional(),
})),
}),
},

View file

@ -1,6 +1,6 @@
# Connecting Google to Rowboat
Rowboat requires a Google OAuth Client ID to connect to Gmail, Calendar, and Drive. Follow the steps below to generate your Client ID correctly.
Rowboat requires Google OAuth credentials (Client ID and Client Secret) to connect to Gmail, Calendar, and Drive. Follow the steps below to generate them.
---
@ -114,25 +114,43 @@ Click **Create Credentials → OAuth Client ID**
Select:
**Universal Windows Platform (UWP)**
**Web application**
- Name it anything (e.g. `Rowboat Desktop`)
- Store ID can be anything (e.g. `test` )
- Click **Create**
![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
Add the following redirect URI:
- `http://localhost:8080/oauth/callback`
Use this exactly: no trailing slash, port **8080**. This must match what the app uses for the OAuth callback.
Click **Create**.
---
## 7⃣ Copy the Client ID
## 7⃣ Copy the Client ID and Client Secret
After creation, Google will show:
- **Client ID**
- **Client Secret**
Copy the **Client ID** and paste it into Rowboat where prompted.
Copy **both values** and paste them into Rowboat when prompted.
![Copy Client ID](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/06-copy-client-id.png)
![Enter credentials in Rowboat](https://raw.githubusercontent.com/rowboatlabs/rowboat/main/apps/docs/docs/img/google-setup/07-enter-credentials.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). Ensure `http://localhost:8080/oauth/callback` is listed under **Authorized redirect URIs**.
3. **Client type**: Make sure you selected **Web application** as the application type. Other types (Desktop, UWP) may not provide a client secret or may handle redirect URIs differently.
---