Merge pull request #332 from rowboatlabs/dev

Dev
This commit is contained in:
Ramnique Singh 2026-02-04 14:46:04 +05:30 committed by GitHub
commit fd730f6eb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 5401 additions and 503 deletions

152
CLAUDE.md Normal file
View file

@ -0,0 +1,152 @@
# CLAUDE.md - AI Coding Agent Context
This file provides context for AI coding agents working on the Rowboat monorepo.
## Quick Reference Commands
```bash
# Electron App (apps/x)
cd apps/x && pnpm install # Install dependencies
cd apps/x && npm run deps # Build workspace packages (shared → core → preload)
cd apps/x && npm run dev # Development mode (builds deps, runs app)
cd apps/x && npm run lint # Lint check
cd apps/x/apps/main && npm run package # Production build (.app)
cd apps/x/apps/main && npm run make # Create DMG distributable
```
## Monorepo Structure
```
rowboat/
├── apps/
│ ├── x/ # Electron desktop app (focus of this doc)
│ ├── rowboat/ # Next.js web dashboard
│ ├── rowboatx/ # Next.js frontend
│ ├── cli/ # CLI tool
│ ├── python-sdk/ # Python SDK
│ └── docs/ # Documentation site
├── CLAUDE.md # This file
└── README.md # User-facing readme
```
## Electron App Architecture (`apps/x`)
The Electron app is a **nested pnpm workspace** with its own package management.
```
apps/x/
├── package.json # Workspace root, dev scripts
├── pnpm-workspace.yaml # Defines workspace packages
├── pnpm-lock.yaml # Lockfile
├── apps/
│ ├── main/ # Electron main process
│ │ ├── src/ # Main process source
│ │ ├── forge.config.cjs # Electron Forge config
│ │ └── bundle.mjs # esbuild bundler
│ ├── renderer/ # React UI (Vite)
│ │ ├── src/ # React components
│ │ └── vite.config.ts
│ └── preload/ # Electron preload scripts
│ └── src/
└── packages/
├── shared/ # @x/shared - Types, utilities, validators
└── core/ # @x/core - Business logic, AI, OAuth, MCP
```
### Build Order (Dependencies)
```
shared (no deps)
core (depends on shared)
preload (depends on shared)
renderer (depends on shared)
main (depends on shared, core)
```
**The `npm run deps` command builds:** shared → core → preload
### Key Entry Points
| Component | Entry | Output |
|-----------|-------|--------|
| main | `apps/main/src/main.ts` | `.package/dist/main.cjs` |
| renderer | `apps/renderer/src/main.tsx` | `apps/renderer/dist/` |
| preload | `apps/preload/src/preload.ts` | `apps/preload/dist/preload.js` |
## Build System
- **Package manager:** pnpm (required for `workspace:*` protocol)
- **Main bundler:** esbuild (bundles to single CommonJS file)
- **Renderer bundler:** Vite
- **Packaging:** Electron Forge
- **TypeScript:** ES2022 target
### Why esbuild bundling?
pnpm uses symlinks for workspace packages. Electron Forge's dependency walker can't follow these symlinks. esbuild bundles everything into a single file, eliminating the need for node_modules in the packaged app.
## Key Files Reference
| Purpose | File |
|---------|------|
| Electron main entry | `apps/x/apps/main/src/main.ts` |
| React app entry | `apps/x/apps/renderer/src/main.tsx` |
| Forge config (packaging) | `apps/x/apps/main/forge.config.cjs` |
| Main process bundler | `apps/x/apps/main/bundle.mjs` |
| Vite config | `apps/x/apps/renderer/vite.config.ts` |
| Shared types | `apps/x/packages/shared/src/` |
| Core business logic | `apps/x/packages/core/src/` |
| Workspace config | `apps/x/pnpm-workspace.yaml` |
| Root scripts | `apps/x/package.json` |
## Common Tasks
### LLM configuration (single provider)
- Config file: `~/.rowboat/config/models.json`
- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }`
- Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only)
### Add a new shared type
1. Edit `apps/x/packages/shared/src/`
2. Run `cd apps/x && npm run deps` to rebuild
### Modify main process
1. Edit `apps/x/apps/main/src/`
2. Restart dev server (main doesn't hot-reload)
### Modify renderer (React UI)
1. Edit `apps/x/apps/renderer/src/`
2. Changes hot-reload automatically in dev mode
### Add a new dependency to main
1. `cd apps/x/apps/main && pnpm add <package>`
2. Import in source - esbuild will bundle it
### Verify compilation
```bash
cd apps/x && npm run deps && npm run lint
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Desktop | Electron 39.x |
| UI | React 19, Vite 7 |
| Styling | TailwindCSS, Radix UI |
| State | React hooks |
| AI | Vercel AI SDK, OpenAI/Anthropic/Google/OpenRouter providers, Vercel AI Gateway, Ollama, models.dev catalog |
| IPC | Electron contextBridge |
| Build | TypeScript 5.9, esbuild, Electron Forge |
## Environment Variables (for packaging)
For production builds with code signing:
- `APPLE_ID` - Apple Developer ID
- `APPLE_PASSWORD` - App-specific password
- `APPLE_TEAM_ID` - Team ID
Not required for local development.

View file

@ -4,6 +4,16 @@ import { URL } from 'url';
const OAUTH_CALLBACK_PATH = '/oauth/callback';
const DEFAULT_PORT = 8080;
/** Escape HTML special characters to prevent XSS */
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export interface AuthServerResult {
server: Server;
port: number;
@ -15,7 +25,7 @@ export interface AuthServerResult {
*/
export function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (code: string, state: string) => void
onCallback: (code: string, state: string) => void | Promise<void>
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -46,7 +56,7 @@ export function createAuthServer(
</head>
<body>
<h1 class="error">Authorization Failed</h1>
<p>Error: ${error}</p>
<p>Error: ${escapeHtml(error)}</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
@ -55,48 +65,28 @@ export function createAuthServer(
return;
}
if (code && state) {
onCallback(code, state);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(400, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">Invalid Request</h1>
<p>Missing code or state parameter.</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
}
// 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(code || '', state || '');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.success { color: #2e7d32; }
</style>
</head>
<body>
<h1 class="success">Authorization Successful</h1>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>
`);
} else {
res.writeHead(404);
res.end('Not Found');

View file

@ -0,0 +1,296 @@
import { shell, BrowserWindow } from 'electron';
import { createAuthServer } from './auth-server.js';
import * as composioClient from '@x/core/dist/composio/client.js';
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
// Store active OAuth flows
const activeFlows = new Map<string, {
toolkitSlug: string;
connectedAccountId: string;
authConfigId: string;
}>();
/**
* Emit Composio connection event to all renderer windows
*/
export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('composio:didConnect', event);
}
}
}
/**
* Check if Composio is configured with an API key
*/
export function isConfigured(): { configured: boolean } {
return { configured: composioClient.isConfigured() };
}
/**
* Set the Composio API key
*/
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
try {
composioClient.setApiKey(apiKey);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set API key',
};
}
}
/**
* Initiate OAuth connection for a toolkit
*/
export async function initiateConnection(toolkitSlug: string): Promise<{
success: boolean;
redirectUrl?: string;
connectedAccountId?: string;
error?: string;
}> {
try {
console.log(`[Composio] Initiating connection for ${toolkitSlug}...`);
// Check if already connected
if (composioAccountsRepo.isConnected(toolkitSlug)) {
return { success: true };
}
// Get toolkit to check auth schemes
const toolkit = await composioClient.getToolkit(toolkitSlug);
// Check for managed OAuth2
if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
return {
success: false,
error: `Toolkit ${toolkitSlug} does not support managed OAuth2`,
};
}
// Find or create managed OAuth2 auth config
const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true);
let authConfigId: string;
const managedOauth2 = authConfigs.items.find(
cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed
);
if (managedOauth2) {
authConfigId = managedOauth2.id;
} else {
// Create new managed auth config
const created = await composioClient.createAuthConfig({
toolkit: { slug: toolkitSlug },
auth_config: {
type: 'use_composio_managed_auth',
name: `rowboat-${toolkitSlug}`,
},
});
authConfigId = created.auth_config.id;
}
// Create connected account with callback URL
const callbackUrl = REDIRECT_URI;
const response = await composioClient.createConnectedAccount({
auth_config: { id: authConfigId },
connection: {
user_id: 'rowboat-user',
callback_url: callbackUrl,
},
});
const connectedAccountId = response.id;
// Safely extract redirectUrl with type checking
const connectionVal = response.connectionData?.val;
const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal
? String((connectionVal as Record<string, unknown>).redirectUrl)
: undefined;
if (!redirectUrl) {
return {
success: false,
error: 'No redirect URL received from Composio',
};
}
// Store flow state
const flowKey = `${toolkitSlug}-${Date.now()}`;
activeFlows.set(flowKey, {
toolkitSlug,
connectedAccountId,
authConfigId,
});
// Save initial account state
const account: LocalConnectedAccount = {
id: connectedAccountId,
authConfigId,
status: 'INITIATED',
toolkitSlug,
createdAt: new Date().toISOString(),
lastUpdatedAt: new Date().toISOString(),
};
composioAccountsRepo.saveAccount(account);
// Set up callback server
let cleanupTimeout: NodeJS.Timeout;
const { server } = await createAuthServer(8081, async (_code, _state) => {
// OAuth callback received - sync the account status
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
if (accountStatus.status === 'ACTIVE') {
emitComposioEvent({ toolkitSlug, success: true });
} else {
emitComposioEvent({
toolkitSlug,
success: false,
error: `Connection status: ${accountStatus.status}`,
});
}
} catch (error) {
console.error('[Composio] Failed to sync account status:', error);
emitComposioEvent({
toolkitSlug,
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
});
} finally {
activeFlows.delete(flowKey);
server.close();
clearTimeout(cleanupTimeout);
}
});
// Timeout for abandoned flows (5 minutes)
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(flowKey)) {
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
activeFlows.delete(flowKey);
server.close();
emitComposioEvent({
toolkitSlug,
success: false,
error: 'OAuth flow timed out',
});
}
}, 5 * 60 * 1000);
// Open browser for OAuth
shell.openExternal(redirectUrl);
return {
success: true,
redirectUrl,
connectedAccountId,
};
} catch (error) {
console.error('[Composio] Connection initiation failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
/**
* Get connection status for a toolkit
*/
export async function getConnectionStatus(toolkitSlug: string): Promise<{
isConnected: boolean;
status?: string;
}> {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account) {
return { isConnected: false };
}
return {
isConnected: account.status === 'ACTIVE',
status: account.status,
};
}
/**
* Sync connection status with Composio API
*/
export async function syncConnection(
toolkitSlug: string,
connectedAccountId: string
): Promise<{ status: string }> {
try {
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
return { status: accountStatus.status };
} catch (error) {
console.error('[Composio] Failed to sync connection:', error);
return { status: 'FAILED' };
}
}
/**
* Disconnect a toolkit
*/
export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (account) {
// Delete from Composio
await composioClient.deleteConnectedAccount(account.id);
// Delete local record
composioAccountsRepo.deleteAccount(toolkitSlug);
}
return { success: true };
} catch (error) {
console.error('[Composio] Disconnect failed:', error);
// Still delete local record even if API call fails
composioAccountsRepo.deleteAccount(toolkitSlug);
return { success: true };
}
}
/**
* List connected toolkits
*/
export function listConnected(): { toolkits: string[] } {
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
}
/**
* Execute a Composio action
*/
export async function executeAction(
actionSlug: string,
toolkitSlug: string,
input: Record<string, unknown>
): Promise<{ success: boolean; data: unknown; error?: string }> {
try {
const account = composioAccountsRepo.getAccount(toolkitSlug);
if (!account || account.status !== 'ACTIVE') {
return {
success: false,
data: null,
error: `Toolkit ${toolkitSlug} is not connected`,
};
}
const result = await composioClient.executeAction(actionSlug, account.id, input);
return result;
} catch (error) {
console.error('[Composio] Action execution failed:', error);
return {
success: false,
data: null,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

View file

@ -15,11 +15,15 @@ import { bus } from '@x/core/dist/runs/bus.js';
import type { FSWatcher } from 'chokidar';
import fs from 'node:fs/promises';
import z from 'zod';
import { RunEvent } from 'packages/shared/dist/runs.js';
import { RunEvent } from '@x/shared/dist/runs.js';
import container from '@x/core/dist/di/container.js';
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
import { testModelConnection } from '@x/core/dist/models/models.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import * as composioHandler from './composio-handler.js';
type InvokeChannels = ipc.InvokeChannels;
type IPCChannels = ipc.IPCChannels;
@ -295,7 +299,7 @@ export function setupIpcHandlers() {
return { success: true };
},
'runs:stop': async (_event, args) => {
await runsCore.stop(args.runId);
await runsCore.stop(args.runId, args.force);
return { success: true };
},
'runs:fetch': async (_event, args) => {
@ -304,8 +308,19 @@ export function setupIpcHandlers() {
'runs:list': async (_event, args) => {
return runsCore.listRuns(args.cursor);
},
'models:list': async () => {
return await listOnboardingModels();
},
'models:test': async (_event, args) => {
return await testModelConnection(args.provider, args.model);
},
'models:saveConfig': async (_event, args) => {
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
await repo.setConfig(args);
return { success: true };
},
'oauth:connect': async (_event, args) => {
return await connectProvider(args.provider);
return await connectProvider(args.provider, args.clientId);
},
'oauth:disconnect': async (_event, args) => {
return await disconnectProvider(args.provider);
@ -344,5 +359,30 @@ export function setupIpcHandlers() {
markOnboardingComplete();
return { success: true };
},
// Composio integration handlers
'composio:is-configured': async () => {
return composioHandler.isConfigured();
},
'composio:set-api-key': async (_event, args) => {
return composioHandler.setApiKey(args.apiKey);
},
'composio:initiate-connection': async (_event, args) => {
return composioHandler.initiateConnection(args.toolkitSlug);
},
'composio:get-connection-status': async (_event, args) => {
return composioHandler.getConnectionStatus(args.toolkitSlug);
},
'composio:sync-connection': async (_event, args) => {
return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId);
},
'composio:disconnect': async (_event, args) => {
return composioHandler.disconnect(args.toolkitSlug);
},
'composio:list-connected': async () => {
return composioHandler.listConnected();
},
'composio:execute-action': async (_event, args) => {
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
},
});
}
}

View file

@ -10,6 +10,7 @@ import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
import { init as initPreBuiltRunner } from "@x/core/dist/pre_built/runner.js";
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -96,7 +97,7 @@ function createWindow() {
}
}
app.whenReady().then(() => {
app.whenReady().then(async () => {
// Register custom protocol before creating window (for production builds)
if (app.isPackaged) {
registerAppProtocol();
@ -113,6 +114,9 @@ app.whenReady().then(() => {
});
}
// Initialize all config files before UI can access them
await initConfigs();
setupIpcHandlers();
createWindow();

View file

@ -1,8 +1,15 @@
import { shell } from 'electron';
import type { Server } from 'http';
import { createAuthServer } from './auth-server.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
import {
clearProviderClientIdOverride,
getProviderClientIdOverride,
hasProviderClientIdOverride,
setProviderClientIdOverride,
} from '@x/core/dist/auth/provider-client-id.js';
import container from '@x/core/dist/di/container.js';
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
@ -14,12 +21,48 @@ import { emitOAuthEvent } from './ipc.js';
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
// Store active OAuth flows (state -> { codeVerifier, provider, config })
const activeFlows = new Map<string, {
codeVerifier: string;
const activeFlows = new Map<string, {
codeVerifier: string;
provider: string;
config: Configuration;
}>();
// Module-level state for tracking the active OAuth flow
interface ActiveOAuthFlow {
provider: string;
state: string;
server: Server;
cleanupTimeout: NodeJS.Timeout;
}
let activeFlow: ActiveOAuthFlow | null = null;
/**
* Cancel any active OAuth flow, cleaning up resources
*/
function cancelActiveFlow(reason: string = 'cancelled'): void {
if (!activeFlow) {
return;
}
console.log(`[OAuth] Cancelling active flow for ${activeFlow.provider}: ${reason}`);
clearTimeout(activeFlow.cleanupTimeout);
activeFlow.server.close();
activeFlows.delete(activeFlow.state);
// Only emit event for user-visible cancellations
if (reason !== 'new_flow_started') {
emitOAuthEvent({
provider: activeFlow.provider,
success: false,
error: `OAuth flow ${reason}`
});
}
activeFlow = null;
}
/**
* Get OAuth repository from DI container
*/
@ -39,14 +82,25 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
*/
async function getProviderConfiguration(provider: string): Promise<Configuration> {
const config = getProviderConfig(provider);
const resolveClientId = (): string => {
const override = getProviderClientIdOverride(provider);
if (override) {
return override;
}
if (config.client.mode === 'static' && config.client.clientId) {
return config.client.clientId;
}
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
};
if (config.discovery.mode === 'issuer') {
if (config.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
const clientId = resolveClientId();
return await oauthClient.discoverConfiguration(
config.discovery.issuer,
config.client.clientId
clientId
);
} else {
// DCR mode - check for existing registration or register new
@ -83,10 +137,11 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
}
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
const clientId = resolveClientId();
return oauthClient.createStaticConfiguration(
config.discovery.authorizationEndpoint,
config.discovery.tokenEndpoint,
config.client.clientId,
clientId,
config.discovery.revocationEndpoint
);
}
@ -95,12 +150,24 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
/**
* Initiate OAuth flow for a provider
*/
export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> {
export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> {
try {
console.log(`[OAuth] Starting connection flow for ${provider}...`);
// Cancel any existing flow before starting a new one
cancelActiveFlow('new_flow_started');
const oauthRepo = getOAuthRepo();
const providerConfig = getProviderConfig(provider);
if (provider === 'google') {
const trimmedClientId = clientId?.trim();
if (!trimmedClientId) {
return { success: false, error: 'Google client ID is required to connect.' };
}
setProviderClientIdOverride(provider, trimmedClientId);
}
// Get or create OAuth configuration
const config = await getProviderConfiguration(provider);
@ -122,9 +189,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool
state,
});
// Declare timeout variable (will be set after server is created)
let cleanupTimeout: NodeJS.Timeout;
// Create callback server
const { server } = await createAuthServer(8080, async (code, receivedState) => {
// Validate state
@ -140,7 +204,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool
try {
// Build callback URL for token exchange
const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`);
// Exchange code for tokens
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
const tokens = await oauthClient.exchangeCodeForTokens(
@ -172,21 +236,30 @@ export async function connectProvider(provider: string): Promise<{ success: bool
} finally {
// Clean up
activeFlows.delete(state);
server.close();
clearTimeout(cleanupTimeout);
if (activeFlow && activeFlow.state === state) {
clearTimeout(activeFlow.cleanupTimeout);
activeFlow.server.close();
activeFlow = null;
}
}
});
// Set timeout to clean up abandoned flows (5 minutes)
// Set timeout to clean up abandoned flows (2 minutes)
// This prevents memory leaks if user never completes the OAuth flow
cleanupTimeout = setTimeout(() => {
if (activeFlows.has(state)) {
const cleanupTimeout = setTimeout(() => {
if (activeFlow?.state === state) {
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
activeFlows.delete(state);
server.close();
emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' });
cancelActiveFlow('timed_out');
}
}, 5 * 60 * 1000); // 5 minutes
}, 2 * 60 * 1000); // 2 minutes
// Store complete flow state for cleanup
activeFlow = {
provider,
state,
server,
cleanupTimeout,
};
// Open in system browser (shares cookies/sessions with user's regular browser)
shell.openExternal(authUrl.toString());
@ -209,6 +282,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
try {
const oauthRepo = getOAuthRepo();
await oauthRepo.clearTokens(provider);
if (provider === 'google') {
clearProviderClientIdOverride(provider);
}
return { success: true };
} catch (error) {
console.error('OAuth disconnect failed:', error);
@ -222,6 +298,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
export async function isConnected(provider: string): Promise<{ isConnected: boolean }> {
try {
const oauthRepo = getOAuthRepo();
if (provider === 'google' && !hasProviderClientIdOverride(provider)) {
return { isConnected: false };
}
const connected = await oauthRepo.isConnected(provider);
return { isConnected: connected };
} catch (error) {
@ -278,7 +357,10 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }>
try {
const oauthRepo = getOAuthRepo();
const providers = await oauthRepo.getConnectedProviders();
return { providers };
const filteredProviders = providers.filter((provider) =>
provider === 'google' ? hasProviderClientIdOverride(provider) : true
);
return { providers: filteredProviders };
} catch (error) {
console.error('Get connected providers failed:', error);
return { providers: [] };

View file

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from 'electron';
import { contextBridge, ipcRenderer, webUtils } from 'electron';
import { ipc as ipcShared } from '@x/shared';
type InvokeChannels = ipcShared.InvokeChannels;
@ -51,4 +51,8 @@ const ipc = {
},
};
contextBridge.exposeInMainWorld('ipc', ipc);
contextBridge.exposeInMainWorld('ipc', ipc);
contextBridge.exposeInMainWorld('electronUtils', {
getPathForFile: (file: File) => webUtils.getPathForFile(file),
});

View file

@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
import './App.css'
import z from 'zod';
import { Button } from './components/ui/button';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react';
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { MarkdownEditor } from './components/markdown-editor';
import { ChatInputBar } from './components/chat-button';
@ -279,7 +279,9 @@ const collectFilePaths = (nodes: TreeNode[]): string[] =>
// Inner component that uses the controller to access mentions
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
@ -287,7 +289,9 @@ interface ChatInputInnerProps {
function ChatInputInner({
onSubmit,
onStop,
isProcessing,
isStopping,
presetMessage,
onPresetMessageConsumed,
runId,
@ -318,6 +322,37 @@ function ChatInputInner({
}
}, [handleSubmit])
useEffect(() => {
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault()
}
}
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes("Files")) {
e.preventDefault()
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
const paths = Array.from(e.dataTransfer.files)
.map((f) => window.electronUtils?.getPathForFile(f))
.filter(Boolean)
if (paths.length > 0) {
const currentText = controller.textInput.value
const pathText = paths.join(' ')
controller.textInput.setInput(
currentText ? `${currentText} ${pathText}` : pathText
)
}
}
}
document.addEventListener("dragover", onDragOver)
document.addEventListener("drop", onDrop)
return () => {
document.removeEventListener("dragover", onDragOver)
document.removeEventListener("drop", onDrop)
}
}, [controller])
return (
<div className="flex items-center gap-2 bg-background border border-border rounded-3xl shadow-xl px-4 py-2.5">
<PromptInputTextarea
@ -327,19 +362,39 @@ function ChatInputInner({
focusTrigger={runId}
className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none"
/>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? "Click again to force stop" : "Stop generation"}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
isStopping
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
)
}
@ -350,7 +405,9 @@ interface ChatInputWithMentionsProps {
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
presetMessage?: string
onPresetMessageConsumed?: () => void
runId?: string | null
@ -361,7 +418,9 @@ function ChatInputWithMentions({
recentFiles,
visibleFiles,
onSubmit,
onStop,
isProcessing,
isStopping,
presetMessage,
onPresetMessageConsumed,
runId,
@ -370,7 +429,9 @@ function ChatInputWithMentions({
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
<ChatInputInner
onSubmit={onSubmit}
onStop={onStop}
isProcessing={isProcessing}
isStopping={isStopping}
presetMessage={presetMessage}
onPresetMessageConsumed={onPresetMessageConsumed}
runId={runId}
@ -414,6 +475,8 @@ function App() {
const [runId, setRunId] = useState<string | null>(null)
const runIdRef = useRef<string | null>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [isStopping, setIsStopping] = useState(false)
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
const [agentId] = useState<string>('copilot')
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
@ -758,6 +821,8 @@ function App() {
case 'run-processing-end':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
break
case 'start':
@ -936,8 +1001,32 @@ function App() {
break
}
case 'run-stopped':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
// Clear pending requests since they've been aborted
setPendingPermissionRequests(new Map())
setPendingAskHumanRequests(new Map())
// Flush any streaming content as a message
setCurrentAssistantMessage(currentMsg => {
if (currentMsg) {
setConversation(prev => [...prev, {
id: `assistant-stopped-${Date.now()}`,
role: 'assistant',
content: currentMsg,
timestamp: Date.now(),
}])
}
return ''
})
setCurrentReasoning('')
break
case 'error':
setIsProcessing(false)
setIsStopping(false)
setStopClickedAt(null)
console.error('Run error:', event.error)
break
}
@ -1009,6 +1098,21 @@ function App() {
}
}
const handleStop = useCallback(async () => {
if (!runId) return
const now = Date.now()
const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000
setStopClickedAt(now)
setIsStopping(true)
try {
await window.ipc.invoke('runs:stop', { runId, force: isForce })
} catch (error) {
console.error('Failed to stop run:', error)
}
}, [runId, isStopping, stopClickedAt])
const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => {
if (!runId) return
@ -1337,6 +1441,29 @@ function App() {
},
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
// Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
// Refresh the tree to show the new file/folder
const newTree = await loadDirectory()
setTree(newTree)
// Expand parent directories to show the file
const parts = notePath.split('/')
const parentPaths: string[] = []
for (let i = 1; i < parts.length; i++) {
parentPaths.push(parts.slice(0, i).join('/'))
}
setExpandedPaths(prev => {
const newSet = new Set(prev)
parentPaths.forEach(p => newSet.add(p))
return newSet
})
// Select the file to show it in the editor
setIsGraphOpen(false)
setSelectedPath(notePath)
}, [loadDirectory])
const ensureWikiFile = useCallback(async (wikiPath: string) => {
const resolvedPath = toKnowledgePath(wikiPath)
if (!resolvedPath) return null
@ -1562,7 +1689,7 @@ function App() {
return (
<TooltipProvider delayDuration={0}>
<SidebarSectionProvider defaultSection="knowledge" onSectionChange={handleSectionChange}>
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
<div className="flex h-svh w-full">
{/* Icon sidebar - always visible, fixed position */}
<SidebarIcon />
@ -1583,6 +1710,7 @@ function App() {
expandedPaths={expandedPaths}
onSelectFile={toggleExpand}
knowledgeActions={knowledgeActions}
onVoiceNoteCreated={handleVoiceNoteCreated}
runs={runs}
currentRunId={runId}
tasksActions={{
@ -1779,7 +1907,9 @@ function App() {
recentFiles={recentWikiFiles}
visibleFiles={visibleKnowledgeFiles}
onSubmit={handlePromptSubmit}
onStop={handleStop}
isProcessing={isProcessing}
isStopping={isStopping}
presetMessage={presetMessage}
onPresetMessageConsumed={() => setPresetMessage(undefined)}
runId={runId}
@ -1801,6 +1931,8 @@ function App() {
currentAssistantMessage={currentAssistantMessage}
currentReasoning={currentReasoning}
isProcessing={isProcessing}
isStopping={isStopping}
onStop={handleStop}
message={message}
onMessageChange={setMessage}
onSubmit={handlePromptSubmit}

View file

@ -13,7 +13,6 @@ export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ArrowUp, Expand, Plus } from 'lucide-react'
import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react'
import type { ToolUIPart } from 'ai'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
@ -115,6 +115,8 @@ interface ChatSidebarProps {
currentAssistantMessage: string
currentReasoning: string
isProcessing: boolean
isStopping?: boolean
onStop?: () => void
message: string
onMessageChange: (message: string) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
@ -139,6 +141,8 @@ export function ChatSidebar({
currentAssistantMessage,
currentReasoning,
isProcessing,
isStopping,
onStop,
message,
onMessageChange,
onSubmit,
@ -595,19 +599,39 @@ export function ChatSidebar({
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
</div>
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
{isProcessing ? (
<Button
size="icon"
onClick={onStop}
title={isStopping ? "Click again to force stop" : "Stop generation"}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
isStopping
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
: "bg-primary text-primary-foreground hover:bg-primary/90"
)}
>
{isStopping ? (
<LoaderIcon className="h-4 w-4 animate-spin" />
) : (
<Square className="h-3 w-3 fill-current" />
)}
</Button>
) : (
<Button
size="icon"
onClick={handleSubmit}
disabled={!canSubmit}
className={cn(
"h-7 w-7 rounded-full shrink-0 transition-all",
canSubmit
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground"
)}
>
<ArrowUp className="h-4 w-4" />
</Button>
)}
</div>
{knowledgeFiles.length > 0 && (
<MentionPopover

View file

@ -0,0 +1,94 @@
"use client"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface ComposioApiKeyModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (apiKey: string) => void
isSubmitting?: boolean
}
export function ComposioApiKeyModal({
open,
onOpenChange,
onSubmit,
isSubmitting = false,
}: ComposioApiKeyModalProps) {
const [apiKey, setApiKey] = useState("")
useEffect(() => {
if (!open) {
setApiKey("")
}
}, [open])
const trimmedApiKey = apiKey.trim()
const isValid = trimmedApiKey.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedApiKey)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Composio API Key</DialogTitle>
<DialogDescription>
Get your API key from{" "}
<a
href="https://app.composio.dev/settings"
target="_blank"
rel="noopener noreferrer"
className="text-primary underline underline-offset-2"
>
app.composio.dev/settings
</a>
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="composio-api-key">
API Key
</label>
<Input
id="composio-api-key"
type="password"
placeholder="Enter your Composio API key"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail } from "lucide-react"
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
import {
Popover,
@ -17,6 +17,9 @@ import {
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Separator } from "@/components/ui/separator"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
interface ProviderState {
@ -35,11 +38,18 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
// Load available providers on mount
useEffect(() => {
async function loadProviders() {
@ -86,11 +96,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Connect to Slack via Composio
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Handle Slack connect button click
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
// Disconnect from Slack
const handleDisconnectSlack = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
if (result.success) {
setSlackConnected(false)
toast.success('Disconnected from Slack')
} else {
toast.error('Failed to disconnect from Slack')
}
} catch (error) {
console.error('Failed to disconnect from Slack:', error)
toast.error('Failed to disconnect from Slack')
} finally {
setSlackLoading(false)
}
}, [])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -117,7 +205,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when popover opens or providers list changes
useEffect(() => {
@ -161,15 +249,34 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
return cleanup
}, [refreshAllStatuses])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
if (result.success) {
// OAuth flow started - keep isConnecting state, wait for event
@ -192,6 +299,27 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
}, [])
// 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)
return
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
}, [startConnect])
// Disconnect from a provider
const handleDisconnect = useCallback(async (provider: string) => {
setProviderStates(prev => ({
@ -203,6 +331,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
const result = await window.ipc.invoke('oauth:disconnect', { provider })
if (result.success) {
if (provider === 'google') {
clearGoogleClientId()
}
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
toast.success(`Disconnected from ${displayName}`)
setProviderStates(prev => ({
@ -289,6 +420,13 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
}
return (
<>
<GoogleClientIdModal
open={googleClientIdOpen}
onOpenChange={setGoogleClientIdOpen}
onSubmit={handleGoogleClientIdSubmit}
isSubmitting={providerStates.google?.isConnecting ?? false}
/>
<Popover open={open} onOpenChange={setOpen}>
{tooltip ? (
<Tooltip open={open ? false : undefined}>
@ -368,10 +506,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
{/* Fireflies */}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
<Separator className="my-2" />
{/* Team Communication Section - Slack */}
<div className="px-2 py-1.5">
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
</div>
{/* Slack */}
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-4" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<Button
variant="outline"
size="sm"
onClick={handleDisconnectSlack}
className="h-7 px-2 text-xs"
>
Disconnect
</Button>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
className="h-7 px-2 text-xs"
>
{slackConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
</>
)
}

View file

@ -0,0 +1,85 @@
"use client"
import { useEffect, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
interface GoogleClientIdModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (clientId: string) => void
isSubmitting?: boolean
}
export function GoogleClientIdModal({
open,
onOpenChange,
onSubmit,
isSubmitting = false,
}: GoogleClientIdModalProps) {
const [clientId, setClientId] = useState("")
useEffect(() => {
if (!open) {
setClientId("")
}
}, [open])
const trimmedClientId = clientId.trim()
const isValid = trimmedClientId.length > 0
const handleSubmit = () => {
if (!isValid || isSubmitting) return
onSubmit(trimmedClientId)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enter Google Client ID</DialogTitle>
<DialogDescription>
This app does not store the client ID. You will be prompted each session.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
Client ID
</label>
<Input
id="google-client-id"
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
value={clientId}
onChange={(event) => setClientId(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault()
handleSubmit()
}
}}
autoFocus
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
Continue
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback } from "react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react"
import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react"
import {
Dialog,
@ -13,7 +13,18 @@ import {
} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Switch } from "@/components/ui/switch"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { cn } from "@/lib/utils"
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
import { toast } from "sonner"
interface ProviderState {
@ -27,20 +38,75 @@ interface OnboardingModalProps {
onComplete: () => void
}
type Step = 0 | 1 | 2
type Step = 0 | 1 | 2 | 3
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
interface LlmModelOption {
id: string
name?: string
release_date?: string
}
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState<Step>(0)
// LLM setup state
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState<string | null>(null)
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
openai: { apiKey: "", baseURL: "", model: "" },
anthropic: { apiKey: "", baseURL: "", model: "" },
google: { apiKey: "", baseURL: "", model: "" },
openrouter: { apiKey: "", baseURL: "", model: "" },
aigateway: { apiKey: "", baseURL: "", model: "" },
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
})
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
status: "idle",
})
const [savingLlmConfig, setSavingLlmConfig] = useState(false)
// OAuth provider states
const [providers, setProviders] = useState<string[]>([])
const [providersLoading, setProvidersLoading] = useState(true)
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
// Granola state
const [granolaEnabled, setGranolaEnabled] = useState(false)
const [granolaLoading, setGranolaLoading] = useState(true)
// Composio/Slack state
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
const [slackConnected, setSlackConnected] = useState(false)
const [slackLoading, setSlackLoading] = useState(true)
const [slackConnecting, setSlackConnecting] = useState(false)
const updateProviderConfig = useCallback(
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
setProviderConfigs(prev => ({
...prev,
[provider]: { ...prev[provider], ...updates },
}))
setTestState({ status: "idle" })
},
[]
)
const activeConfig = providerConfigs[llmProvider]
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
const canTest =
activeConfig.model.trim().length > 0 &&
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
// Track connected providers for the completion step
const connectedProviders = Object.entries(providerStates)
.filter(([, state]) => state.isConnected)
@ -65,6 +131,48 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
loadProviders()
}, [open])
// Load LLM models catalog on open
useEffect(() => {
if (!open) return
async function loadModels() {
try {
setModelsLoading(true)
setModelsError(null)
const result = await window.ipc.invoke("models:list", null)
const catalog: Record<string, LlmModelOption[]> = {}
for (const provider of result.providers || []) {
catalog[provider.id] = provider.models || []
}
setModelsCatalog(catalog)
} catch (error) {
console.error("Failed to load models catalog:", error)
setModelsError("Failed to load models list")
setModelsCatalog({})
} finally {
setModelsLoading(false)
}
}
loadModels()
}, [open])
// Initialize default models from catalog
useEffect(() => {
if (Object.keys(modelsCatalog).length === 0) return
setProviderConfigs(prev => {
const next = { ...prev }
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
for (const provider of cloudProviders) {
const models = modelsCatalog[provider]
if (models?.length && !next[provider].model) {
next[provider] = { ...next[provider], model: models[0]?.id || "" }
}
}
return next
})
}, [modelsCatalog])
// Load Granola config
const refreshGranolaConfig = useCallback(async () => {
try {
@ -94,11 +202,133 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
}, [])
// Load Slack connection status
const refreshSlackStatus = useCallback(async () => {
try {
setSlackLoading(true)
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
setSlackConnected(result.isConnected)
} catch (error) {
console.error('Failed to load Slack status:', error)
setSlackConnected(false)
} finally {
setSlackLoading(false)
}
}, [])
// Start Slack connection
const startSlackConnect = useCallback(async () => {
try {
setSlackConnecting(true)
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
if (!result.success) {
toast.error(result.error || 'Failed to connect to Slack')
setSlackConnecting(false)
}
// Success will be handled by composio:didConnect event
} catch (error) {
console.error('Failed to connect to Slack:', error)
toast.error('Failed to connect to Slack')
setSlackConnecting(false)
}
}, [])
// Connect to Slack via Composio (checks if configured first)
const handleConnectSlack = useCallback(async () => {
// Check if Composio is configured
const configResult = await window.ipc.invoke('composio:is-configured', null)
if (!configResult.configured) {
setComposioApiKeyOpen(true)
return
}
await startSlackConnect()
}, [startSlackConnect])
// Handle Composio API key submission
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
try {
await window.ipc.invoke('composio:set-api-key', { apiKey })
setComposioApiKeyOpen(false)
toast.success('Composio API key saved')
// Now start the Slack connection
await startSlackConnect()
} catch (error) {
console.error('Failed to save Composio API key:', error)
toast.error('Failed to save API key')
}
}, [startSlackConnect])
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep((prev) => (prev + 1) as Step)
}
}
const handleComplete = () => {
onComplete()
}
const handleTestConnection = useCallback(async () => {
if (!canTest) return
setTestState({ status: "testing" })
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
const result = await window.ipc.invoke("models:test", {
provider: {
flavor: llmProvider,
apiKey,
baseURL,
},
model,
})
if (result.success) {
setTestState({ status: "success" })
toast.success("Connection successful")
} else {
setTestState({ status: "error", error: result.error })
toast.error(result.error || "Connection test failed")
}
} catch (error) {
console.error("Connection test failed:", error)
setTestState({ status: "error", error: "Connection test failed" })
toast.error("Connection test failed")
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
const handleSaveLlmConfig = useCallback(async () => {
if (testState.status !== "success") return
setSavingLlmConfig(true)
try {
const apiKey = activeConfig.apiKey.trim() || undefined
const baseURL = activeConfig.baseURL.trim() || undefined
const model = activeConfig.model.trim()
await window.ipc.invoke("models:saveConfig", {
provider: {
flavor: llmProvider,
apiKey,
baseURL,
},
model,
})
setSavingLlmConfig(false)
handleNext()
} catch (error) {
console.error("Failed to save LLM config:", error)
toast.error("Failed to save LLM settings")
setSavingLlmConfig(false)
}
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, handleNext, llmProvider, testState.status])
// Check connection status for all providers
const refreshAllStatuses = useCallback(async () => {
// Refresh Granola
refreshGranolaConfig()
// Refresh Slack status
refreshSlackStatus()
// Refresh OAuth providers
if (providers.length === 0) return
@ -125,7 +355,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
)
setProviderStates(newStates)
}, [providers, refreshGranolaConfig])
}, [providers, refreshGranolaConfig, refreshSlackStatus])
// Refresh statuses when modal opens or providers list changes
useEffect(() => {
@ -159,15 +389,34 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
return cleanup
}, [])
// Connect to a provider
const handleConnect = useCallback(async (provider: string) => {
// Listen for Composio connection events
useEffect(() => {
const cleanup = window.ipc.on('composio:didConnect', (event) => {
const { toolkitSlug, success, error } = event
if (toolkitSlug === 'slack') {
setSlackConnected(success)
setSlackConnecting(false)
if (success) {
toast.success('Connected to Slack')
} else {
toast.error(error || 'Failed to connect to Slack')
}
}
})
return cleanup
}, [])
const startConnect = useCallback(async (provider: string, clientId?: string) => {
setProviderStates(prev => ({
...prev,
[provider]: { ...prev[provider], isConnecting: true }
}))
try {
const result = await window.ipc.invoke('oauth:connect', { provider })
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
if (!result.success) {
toast.error(result.error || `Failed to connect to ${provider}`)
@ -186,20 +435,31 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
}, [])
const handleNext = () => {
if (currentStep < 2) {
setCurrentStep((prev) => (prev + 1) as Step)
// 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)
return
}
}
const handleComplete = () => {
onComplete()
}
await startConnect(provider)
}, [startConnect])
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
setGoogleClientId(clientId)
setGoogleClientIdOpen(false)
startConnect('google', clientId)
}, [startConnect])
// Step indicator component
const StepIndicator = () => (
<div className="flex gap-2 justify-center mb-6">
{[0, 1, 2].map((step) => (
{[0, 1, 2, 3].map((step) => (
<div
key={step}
className={cn(
@ -291,6 +551,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// Render Slack row
const renderSlackRow = () => (
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
<div className="flex items-center gap-3 min-w-0">
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
<MessageSquare className="size-5" />
</div>
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate">Slack</span>
{slackLoading ? (
<span className="text-xs text-muted-foreground">Checking...</span>
) : (
<span className="text-xs text-muted-foreground truncate">
Send messages and view channels
</span>
)}
</div>
</div>
<div className="shrink-0">
{slackLoading ? (
<Loader2 className="size-4 animate-spin text-muted-foreground" />
) : slackConnected ? (
<div className="flex items-center gap-1.5 text-sm text-green-600">
<CheckCircle2 className="size-4" />
<span>Connected</span>
</div>
) : (
<Button
variant="default"
size="sm"
onClick={handleConnectSlack}
disabled={slackConnecting}
>
{slackConnecting ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Connect"
)}
</Button>
)}
</div>
</div>
)
// Step 0: Welcome
const WelcomeStep = () => (
<div className="flex flex-col items-center text-center">
@ -323,7 +627,156 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// Step 1: Connect Accounts
// Step 1: LLM Setup
const LlmSetupStep = () => {
const providerOptions: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
{ id: "openai", name: "OpenAI", description: "Use your OpenAI API key" },
{ id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" },
{ id: "google", name: "Google", description: "Use your Google AI Studio key" },
{ id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" },
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" },
{ id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" },
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" },
]
const modelsForProvider = modelsCatalog[llmProvider] || []
const showModelInput = isLocalProvider || modelsForProvider.length === 0
return (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
<DialogTitle className="text-2xl">Choose your model</DialogTitle>
<DialogDescription className="text-base">
Select your provider and model to power Rowboats AI.
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
<div className="grid gap-2 sm:grid-cols-2">
{providerOptions.map((provider) => (
<button
key={provider.id}
onClick={() => {
setLlmProvider(provider.id)
setTestState({ status: "idle" })
}}
className={cn(
"rounded-md border px-3 py-3 text-left transition-colors",
llmProvider === provider.id
? "border-primary bg-primary/5"
: "border-border hover:bg-accent"
)}
>
<div className="text-sm font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground mt-1">{provider.description}</div>
</button>
))}
</div>
</div>
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
{modelsLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading models...
</div>
) : showModelInput ? (
<Input
value={activeConfig.model}
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
placeholder="Enter model ID"
/>
) : (
<Select
value={activeConfig.model}
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{modelsForProvider.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.name || model.id}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{modelsError && (
<div className="text-xs text-destructive">{modelsError}</div>
)}
</div>
{requiresApiKey && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API Key</span>
<Input
type="password"
value={activeConfig.apiKey}
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
placeholder="Paste your API key"
/>
</div>
)}
{showBaseURL && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
<Input
value={activeConfig.baseURL}
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
placeholder={
llmProvider === "ollama"
? "http://localhost:11434"
: llmProvider === "openai-compatible"
? "http://localhost:1234/v1"
: "https://ai-gateway.vercel.sh/v1"
}
/>
</div>
)}
</div>
<div className="mt-6 flex items-center gap-3">
<Button
variant="default"
onClick={handleTestConnection}
disabled={!canTest || testState.status === "testing"}
>
{testState.status === "testing" ? (
<Loader2 className="size-4 animate-spin" />
) : (
"Test connection"
)}
</Button>
{testState.status === "success" && (
<span className="text-sm text-green-600">Connected</span>
)}
{testState.status === "error" && (
<span className="text-sm text-destructive">
{testState.error || "Test failed"}
</span>
)}
</div>
<div className="flex flex-col gap-3 mt-8">
<Button
onClick={handleSaveLlmConfig}
size="lg"
disabled={testState.status !== "success" || savingLlmConfig}
>
{savingLlmConfig ? <Loader2 className="size-4 animate-spin" /> : "Continue"}
</Button>
</div>
</div>
)
}
// Step 2: Connect Accounts
const AccountConnectionStep = () => (
<div className="flex flex-col">
<DialogHeader className="text-center mb-6">
@ -358,6 +811,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
{renderGranolaRow()}
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
</div>
{/* Team Communication Section */}
<div className="space-y-2">
<div className="px-3">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
</div>
{renderSlackRow()}
</div>
</>
)}
</div>
@ -373,9 +834,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
</div>
)
// Step 2: Completion
// Step 3: Completion
const CompletionStep = () => {
const hasConnections = connectedProviders.length > 0 || granolaEnabled
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
return (
<div className="flex flex-col items-center text-center">
@ -416,6 +877,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
<span>Granola (Local meeting notes)</span>
</div>
)}
{slackConnected && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="size-4 text-green-600" />
<span>Slack (Team communication)</span>
</div>
)}
</div>
</div>
</div>
@ -429,6 +896,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
}
return (
<>
<GoogleClientIdModal
open={googleClientIdOpen}
onOpenChange={setGoogleClientIdOpen}
onSubmit={handleGoogleClientIdSubmit}
isSubmitting={providerStates.google?.isConnecting ?? false}
/>
<ComposioApiKeyModal
open={composioApiKeyOpen}
onOpenChange={setComposioApiKeyOpen}
onSubmit={handleComposioApiKeySubmit}
isSubmitting={slackConnecting}
/>
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
@ -438,9 +918,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
>
<StepIndicator />
{currentStep === 0 && <WelcomeStep />}
{currentStep === 1 && <AccountConnectionStep />}
{currentStep === 2 && <CompletionStep />}
{currentStep === 1 && <LlmSetupStep />}
{currentStep === 2 && <AccountConnectionStep />}
{currentStep === 3 && <CompletionStep />}
</DialogContent>
</Dialog>
</>
)
}

View file

@ -12,8 +12,10 @@ import {
Folder,
FolderPlus,
MessageSquare,
Mic,
Network,
Pencil,
Square,
SquarePen,
Trash2,
} from "lucide-react"
@ -88,6 +90,7 @@ type SidebarContentPanelProps = {
expandedPaths: Set<string>
onSelectFile: (path: string, kind: "file" | "dir") => void
knowledgeActions: KnowledgeActions
onVoiceNoteCreated?: (path: string) => void
runs?: RunListItem[]
currentRunId?: string | null
tasksActions?: TasksActions
@ -95,7 +98,7 @@ type SidebarContentPanelProps = {
const sectionTitles = {
knowledge: "Knowledge",
tasks: "Tasks",
tasks: "Chats",
}
export function SidebarContentPanel({
@ -104,6 +107,7 @@ export function SidebarContentPanel({
expandedPaths,
onSelectFile,
knowledgeActions,
onVoiceNoteCreated,
runs = [],
currentRunId,
tasksActions,
@ -126,6 +130,7 @@ export function SidebarContentPanel({
expandedPaths={expandedPaths}
onSelectFile={onSelectFile}
actions={knowledgeActions}
onVoiceNoteCreated={onVoiceNoteCreated}
/>
)}
{activeSection === "tasks" && (
@ -141,6 +146,227 @@ export function SidebarContentPanel({
)
}
async function transcribeWithDeepgram(audioBlob: Blob): Promise<string | null> {
try {
const configResult = await window.ipc.invoke('workspace:readFile', {
path: 'config/deepgram.json',
encoding: 'utf8',
})
const { apiKey } = JSON.parse(configResult.data) as { apiKey: string }
if (!apiKey) throw new Error('No apiKey in deepgram.json')
const response = await fetch(
'https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true',
{
method: 'POST',
headers: {
Authorization: `Token ${apiKey}`,
'Content-Type': audioBlob.type,
},
body: audioBlob,
},
)
if (!response.ok) throw new Error(`Deepgram API error: ${response.status}`)
const result = await response.json()
return result.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null
} catch (err) {
console.error('Deepgram transcription failed:', err)
return null
}
}
// Voice Note Recording Button
function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) {
const [isRecording, setIsRecording] = React.useState(false)
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
const chunksRef = React.useRef<Blob[]>([])
const notePathRef = React.useRef<string | null>(null)
const timestampRef = React.useRef<string | null>(null)
const relativePathRef = React.useRef<string | null>(null)
const startRecording = async () => {
try {
// Generate timestamp and paths immediately
const now = new Date()
const timestamp = now.toISOString().replace(/[:.]/g, '-')
const dateStr = now.toISOString().split('T')[0] // YYYY-MM-DD
const noteName = `voice-memo-${timestamp}`
const notePath = `knowledge/Voice Memos/${dateStr}/${noteName}.md`
timestampRef.current = timestamp
notePathRef.current = notePath
// Relative path for linking (from knowledge/ root, without .md extension)
const relativePath = `Voice Memos/${dateStr}/${noteName}`
relativePathRef.current = relativePath
// Create the note immediately with a "Recording..." placeholder
await window.ipc.invoke('workspace:mkdir', {
path: `knowledge/Voice Memos/${dateStr}`,
recursive: true,
})
const initialContent = `# Voice Memo
**Type:** voice memo
**Recorded:** ${now.toLocaleString()}
**Path:** ${relativePath}
## Transcript
*Recording in progress...*
`
await window.ipc.invoke('workspace:writeFile', {
path: notePath,
data: initialContent,
opts: { encoding: 'utf8' },
})
// Select the note so the user can see it
onNoteCreated?.(notePath)
// Start actual recording
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const mimeType = MediaRecorder.isTypeSupported('audio/mp4')
? 'audio/mp4'
: 'audio/webm'
const recorder = new MediaRecorder(stream, { mimeType })
chunksRef.current = []
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data)
}
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop())
const blob = new Blob(chunksRef.current, { type: mimeType })
const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm'
const audioFilename = `voice-memo-${timestampRef.current}.${ext}`
// Save audio file to voice_memos folder (for backup/reference)
try {
await window.ipc.invoke('workspace:mkdir', {
path: 'voice_memos',
recursive: true,
})
const arrayBuffer = await blob.arrayBuffer()
const base64 = btoa(
new Uint8Array(arrayBuffer).reduce(
(data, byte) => data + String.fromCharCode(byte),
'',
),
)
await window.ipc.invoke('workspace:writeFile', {
path: `voice_memos/${audioFilename}`,
data: base64,
opts: { encoding: 'base64' },
})
} catch {
console.error('Failed to save audio file')
}
// Update note to show transcribing status
const currentNotePath = notePathRef.current
const currentRelativePath = relativePathRef.current
if (currentNotePath && currentRelativePath) {
const transcribingContent = `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
## Transcript
*Transcribing...*
`
await window.ipc.invoke('workspace:writeFile', {
path: currentNotePath,
data: transcribingContent,
opts: { encoding: 'utf8' },
})
}
// Transcribe and update the note with the transcript
const transcript = await transcribeWithDeepgram(blob)
if (currentNotePath && currentRelativePath) {
const finalContent = transcript
? `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
## Transcript
${transcript}
`
: `# Voice Memo
**Type:** voice memo
**Recorded:** ${new Date().toLocaleString()}
**Path:** ${currentRelativePath}
## Transcript
*Transcription failed. Please try again.*
`
await window.ipc.invoke('workspace:writeFile', {
path: currentNotePath,
data: finalContent,
opts: { encoding: 'utf8' },
})
// Re-select to trigger refresh
onNoteCreated?.(currentNotePath)
if (transcript) {
toast('Voice note transcribed', 'success')
} else {
toast('Transcription failed', 'error')
}
}
}
recorder.start()
mediaRecorderRef.current = recorder
setIsRecording(true)
toast('Recording started', 'success')
} catch {
toast('Could not access microphone', 'error')
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
setIsRecording(false)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={isRecording ? stopRecording : startRecording}
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
>
{isRecording ? (
<Square className="size-4 fill-red-500 text-red-500 animate-pulse" />
) : (
<Mic className="size-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{isRecording ? 'Stop Recording' : 'New Voice Note'}
</TooltipContent>
</Tooltip>
)
}
// Knowledge Section
function KnowledgeSection({
tree,
@ -148,15 +374,17 @@ function KnowledgeSection({
expandedPaths,
onSelectFile,
actions,
onVoiceNoteCreated,
}: {
tree: TreeNode[]
selectedPath: string | null
expandedPaths: Set<string>
onSelectFile: (path: string, kind: "file" | "dir") => void
actions: KnowledgeActions
onVoiceNoteCreated?: (path: string) => void
}) {
const isExpanded = expandedPaths.size > 0
const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
@ -181,6 +409,7 @@ function KnowledgeSection({
<TooltipContent side="bottom">{action.label}</TooltipContent>
</Tooltip>
))}
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
<Tooltip>
<TooltipTrigger asChild>
<button

View file

@ -4,7 +4,7 @@ import * as React from "react"
import {
Brain,
HelpCircle,
ListTodo,
MessageSquare,
Plug,
Settings,
} from "lucide-react"
@ -27,8 +27,8 @@ type NavItem = {
}
const navItems: NavItem[] = [
{ id: "tasks", title: "Chats", icon: MessageSquare },
{ id: "knowledge", title: "Knowledge", icon: Brain },
{ id: "tasks", title: "Tasks", icon: ListTodo },
]
export function SidebarIcon() {

View file

@ -20,7 +20,7 @@ export function useSidebarSection() {
}
export function SidebarSectionProvider({
defaultSection = "knowledge",
defaultSection = "tasks",
onSectionChange,
children,
}: {

View file

@ -33,6 +33,9 @@ declare global {
handler: (event: IPCChannels[K]['req']) => void
): () => void;
};
electronUtils: {
getPathForFile: (file: File) => string;
};
}
}

View file

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

View file

@ -0,0 +1,17 @@
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

@ -12,11 +12,14 @@
"@ai-sdk/anthropic": "^2.0.44",
"@ai-sdk/google": "^2.0.25",
"@ai-sdk/openai": "^2.0.53",
"@composio/core": "^0.6.0",
"@ai-sdk/openai-compatible": "^1.0.27",
"@ai-sdk/provider": "^2.0.0",
"@google-cloud/local-auth": "^3.0.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@openrouter/ai-sdk-provider": "^1.2.6",
"@react-pdf/renderer": "^4.3.2",
"@types/react": "^19.2.7",
"@x/shared": "workspace:*",
"ai": "^5.0.102",
"awilix": "^12.0.5",
@ -27,6 +30,7 @@
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"openid-client": "^6.8.1",
"react": "^19.2.3",
"yaml": "^2.8.2",
"zod": "^4.2.1"
},

View file

@ -15,13 +15,14 @@ import { CopilotAgent } from "../application/assistant/agent.js";
import { isBlocked } from "../application/lib/command-executor.js";
import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js";
import { getProvider } from "../models/models.js";
import { createProvider } from "../models/models.js";
import { IAgentsRepo } from "./repo.js";
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
import { IBus } from "../application/lib/bus.js";
import { IMessageQueue } from "../application/lib/message-queue.js";
import { IRunsRepo } from "../runs/repo.js";
import { IRunsLock } from "../runs/lock.js";
import { IAbortRegistry } from "../runs/abort-registry.js";
import { PrefixLogger } from "@x/shared";
import { parse } from "yaml";
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
@ -39,6 +40,7 @@ export class AgentRuntime implements IAgentRuntime {
private messageQueue: IMessageQueue;
private modelConfigRepo: IModelConfigRepo;
private runsLock: IRunsLock;
private abortRegistry: IAbortRegistry;
constructor({
runsRepo,
@ -47,6 +49,7 @@ export class AgentRuntime implements IAgentRuntime {
messageQueue,
modelConfigRepo,
runsLock,
abortRegistry,
}: {
runsRepo: IRunsRepo;
idGenerator: IMonotonicallyIncreasingIdGenerator;
@ -54,6 +57,7 @@ export class AgentRuntime implements IAgentRuntime {
messageQueue: IMessageQueue;
modelConfigRepo: IModelConfigRepo;
runsLock: IRunsLock;
abortRegistry: IAbortRegistry;
}) {
this.runsRepo = runsRepo;
this.idGenerator = idGenerator;
@ -61,6 +65,7 @@ export class AgentRuntime implements IAgentRuntime {
this.messageQueue = messageQueue;
this.modelConfigRepo = modelConfigRepo;
this.runsLock = runsLock;
this.abortRegistry = abortRegistry;
}
async trigger(runId: string): Promise<void> {
@ -68,6 +73,7 @@ export class AgentRuntime implements IAgentRuntime {
console.log(`unable to acquire lock on run ${runId}`);
return;
}
const signal = this.abortRegistry.createForRun(runId);
try {
await this.bus.publish({
runId,
@ -75,6 +81,11 @@ export class AgentRuntime implements IAgentRuntime {
subflow: [],
});
while (true) {
// Check for abort before each iteration
if (signal.aborted) {
break;
}
let eventCount = 0;
const run = await this.runsRepo.fetch(runId);
if (!run) {
@ -84,18 +95,28 @@ export class AgentRuntime implements IAgentRuntime {
for (const event of run.log) {
state.ingest(event);
}
for await (const event of streamAgent({
state,
idGenerator: this.idGenerator,
runId,
messageQueue: this.messageQueue,
modelConfigRepo: this.modelConfigRepo,
})) {
eventCount++;
if (event.type !== "llm-stream-event") {
await this.runsRepo.appendEvents(runId, [event]);
try {
for await (const event of streamAgent({
state,
idGenerator: this.idGenerator,
runId,
messageQueue: this.messageQueue,
modelConfigRepo: this.modelConfigRepo,
signal,
abortRegistry: this.abortRegistry,
})) {
eventCount++;
if (event.type !== "llm-stream-event") {
await this.runsRepo.appendEvents(runId, [event]);
}
await this.bus.publish(event);
}
await this.bus.publish(event);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// Abort detected — exit cleanly
break;
}
throw error;
}
// if no events, break
@ -103,7 +124,20 @@ export class AgentRuntime implements IAgentRuntime {
break;
}
}
// Emit run-stopped event if aborted
if (signal.aborted) {
const stoppedEvent: z.infer<typeof RunEvent> = {
runId,
type: "run-stopped",
reason: "user-requested",
subflow: [],
};
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
await this.bus.publish(stoppedEvent);
}
} finally {
this.abortRegistry.cleanup(runId);
await this.runsLock.release(runId);
await this.bus.publish({
runId,
@ -428,6 +462,39 @@ export class AgentState {
return response;
}
/**
* Returns tool-result messages for all pending tool calls, marking them as aborted.
* This is called when a run is stopped so the LLM knows what happened to its tool requests.
*/
getAbortedToolResults(): z.infer<typeof ToolMessage>[] {
const results: z.infer<typeof ToolMessage>[] = [];
for (const toolCallId of Object.keys(this.pendingToolCalls)) {
const toolCall = this.toolCallIdMap[toolCallId];
if (toolCall) {
results.push({
role: "tool",
content: JSON.stringify({ error: "Tool execution aborted" }),
toolCallId,
toolName: toolCall.toolName,
});
}
}
return results;
}
/**
* Clear all pending state (permissions, ask-human, tool calls).
* Used when a run is stopped.
*/
clearAllPending(): void {
this.pendingToolPermissionRequests = {};
this.pendingAskHumanRequests = {};
// Recursively clear subflows
for (const subflow of Object.values(this.subflowStates)) {
subflow.clearAllPending();
}
}
finalResponse(): string {
if (!this.lastAssistantMsg) {
return '';
@ -526,12 +593,16 @@ export async function* streamAgent({
runId,
messageQueue,
modelConfigRepo,
signal,
abortRegistry,
}: {
state: AgentState,
idGenerator: IMonotonicallyIncreasingIdGenerator;
runId: string;
messageQueue: IMessageQueue;
modelConfigRepo: IModelConfigRepo;
signal: AbortSignal;
abortRegistry: IAbortRegistry;
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
@ -552,11 +623,14 @@ export async function* streamAgent({
const tools = await buildTools(agent);
// set up provider + model
const provider = await getProvider(agent.provider);
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
const provider = createProvider(modelConfig.provider);
const model = provider.languageModel(modelConfig.model);
let loopCounter = 0;
while (true) {
// Check abort at the top of each iteration
signal.throwIfAborted();
loopCounter++;
const loopLogger = logger.child(`iter-${loopCounter}`);
loopLogger.log('starting loop iteration');
@ -598,6 +672,11 @@ export async function* streamAgent({
}
// execute approved tool
// Check abort before starting tool execution
if (signal.aborted) {
_logger.log('skipping, reason: aborted');
break;
}
_logger.log('executing tool');
yield* processEvent({
runId,
@ -616,6 +695,8 @@ export async function* streamAgent({
runId,
messageQueue,
modelConfigRepo,
signal,
abortRegistry,
})) {
yield* processEvent({
...event,
@ -626,7 +707,7 @@ export async function* streamAgent({
result = subflowState.finalResponse();
}
} else {
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments);
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
}
const resultPayload = result === undefined ? null : result;
const resultMsg: z.infer<typeof ToolMessage> = {
@ -709,6 +790,7 @@ export async function* streamAgent({
state.messages,
instructionsWithDateTime,
tools,
signal,
)) {
// Only log significant events (not text-delta to reduce noise)
if (event.type !== 'text-delta') {
@ -791,6 +873,7 @@ async function* streamLlm(
messages: z.infer<typeof MessageList>,
instructions: string,
tools: ToolSet,
signal?: AbortSignal,
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
const { fullStream } = streamText({
model,
@ -798,8 +881,11 @@ async function* streamLlm(
system: instructions,
tools,
stopWhen: stepCountIs(1),
abortSignal: signal,
});
for await (const event of fullStream) {
// Check abort on every chunk for responsiveness
signal?.throwIfAborted();
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
switch (event.type) {
case "reasoning-start":

View file

@ -26,8 +26,12 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
## Memory That Compounds
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
@ -148,29 +152,31 @@ When a user asks for ANY task that might require external capabilities (web sear
- NEVER ask what OS the user is on - they are on macOS.
- Load the \`organize-files\` skill for guidance on file organization tasks.
**Command Approval:**
- Approved shell commands are listed in \`~/.rowboat/config/security.json\`. Read this file to see what commands are allowed.
- Only use commands from the approved list. Commands not in the list will be blocked.
- If you cannot accomplish a task with the approved commands, tell the user which command you need and ask them to add it to \`security.json\`.
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries:
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval:
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management
- \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
- \`loadSkill\` - Skill loading
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`.
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, 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.
- 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?").
**CRITICAL: MCP Server Configuration**
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
- NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers
- Invalid MCP configs will prevent the agent from starting with validation errors
**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools.`;
Rowboat's internal builtin tools never require approval only shell commands via \`executeCommand\` do.`;

View file

@ -0,0 +1,83 @@
export const skill = String.raw`
# PDF Presentation Generator Skill
## When to Use
Activate when the user wants to create presentations, slide decks, or pitch decks.
## Workflow
1. Check ~/.rowboat/knowledge/ for relevant context about the company, product, team, etc.
2. Ensure Playwright is installed: 'npm install playwright && npx playwright install chromium'
3. Create an HTML file (e.g., /tmp/presentation.html) with slides (1280x720px each)
4. Create a Node.js script to convert HTML to PDF:
~~~javascript
// save as /tmp/convert.js
const { chromium } = require('playwright');
const path = require('path');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' });
await page.pdf({
path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'),
width: '1280px',
height: '720px',
printBackground: true,
});
await browser.close();
console.log('Done: ~/Desktop/presentation.pdf');
})();
~~~
5. Run it: 'node /tmp/convert.js'
6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf"
Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it.
## PDF Export Rules
**These rules prevent rendering issues in PDF. Violating them causes overlapping rectangles and broken layouts.**
1. **No layered elements** - Never create separate elements for backgrounds or shadows. Style content elements directly.
2. **No box-shadow** - Use borders instead: \`border: 1px solid #e5e7eb\`
3. **Bullets via CSS only** - Use \`li::before\` pseudo-elements, not separate DOM elements
4. **Content must fit** - Slides are 1280x720px with 60px padding. Safe area is 1160x600px. Use \`overflow: hidden\`.
## Required CSS
~~~css
@page { size: 1280px 720px; margin: 0; }
html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
.slide {
width: 1280px;
height: 720px;
padding: 60px;
overflow: hidden;
page-break-after: always;
page-break-inside: avoid;
}
.slide:last-child { page-break-after: auto; }
~~~
## Playwright Export
~~~typescript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' });
await page.pdf({
path: '~/Desktop/presentation.pdf',
width: '1280px',
height: '720px',
printBackground: true,
});
await browser.close();
~~~
`;
export default skill;

View file

@ -7,17 +7,17 @@ import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import slackSkill from "./slack/skill.js";
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
const CURRENT_FILE = fileURLToPath(import.meta.url);
const CURRENT_DIR = path.dirname(CURRENT_FILE);
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
type SkillDefinition = {
id: string;
id: string; // Also used as folder name
title: string;
folder: string;
summary: string;
content: string;
};
@ -29,66 +29,69 @@ type ResolvedSkill = {
};
const definitions: SkillDefinition[] = [
{
id: "create-presentations",
title: "Create Presentations",
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
content: createPresentationsSkill,
},
{
id: "doc-collab",
title: "Document Collaboration",
folder: "doc-collab",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
content: docCollabSkill,
},
{
id: "draft-emails",
title: "Draft Emails",
folder: "draft-emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
folder: "meeting-prep",
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
{
id: "organize-files",
title: "Organize Files",
folder: "organize-files",
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "slack",
title: "Slack Integration",
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
content: slackSkill,
},
{
id: "workflow-authoring",
title: "Workflow Authoring",
folder: "workflow-authoring",
summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.",
content: workflowAuthoringSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
folder: "builtin-tools",
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
content: builtinToolsSkill,
},
{
id: "mcp-integration",
title: "MCP Integration Guidance",
folder: "mcp-integration",
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
folder: "deletion-guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
},
{
id: "workflow-run-ops",
title: "Workflow Run Operations",
folder: "workflow-run-ops",
summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.",
content: workflowRunOpsSkill,
},
@ -96,7 +99,7 @@ const definitions: SkillDefinition[] = [
const skillEntries = definitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`,
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
}));
const catalogSections = skillEntries.map((entry) => [
@ -146,8 +149,8 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
};
for (const entry of skillEntries) {
const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js");
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
const resolvedEntry: ResolvedSkill = {
id: entry.id,
catalogPath: entry.catalogPath,
@ -156,14 +159,13 @@ for (const entry of skillEntries) {
const baseAliases = [
entry.id,
entry.folder,
`${entry.folder}/skill`,
`${entry.folder}/skill.ts`,
`${entry.folder}/skill.js`,
`skills/${entry.folder}/skill.ts`,
`skills/${entry.folder}/skill.js`,
`${CATALOG_PREFIX}/${entry.folder}/skill.ts`,
`${CATALOG_PREFIX}/${entry.folder}/skill.js`,
`${entry.id}/skill`,
`${entry.id}/skill.ts`,
`${entry.id}/skill.js`,
`skills/${entry.id}/skill.ts`,
`skills/${entry.id}/skill.js`,
`${CATALOG_PREFIX}/${entry.id}/skill.ts`,
`${CATALOG_PREFIX}/${entry.id}/skill.js`,
absoluteTs,
absoluteJs,
];

View file

@ -0,0 +1,121 @@
import { slackToolCatalogMarkdown } from "./tool-catalog.js";
const skill = String.raw`
# Slack Integration Skill
You can interact with Slack to help users communicate with their team. This includes sending messages, viewing channel history, finding users, and searching conversations.
## Prerequisites
Before using Slack tools, ALWAYS check if Slack is connected:
\`\`\`
slack-checkConnection({})
\`\`\`
If not connected, inform the user they need to connect Slack from the settings/onboarding.
## Available Tools
### Check Connection
\`\`\`
slack-checkConnection({})
\`\`\`
Returns whether Slack is connected and ready to use.
### List Users
\`\`\`
slack-listUsers({ limit: 100 })
\`\`\`
Lists users in the workspace. Use this to resolve a name to a user ID.
### List DM Conversations
\`\`\`
slack-getDirectMessages({ limit: 50 })
\`\`\`
Lists DM channels (type "im"). Each entry includes the DM channel ID and the user ID.
### List Channels
\`\`\`
slack-listChannels({ types: "public_channel,private_channel", limit: 100 })
\`\`\`
Lists channels the user has access to.
### Get Conversation History
\`\`\`
slack-getChannelHistory({ channel: "C01234567", limit: 20 })
\`\`\`
Fetches recent messages for a channel or DM.
### Search Messages
\`\`\`
slack-searchMessages({ query: "in:@username", count: 20 })
\`\`\`
Searches Slack messages using Slack search syntax.
### Send a Message
\`\`\`
slack-sendMessage({ channel: "C01234567", text: "Hello team!" })
\`\`\`
Sends a message to a channel or DM. Always show the draft first.
### Execute a Slack Action
\`\`\`
slack-executeAction({
toolSlug: "EXACT_TOOL_SLUG_FROM_DISCOVERY",
input: { /* tool-specific parameters */ }
})
\`\`\`
Executes any Slack tool using its exact slug discovered from \`slack-listAvailableTools\`.
### Discover Available Tools (Fallback)
\`\`\`
slack-listAvailableTools({ search: "conversation" })
\`\`\`
Lists available Slack tools from Composio. Use this only if a builtin Slack tool fails and you need a specific slug.
## Composio Slack Tool Catalog (Pinned)
Use the exact tool slugs below with \`slack-executeAction\` when needed. Prefer these over \`slack-listAvailableTools\` to avoid redundant discovery.
${slackToolCatalogMarkdown}
## Workflow
### Step 1: Check Connection
\`\`\`
slack-checkConnection({})
\`\`\`
### Step 2: Choose the Builtin Tool
Use the builtin Slack tools above for common tasks. Only fall back to \`slack-listAvailableTools\` + \`slack-executeAction\` if something is missing.
## Common Tasks
### Find the Most Recent DM with Someone
1. Search messages first: \`slack-searchMessages({ query: "in:@Name", count: 1 })\`
2. If you need exact DM history:
- \`slack-listUsers({})\` to find the user ID
- \`slack-getDirectMessages({})\` to find the DM channel for that user
- \`slack-getChannelHistory({ channel: "D...", limit: 20 })\`
### Send a Message
1. Draft the message and show it to the user
2. ONLY after user approval, send using \`slack-sendMessage\`
### Search Messages
1. Use \`slack-searchMessages({ query: "...", count: 20 })\`
## Best Practices
- **Always show drafts before sending** - Never send Slack messages without user confirmation
- **Summarize, don't dump** - When showing channel history, summarize the key points
- **Cross-reference with knowledge base** - Check if mentioned people have notes in the knowledge base
## Error Handling
If a Slack operation fails:
1. Try \`slack-listAvailableTools\` to verify the tool slug is correct
2. Check if Slack is still connected with \`slack-checkConnection\`
3. Inform the user of the specific error
`;
export default skill;

View file

@ -0,0 +1,117 @@
export type SlackToolDefinition = {
name: string;
slug: string;
description: string;
};
export const slackToolCatalog: SlackToolDefinition[] = [
{ name: "Add Emoji Alias", slug: "SLACK_ADD_AN_EMOJI_ALIAS_IN_SLACK", description: "Adds an alias for an existing custom emoji." },
{ name: "Add Remote File", slug: "SLACK_ADD_A_REMOTE_FILE_FROM_A_SERVICE", description: "Adds a reference to an external file (e.g., GDrive, Dropbox) to Slack." },
{ name: "Add Star to Item", slug: "SLACK_ADD_A_STAR_TO_AN_ITEM", description: "Stars a channel, file, comment, or message." },
{ name: "Add Call Participants", slug: "SLACK_ADD_CALL_PARTICIPANTS", description: "Registers new participants added to a Slack call." },
{ name: "Add Emoji", slug: "SLACK_ADD_EMOJI", description: "Adds a custom emoji to a workspace via a unique name and URL." },
{ name: "Add Reaction", slug: "SLACK_ADD_REACTION_TO_AN_ITEM", description: "Adds a specified emoji reaction to a message." },
{ name: "Archive Channel", slug: "SLACK_ARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Archives a public or private channel." },
{ name: "Archive Conversation", slug: "SLACK_ARCHIVE_A_SLACK_CONVERSATION", description: "Archives a conversation by its ID." },
{ name: "Close DM/MPDM", slug: "SLACK_CLOSE_DM_OR_MULTI_PERSON_DM", description: "Closes a DM or MPDM sidebar view for the user." },
{ name: "Create Reminder", slug: "SLACK_CREATE_A_REMINDER", description: "Creates a reminder with text and time (natural language supported)." },
{ name: "Create User Group", slug: "SLACK_CREATE_A_SLACK_USER_GROUP", description: "Creates a new user group (subteam)." },
{ name: "Create Channel", slug: "SLACK_CREATE_CHANNEL", description: "Initiates a public or private channel conversation." },
{ name: "Create Channel Conversation", slug: "SLACK_CREATE_CHANNEL_BASED_CONVERSATION", description: "Creates a new channel with specific org-wide or team settings." },
{ name: "Customize URL Unfurl", slug: "SLACK_CUSTOMIZE_URL_UNFURL", description: "Defines custom content for URL previews in a specific message." },
{ name: "Delete File Comment", slug: "SLACK_DELETE_A_COMMENT_ON_A_FILE", description: "Deletes a specific comment from a file." },
{ name: "Delete File", slug: "SLACK_DELETE_A_FILE_BY_ID", description: "Permanently deletes a file by its ID." },
{ name: "Delete Channel", slug: "SLACK_DELETE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Irreversibly deletes a channel and its history (Enterprise only)." },
{ name: "Delete Scheduled Message", slug: "SLACK_DELETE_A_SCHEDULED_MESSAGE_IN_A_CHAT", description: "Deletes a pending scheduled message." },
{ name: "Delete Reminder", slug: "SLACK_DELETE_A_SLACK_REMINDER", description: "Deletes an existing reminder." },
{ name: "Delete Message", slug: "SLACK_DELETES_A_MESSAGE_FROM_A_CHAT", description: "Deletes a message by channel ID and timestamp." },
{ name: "Delete Profile Photo", slug: "SLACK_DELETE_USER_PROFILE_PHOTO", description: "Reverts the user's profile photo to the default avatar." },
{ name: "Disable User Group", slug: "SLACK_DISABLE_AN_EXISTING_SLACK_USER_GROUP", description: "Disables (archives) a user group." },
{ name: "Enable User Group", slug: "SLACK_ENABLE_A_SPECIFIED_USER_GROUP", description: "Reactivates a disabled user group." },
{ name: "Share File Publicly", slug: "SLACK_ENABLE_PUBLIC_SHARING_OF_A_FILE", description: "Generates a public URL for a file." },
{ name: "End Call", slug: "SLACK_END_A_CALL_WITH_DURATION_AND_ID", description: "Ends an ongoing call." },
{ name: "End Snooze", slug: "SLACK_END_SNOOZE", description: "Ends the current user's snooze mode immediately." },
{ name: "End DND Session", slug: "SLACK_END_USER_DO_NOT_DISTURB_SESSION", description: "Ends the current DND session." },
{ name: "Fetch Bot Info", slug: "SLACK_FETCH_BOT_USER_INFORMATION", description: "Fetches metadata for a specific bot user." },
{ name: "Fetch History", slug: "SLACK_FETCH_CONVERSATION_HISTORY", description: "Fetches chronological messages and events from a channel." },
{ name: "Fetch Item Reactions", slug: "SLACK_FETCH_ITEM_REACTIONS", description: "Fetches all reactions for a message, file, or comment." },
{ name: "Retrieve Replies", slug: "SLACK_FETCH_MESSAGE_THREAD_FROM_A_CONVERSATION", description: "Retrieves replies to a specific parent message." },
{ name: "Fetch Team Info", slug: "SLACK_FETCH_TEAM_INFO", description: "Fetches comprehensive metadata about the team." },
{ name: "Fetch Workspace Settings", slug: "SLACK_FETCH_WORKSPACE_SETTINGS_INFORMATION", description: "Retrieves detailed settings for a specific workspace." },
{ name: "Find Channels", slug: "SLACK_FIND_CHANNELS", description: "Searches channels by name, topic, or purpose." },
{ name: "Find User by Email", slug: "SLACK_FIND_USER_BY_EMAIL_ADDRESS", description: "Finds a user object using their email address." },
{ name: "Find Users", slug: "SLACK_FIND_USERS", description: "Searches users by name, email, or display name." },
{ name: "Get Conversation Preferences", slug: "SLACK_GET_CHANNEL_CONVERSATION_PREFERENCES", description: "Retrieves posting/threading preferences for a channel." },
{ name: "Get Reminder Info", slug: "SLACK_GET_REMINDER_INFORMATION", description: "Retrieves detailed information for a specific reminder." },
{ name: "Get Remote File", slug: "SLACK_GET_REMOTE_FILE", description: "Retrieves info about a previously added remote file." },
{ name: "Get Team DND Status", slug: "SLACK_GET_TEAM_DND_STATUS", description: "Retrieves the DND status for specific users." },
{ name: "Get User Presence", slug: "SLACK_GET_USER_PRESENCE_INFO", description: "Retrieves real-time presence (active/away)." },
{ name: "Invite to Channel", slug: "SLACK_INVITE_USERS_TO_A_SLACK_CHANNEL", description: "Invites users to a channel by their user IDs." },
{ name: "Invite to Workspace", slug: "SLACK_INVITE_USER_TO_WORKSPACE", description: "Invites a user to a workspace and channels via email." },
{ name: "Join Conversation", slug: "SLACK_JOIN_AN_EXISTING_CONVERSATION", description: "Joins a conversation by channel ID." },
{ name: "Leave Conversation", slug: "SLACK_LEAVE_A_CONVERSATION", description: "Leaves a conversation." },
{ name: "List All Channels", slug: "SLACK_LIST_ALL_CHANNELS", description: "Lists all conversations with various filters." },
{ name: "List All Users", slug: "SLACK_LIST_ALL_USERS", description: "Retrieves a paginated list of all users in the workspace." },
{ name: "List User Group Members", slug: "SLACK_LIST_ALL_USERS_IN_A_USER_GROUP", description: "Lists all user IDs within a group." },
{ name: "List Conversations", slug: "SLACK_LIST_CONVERSATIONS", description: "Retrieves conversations accessible to a specific user." },
{ name: "List Files", slug: "SLACK_LIST_FILES_WITH_FILTERS_IN_SLACK", description: "Lists files and metadata with filtering options." },
{ name: "List Reminders", slug: "SLACK_LIST_REMINDERS", description: "Lists all reminders for the authenticated user." },
{ name: "List Remote Files", slug: "SLACK_LIST_REMOTE_FILES", description: "Retrieves info about a team's remote files." },
{ name: "List Scheduled Messages", slug: "SLACK_LIST_SCHEDULED_MESSAGES", description: "Lists pending scheduled messages." },
{ name: "List Pinned Items", slug: "SLACK_LISTS_PINNED_ITEMS_IN_A_CHANNEL", description: "Retrieves all messages/files pinned to a channel." },
{ name: "List Starred Items", slug: "SLACK_LIST_STARRED_ITEMS", description: "Lists items starred by the user." },
{ name: "List Custom Emojis", slug: "SLACK_LIST_TEAM_CUSTOM_EMOJIS", description: "Lists all workspace custom emojis and their URLs." },
{ name: "List User Groups", slug: "SLACK_LIST_USER_GROUPS_FOR_TEAM_WITH_OPTIONS", description: "Lists user-created and default user groups." },
{ name: "List User Reactions", slug: "SLACK_LIST_USER_REACTIONS", description: "Lists all reactions added by a specific user." },
{ name: "List Admin Users", slug: "SLACK_LIST_WORKSPACE_USERS", description: "Retrieves a paginated list of workspace administrators." },
{ name: "Set User Presence", slug: "SLACK_MANUALLY_SET_USER_PRESENCE", description: "Manually overrides automated presence status." },
{ name: "Mark Reminder Complete", slug: "SLACK_MARK_REMINDER_AS_COMPLETE", description: "Marks a reminder as complete (deprecated by Slack in March 2023)." },
{ name: "Open DM", slug: "SLACK_OPEN_DM", description: "Opens/resumes a DM or MPDM." },
{ name: "Pin Item", slug: "SLACK_PINS_AN_ITEM_TO_A_CHANNEL", description: "Pins a message to a channel." },
{ name: "Remove Remote File", slug: "SLACK_REMOVE_A_REMOTE_FILE", description: "Removes a reference to an external file." },
{ name: "Remove Star", slug: "SLACK_REMOVE_A_STAR_FROM_AN_ITEM", description: "Unstars an item." },
{ name: "Remove from Channel", slug: "SLACK_REMOVE_A_USER_FROM_A_CONVERSATION", description: "Removes a specified user from a conversation." },
{ name: "Remove Call Participants", slug: "SLACK_REMOVE_CALL_PARTICIPANTS", description: "Registers the removal of participants from a call." },
{ name: "Remove Reaction", slug: "SLACK_REMOVE_REACTION_FROM_ITEM", description: "Removes an emoji reaction from an item." },
{ name: "Rename Conversation", slug: "SLACK_RENAME_A_CONVERSATION", description: "Renames a channel ID/Conversation." },
{ name: "Rename Emoji", slug: "SLACK_RENAME_AN_EMOJI", description: "Renames a custom emoji." },
{ name: "Rename Channel", slug: "SLACK_RENAME_A_SLACK_CHANNEL", description: "Renames a public or private channel." },
{ name: "Retrieve Identity", slug: "SLACK_RETRIEVE_A_USER_S_IDENTITY_DETAILS", description: "Retrieves basic user/team identity details." },
{ name: "Retrieve Call Info", slug: "SLACK_RETRIEVE_CALL_INFORMATION", description: "Retrieves a snapshot of a call's status." },
{ name: "Retrieve Conversation Info", slug: "SLACK_RETRIEVE_CONVERSATION_INFORMATION", description: "Retrieves metadata for a specific conversation." },
{ name: "Get Conversation Members", slug: "SLACK_RETRIEVE_CONVERSATION_MEMBERS_LIST", description: "Lists active user IDs in a conversation." },
{ name: "Retrieve User DND", slug: "SLACK_RETRIEVE_CURRENT_USER_DND_STATUS", description: "Retrieves DND status for a user." },
{ name: "Retrieve File Details", slug: "SLACK_RETRIEVE_DETAILED_INFORMATION_ABOUT_A_FILE", description: "Retrieves metadata and comments for a file." },
{ name: "Retrieve User Details", slug: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION", description: "Retrieves comprehensive info for a specific user ID." },
{ name: "Get Message Permalink", slug: "SLACK_RETRIEVE_MESSAGE_PERMALINK_URL", description: "Gets the permalink URL for a specific message." },
{ name: "Retrieve Team Profile", slug: "SLACK_RETRIEVE_TEAM_PROFILE_DETAILS", description: "Retrieves the profile field structure for a team." },
{ name: "Retrieve User Profile", slug: "SLACK_RETRIEVE_USER_PROFILE_INFORMATION", description: "Retrieves specific profile info for a user." },
{ name: "Revoke Public File", slug: "SLACK_REVOKE_PUBLIC_SHARING_ACCESS_FOR_A_FILE", description: "Revokes a file's public sharing URL." },
{ name: "Schedule Message", slug: "SLACK_SCHEDULE_MESSAGE", description: "Schedules a message for a future time (up to 120 days)." },
{ name: "Search Messages", slug: "SLACK_SEARCH_MESSAGES", description: "Workspace-wide message search with advanced filters." },
{ name: "Send Ephemeral", slug: "SLACK_SEND_EPHEMERAL_MESSAGE", description: "Sends a message visible only to a specific user." },
{ name: "Send Message", slug: "SLACK_SEND_MESSAGE", description: "Posts a message to a channel, DM, or group." },
{ name: "Set Conversation Purpose", slug: "SLACK_SET_A_CONVERSATION_S_PURPOSE", description: "Updates the purpose description of a channel." },
{ name: "Set DND Duration", slug: "SLACK_SET_DND_DURATION", description: "Turns on DND or changes its current duration." },
{ name: "Set Profile Photo", slug: "SLACK_SET_PROFILE_PHOTO", description: "Sets the user's profile image with cropping." },
{ name: "Set Read Cursor", slug: "SLACK_SET_READ_CURSOR_IN_A_CONVERSATION", description: "Marks a specific timestamp as read." },
{ name: "Set User Profile", slug: "SLACK_SET_SLACK_USER_PROFILE_INFORMATION", description: "Updates individual or multiple user profile fields." },
{ name: "Set Conversation Topic", slug: "SLACK_SET_THE_TOPIC_OF_A_CONVERSATION", description: "Updates the topic of a conversation." },
{ name: "Share Me Message", slug: "SLACK_SHARE_A_ME_MESSAGE_IN_A_CHANNEL", description: "Sends a third-person user action message (/me)." },
{ name: "Share Remote File", slug: "SLACK_SHARE_REMOTE_FILE_IN_CHANNELS", description: "Shares a registered remote file into channels." },
{ name: "Start Call", slug: "SLACK_START_CALL", description: "Registers a new call for third-party integration." },
{ name: "Start RTM Session", slug: "SLACK_START_REAL_TIME_MESSAGING_SESSION", description: "Initiates a real-time messaging WebSocket session." },
{ name: "Unarchive Channel", slug: "SLACK_UNARCHIVE_A_PUBLIC_OR_PRIVATE_CHANNEL", description: "Unarchives a specific channel." },
{ name: "Unarchive Conversation", slug: "SLACK_UNARCHIVE_CHANNEL", description: "Reverses archival for a conversation." },
{ name: "Unpin Item", slug: "SLACK_UNPIN_ITEM_FROM_CHANNEL", description: "Unpins a message from a channel." },
{ name: "Update User Group", slug: "SLACK_UPDATE_AN_EXISTING_SLACK_USER_GROUP", description: "Updates name, handle, or channels for a user group." },
{ name: "Update Remote File", slug: "SLACK_UPDATES_AN_EXISTING_REMOTE_FILE", description: "Updates metadata for a remote file reference." },
{ name: "Update Message", slug: "SLACK_UPDATES_A_SLACK_MESSAGE", description: "Modifies the content of an existing message." },
{ name: "Update Call Info", slug: "SLACK_UPDATE_SLACK_CALL_INFORMATION", description: "Updates call title or join URLs." },
{ name: "Update Group Members", slug: "SLACK_UPDATE_USER_GROUP_MEMBERS", description: "Replaces the member list of a user group." },
{ name: "Upload File", slug: "SLACK_UPLOAD_OR_CREATE_A_FILE_IN_SLACK", description: "Uploads content or binary files to Slack." },
];
export const slackToolCatalogMarkdown = slackToolCatalog
.map((tool) => `- ${tool.name} (${tool.slug}) - ${tool.description}`)
.join("\n");

View file

@ -2,7 +2,7 @@ import { z, ZodType } from "zod";
import * as path from "path";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand } from "./command-executor.js";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
@ -11,17 +11,247 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
import * as workspace from "../../workspace/workspace.js";
import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
import type { ToolContext } from "./exec-tool.js";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const BuiltinToolsSchema = z.record(z.string(), z.object({
description: z.string(),
inputSchema: z.custom<ZodType>(),
execute: z.function({
input: z.any(),
input: z.any(), // (input, ctx?) => Promise<any>
output: z.promise(z.any()),
}),
}));
type SlackToolHint = {
search?: string;
patterns: string[];
fallbackSlugs?: string[];
preferSlugIncludes?: string[];
excludePatterns?: string[];
minScore?: number;
};
const slackToolHints: Record<string, SlackToolHint> = {
sendMessage: {
search: "message",
patterns: ["send", "message", "channel"],
fallbackSlugs: [
"SLACK_SEND_MESSAGE",
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
"SLACK_SEND_A_MESSAGE",
],
},
listConversations: {
search: "conversation",
patterns: ["list", "conversation", "channel"],
fallbackSlugs: [
"SLACK_LIST_CONVERSATIONS",
"SLACK_LIST_ALL_CHANNELS",
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
"SLACK_LIST_CHANNELS",
"SLACK_LIST_CHANNEL",
],
preferSlugIncludes: ["list", "conversation"],
minScore: 2,
},
getConversationHistory: {
search: "history",
patterns: ["history", "conversation", "message"],
fallbackSlugs: [
"SLACK_FETCH_CONVERSATION_HISTORY",
"SLACK_FETCHES_CONVERSATION_HISTORY",
"SLACK_GET_CONVERSATION_HISTORY",
"SLACK_GET_CHANNEL_HISTORY",
],
preferSlugIncludes: ["history"],
minScore: 2,
},
listUsers: {
search: "user",
patterns: ["list", "user"],
fallbackSlugs: [
"SLACK_LIST_ALL_USERS",
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
"SLACK_LIST_USERS",
"SLACK_GET_USERS",
"SLACK_USERS_LIST",
],
preferSlugIncludes: ["list", "user"],
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
minScore: 2,
},
getUserInfo: {
search: "user",
patterns: ["user", "info", "profile"],
fallbackSlugs: [
"SLACK_GET_USER_INFO",
"SLACK_GET_USER",
"SLACK_USER_INFO",
],
preferSlugIncludes: ["user", "info"],
minScore: 1,
},
searchMessages: {
search: "search",
patterns: ["search", "message"],
fallbackSlugs: [
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
"SLACK_SEARCH_MESSAGES",
"SLACK_SEARCH_MESSAGE",
],
preferSlugIncludes: ["search"],
minScore: 1,
},
};
const slackToolSlugCache = new Map<string, string>();
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
sendMessage: "SLACK_SEND_MESSAGE",
listConversations: "SLACK_LIST_CONVERSATIONS",
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
listUsers: "SLACK_LIST_ALL_USERS",
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
searchMessages: "SLACK_SEARCH_MESSAGES",
};
const compactObject = (input: Record<string, unknown>) =>
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
/** Helper to execute a Slack tool with consistent account validation and error handling */
async function executeSlackTool(
hintKey: keyof typeof slackToolHints,
params: Record<string, unknown>
): Promise<SlackToolResult> {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const toolSlug = await resolveSlackToolSlug(hintKey);
return await executeComposioAction(toolSlug, account.id, compactObject(params));
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
const slug = tool.slug.toLowerCase();
const name = (tool.name || "").toLowerCase();
const description = (tool.description || "").toLowerCase();
let score = 0;
for (const pattern of patterns) {
const needle = pattern.toLowerCase();
if (slug.includes(needle)) score += 3;
if (name.includes(needle)) score += 2;
if (description.includes(needle)) score += 1;
}
return score;
};
const pickSlackTool = (
tools: Array<{ slug: string; name?: string; description?: string }>,
hint: SlackToolHint,
) => {
let candidates = tools;
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
candidates = candidates.filter((tool) => {
const haystack = normalizeSlackTool(tool);
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
});
}
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
const preferred = candidates.filter((tool) =>
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
);
if (preferred.length > 0) {
candidates = preferred;
}
}
let best: { slug: string; name?: string; description?: string } | null = null;
let bestScore = 0;
for (const tool of candidates) {
const score = scoreSlackTool(tool, hint.patterns);
if (score > bestScore) {
bestScore = score;
best = tool;
}
}
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
return null;
}
return best;
};
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
const cached = slackToolSlugCache.get(hintKey);
if (cached) return cached;
const hint = slackToolHints[hintKey];
const override = slackToolSlugOverrides[hintKey];
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
slackToolSlugCache.set(hintKey, override);
return override;
}
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
if (fallback) return fallback.slug;
}
const best = pickSlackTool(tools, hint);
return best?.slug || null;
};
const initialTools = slackToolCatalog;
if (!initialTools.length) {
throw new Error("No Slack tools returned from Composio");
}
const initialSlug = resolveFromTools(initialTools);
if (initialSlug) {
slackToolSlugCache.set(hintKey, initialSlug);
return initialSlug;
}
const allSlug = resolveFromTools(slackToolCatalog);
if (!allSlug) {
const fallback = await listToolkitTools("slack", hint.search || null);
const fallbackSlug = resolveFromTools(fallback.items || []);
if (!fallbackSlug) {
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
}
slackToolSlugCache.set(hintKey, fallbackSlug);
return fallbackSlug;
}
slackToolSlugCache.set(hintKey, allSlug);
return allSlug;
};
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
@ -609,17 +839,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
inputSchema: z.object({
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root)'),
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root). You do not need to set this unless absolutely necessary.'),
}),
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
execute: async ({ command, cwd }: { command: string, cwd?: string }, ctx?: ToolContext) => {
try {
const rootDir = path.resolve(WorkDir);
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
const rootPrefix = rootDir.endsWith(path.sep)
? rootDir
: `${rootDir}${path.sep}`;
// TODO: Re-enable this check
// const rootPrefix = rootDir.endsWith(path.sep)
// ? rootDir
// : `${rootDir}${path.sep}`;
// if (workingDir !== rootDir && !workingDir.startsWith(rootPrefix)) {
// return {
// success: false,
@ -629,8 +859,32 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// };
// }
// Use abortable version when we have a signal
if (ctx?.signal) {
const { promise, process: proc } = executeCommandAbortable(command, {
cwd: workingDir,
signal: ctx.signal,
});
// Register process with abort registry for force-kill
ctx.abortRegistry.registerProcess(ctx.runId, proc);
const result = await promise;
return {
success: result.exitCode === 0 && !result.wasAborted,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
wasAborted: result.wasAborted,
command,
workingDir,
};
}
// Fallback to original for backward compatibility
const result = await executeCommand(command, { cwd: workingDir });
return {
success: result.exitCode === 0,
stdout: result.stdout,
@ -648,4 +902,162 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
// ============================================================================
// Slack Tools (via Composio)
// ============================================================================
'slack-checkConnection': {
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
inputSchema: z.object({}),
execute: async () => {
if (!isComposioConfigured()) {
return {
connected: false,
error: 'Composio is not configured. Please set up your Composio API key first.',
};
}
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return {
connected: false,
error: 'Slack is not connected. Please connect Slack from the settings.',
};
}
return {
connected: true,
accountId: account.id,
};
},
},
'slack-listAvailableTools': {
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
inputSchema: z.object({
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
}),
execute: async ({ search }: { search?: string }) => {
if (!isComposioConfigured()) {
return { success: false, error: 'Composio is not configured' };
}
try {
const result = await listToolkitTools('slack', search || null);
return {
success: true,
tools: result.items,
count: result.items.length,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-executeAction': {
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
inputSchema: z.object({
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
}),
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
const account = composioAccountsRepo.getAccount('slack');
if (!account || account.status !== 'ACTIVE') {
return { success: false, error: 'Slack is not connected' };
}
try {
const result = await executeComposioAction(toolSlug, account.id, input);
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
'slack-sendMessage': {
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
inputSchema: z.object({
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
text: z.string().describe('The message text to send'),
}),
execute: async ({ channel, text }: { channel: string; text: string }) => {
return executeSlackTool("sendMessage", { channel, text });
},
},
'slack-listChannels': {
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
inputSchema: z.object({
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
}),
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
return executeSlackTool("listConversations", {
types: types || "public_channel,private_channel",
limit: limit ?? 100,
});
},
},
'slack-getChannelHistory': {
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
inputSchema: z.object({
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
}),
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
return executeSlackTool("getConversationHistory", {
channel,
limit: limit !== undefined ? Math.min(limit, 100) : 20,
});
},
},
'slack-listUsers': {
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listUsers", { limit: limit ?? 100 });
},
},
'slack-getUserInfo': {
description: 'Get detailed information about a specific Slack user by their user ID.',
inputSchema: z.object({
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
}),
execute: async ({ user }: { user: string }) => {
return executeSlackTool("getUserInfo", { user });
},
},
'slack-searchMessages': {
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
inputSchema: z.object({
query: z.string().describe('Search query text'),
count: z.number().optional().describe('Maximum number of results (default: 20)'),
}),
execute: async ({ query, count }: { query: string; count?: number }) => {
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
},
},
'slack-getDirectMessages': {
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
inputSchema: z.object({
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
}),
execute: async ({ limit }: { limit?: number }) => {
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
},
},
};

View file

@ -1,4 +1,4 @@
import { exec, execSync } from 'child_process';
import { exec, execSync, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { getSecurityAllowList } from '../../config/security.js';
@ -110,6 +110,159 @@ export async function executeCommand(
}
}
export interface AbortableCommandResult extends CommandResult {
wasAborted: boolean;
}
const SIGKILL_GRACE_MS = 200;
/**
* Kill a process tree using negative PID (process group kill on Unix).
* Falls back to direct kill if group kill fails.
*/
function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
if (!proc.pid || proc.killed) return;
try {
// Negative PID kills the entire process group (Unix)
process.kill(-proc.pid, signal);
} catch {
try {
proc.kill(signal);
} catch {
// Process may already be dead
}
}
}
/**
* Executes a shell command with abort support.
* Uses spawn with detached=true to create a process group for proper tree killing.
* Returns both the promise and the child process handle.
*/
export function executeCommandAbortable(
command: string,
options?: {
cwd?: string;
timeout?: number;
maxBuffer?: number;
signal?: AbortSignal;
}
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
// Check if already aborted before spawning
if (options?.signal?.aborted) {
// Return a dummy process and a resolved result
const dummyProc = spawn('true', { shell: true });
dummyProc.kill();
return {
process: dummyProc,
promise: Promise.resolve({
stdout: '',
stderr: '',
exitCode: 130,
wasAborted: true,
}),
};
}
const proc = spawn(command, [], {
shell: '/bin/sh',
cwd: options?.cwd,
detached: process.platform !== 'win32', // Create process group on Unix
stdio: ['ignore', 'pipe', 'pipe'],
});
const promise = new Promise<AbortableCommandResult>((resolve) => {
let stdout = '';
let stderr = '';
let wasAborted = false;
let exited = false;
// Collect output
proc.stdout?.on('data', (chunk: Buffer) => {
const maxBuffer = options?.maxBuffer || 1024 * 1024;
if (stdout.length < maxBuffer) {
stdout += chunk.toString();
}
});
proc.stderr?.on('data', (chunk: Buffer) => {
const maxBuffer = options?.maxBuffer || 1024 * 1024;
if (stderr.length < maxBuffer) {
stderr += chunk.toString();
}
});
// Abort handler
const abortHandler = () => {
wasAborted = true;
killProcessTree(proc, 'SIGTERM');
// Force kill after grace period
setTimeout(() => {
if (!exited) {
killProcessTree(proc, 'SIGKILL');
}
}, SIGKILL_GRACE_MS);
};
if (options?.signal) {
options.signal.addEventListener('abort', abortHandler, { once: true });
}
// Timeout handler
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (options?.timeout) {
timeoutId = setTimeout(() => {
wasAborted = true;
killProcessTree(proc, 'SIGTERM');
setTimeout(() => {
if (!exited) {
killProcessTree(proc, 'SIGKILL');
}
}, SIGKILL_GRACE_MS);
}, options.timeout);
}
proc.once('exit', (code) => {
exited = true;
// Cleanup listeners
if (options?.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
if (wasAborted) {
stdout += '\n\n(Command was aborted)';
}
resolve({
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: code ?? 1,
wasAborted,
});
});
proc.once('error', (err) => {
exited = true;
if (options?.signal) {
options.signal.removeEventListener('abort', abortHandler);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
resolve({
stdout: '',
stderr: err.message,
exitCode: 1,
wasAborted,
});
});
});
return { promise, process: proc };
}
/**
* Executes a command synchronously (blocking)
* Use with caution - prefer executeCommand for async execution

View file

@ -2,22 +2,36 @@ import { ToolAttachment } from "@x/shared/dist/agent.js";
import { z } from "zod";
import { BuiltinTools } from "./builtin-tools.js";
import { executeTool } from "../../mcp/mcp.js";
import { IAbortRegistry } from "../../runs/abort-registry.js";
/**
* Context passed to every tool execution, providing abort signal and run metadata.
*/
export interface ToolContext {
runId: string;
signal: AbortSignal;
abortRegistry: IAbortRegistry;
}
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
return result;
}
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: Record<string, unknown>): Promise<unknown> {
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: Record<string, unknown>, ctx?: ToolContext): Promise<unknown> {
// Check abort before starting any tool
ctx?.signal.throwIfAborted();
switch (agentTool.type) {
case "mcp":
// MCP tools: let complete on graceful stop (most are fast)
return execMcpTool(agentTool, input);
case "builtin": {
const builtinTool = BuiltinTools[agentTool.name];
if (!builtinTool || !builtinTool.execute) {
throw new Error(`Unsupported builtin tool: ${agentTool.name}`);
}
return builtinTool.execute(input);
return builtinTool.execute(input, ctx);
}
}
}

View file

@ -0,0 +1,23 @@
type ProviderClientIdOverrides = Map<string, string>;
const providerClientIdOverrides: ProviderClientIdOverrides = new Map();
export function setProviderClientIdOverride(provider: string, clientId: string): void {
const trimmed = clientId.trim();
if (!trimmed) {
return;
}
providerClientIdOverrides.set(provider, trimmed);
}
export function getProviderClientIdOverride(provider: string): string | undefined {
return providerClientIdOverrides.get(provider);
}
export function hasProviderClientIdOverride(provider: string): boolean {
return providerClientIdOverrides.has(provider);
}
export function clearProviderClientIdOverride(provider: string): void {
providerClientIdOverrides.delete(provider);
}

View file

@ -22,7 +22,7 @@ const DiscoverySchema = z.discriminatedUnion('mode', [
const ClientSchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('static'),
clientId: z.string().min(1),
clientId: z.string().min(1).optional(),
}),
z.object({
mode: z.literal('dcr'),
@ -58,7 +58,6 @@ const providerConfigs: ProviderConfig = {
},
client: {
mode: 'static',
clientId: '797410052581-ibmmvqec0l68stv5fmgh0juqfvbg08fc.apps.googleusercontent.com',
},
scopes: [
'https://www.googleapis.com/auth/gmail.readonly',

View file

@ -0,0 +1,359 @@
import { z } from "zod";
import fs from "fs";
import path from "path";
import { Composio } from "@composio/core";
import { WorkDir } from "../config/config.js";
import {
ZAuthConfig,
ZConnectedAccount,
ZCreateAuthConfigRequest,
ZCreateAuthConfigResponse,
ZCreateConnectedAccountRequest,
ZCreateConnectedAccountResponse,
ZDeleteOperationResponse,
ZErrorResponse,
ZExecuteActionResponse,
ZListResponse,
ZToolkit,
} from "./types.js";
const BASE_URL = 'https://backend.composio.dev/api/v3';
const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json');
// Composio SDK client (lazily initialized)
let composioClient: Composio | null = null;
function getComposioClient(): Composio {
if (composioClient) {
return composioClient;
}
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
composioClient = new Composio({ apiKey });
return composioClient;
}
function resetComposioClient(): void {
composioClient = null;
}
/**
* Configuration schema for Composio
*/
const ZComposioConfig = z.object({
apiKey: z.string().optional(),
});
type ComposioConfig = z.infer<typeof ZComposioConfig>;
/**
* Load Composio configuration
*/
function loadConfig(): ComposioConfig {
try {
if (fs.existsSync(CONFIG_FILE)) {
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
return ZComposioConfig.parse(JSON.parse(data));
}
} catch (error) {
console.error('[Composio] Failed to load config:', error);
}
return {};
}
/**
* Save Composio configuration
*/
export function saveConfig(config: ComposioConfig): void {
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
/**
* Get the Composio API key
*/
export function getApiKey(): string | null {
const config = loadConfig();
return config.apiKey || process.env.COMPOSIO_API_KEY || null;
}
/**
* Set the Composio API key
*/
export function setApiKey(apiKey: string): void {
const config = loadConfig();
config.apiKey = apiKey;
saveConfig(config);
resetComposioClient();
}
/**
* Check if Composio is configured
*/
export function isConfigured(): boolean {
return !!getApiKey();
}
/**
* Make an API call to Composio
*/
export async function composioApiCall<T extends z.ZodTypeAny>(
schema: T,
url: string,
options: RequestInit = {},
): Promise<z.infer<T>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
console.log(`[Composio] ${options.method || 'GET'} ${url}`);
const startTime = Date.now();
try {
const response = await fetch(url, {
...options,
headers: {
...options.headers,
"x-api-key": apiKey,
...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}),
},
});
const duration = Date.now() - startTime;
console.log(`[Composio] Response in ${duration}ms`);
const contentType = response.headers.get('content-type') || '';
const rawText = await response.text();
if (!response.ok || !contentType.includes('application/json')) {
console.error(`[Composio] Error response:`, {
status: response.status,
statusText: response.statusText,
contentType,
preview: rawText.slice(0, 200),
});
}
if (!response.ok) {
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
}
if (!contentType.includes('application/json')) {
throw new Error('Expected JSON response');
}
let data: unknown;
try {
data = JSON.parse(rawText);
} catch (e) {
const message = e instanceof Error ? e.message : 'Unknown error';
throw new Error(`Failed to parse response: ${message}`);
}
if (typeof data === 'object' && data !== null && 'error' in data) {
const parsedError = ZErrorResponse.parse(data);
throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`);
}
return schema.parse(data);
} catch (error) {
console.error(`[Composio] Error:`, error);
throw error;
}
}
/**
* List available toolkits
*/
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
const url = new URL(`${BASE_URL}/toolkits`);
url.searchParams.set("sort_by", "usage");
if (cursor) {
url.searchParams.set("cursor", cursor);
}
return composioApiCall(ZListResponse(ZToolkit), url.toString());
}
/**
* Get a specific toolkit
*/
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZToolkit>> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = `${BASE_URL}/toolkits/${toolkitSlug}`;
console.log(`[Composio] GET ${url}`);
const response = await fetch(url, {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') ||
data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') ||
false;
return ZToolkit.parse({
...data,
no_auth,
meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 },
auth_schemes: data.auth_schemes || [],
composio_managed_auth_schemes: data.composio_managed_auth_schemes || [],
});
}
/**
* List auth configs for a toolkit
*/
export async function listAuthConfigs(
toolkitSlug: string,
cursor: string | null = null,
managedOnly: boolean = false
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
const url = new URL(`${BASE_URL}/auth_configs`);
url.searchParams.set("toolkit_slug", toolkitSlug);
if (cursor) {
url.searchParams.set("cursor", cursor);
}
if (managedOnly) {
url.searchParams.set("is_composio_managed", "true");
}
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
}
/**
* Create an auth config
*/
export async function createAuthConfig(
request: z.infer<typeof ZCreateAuthConfigRequest>
): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
const url = new URL(`${BASE_URL}/auth_configs`);
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Delete an auth config
*/
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
/**
* Create a connected account
*/
export async function createConnectedAccount(
request: z.infer<typeof ZCreateConnectedAccountRequest>
): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts`);
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
method: 'POST',
body: JSON.stringify(request),
});
}
/**
* Get a connected account
*/
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZConnectedAccount, url.toString());
}
/**
* Delete a connected account
*/
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
method: 'DELETE',
});
}
/**
* List available tools for a toolkit
*/
export async function listToolkitTools(
toolkitSlug: string,
searchQuery: string | null = null,
): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> {
const apiKey = getApiKey();
if (!apiKey) {
throw new Error('Composio API key not configured');
}
const url = new URL(`${BASE_URL}/tools`);
url.searchParams.set('toolkit_slug', toolkitSlug);
url.searchParams.set('limit', '200');
if (searchQuery) {
url.searchParams.set('search', searchQuery);
}
console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`);
const response = await fetch(url.toString(), {
headers: { "x-api-key": apiKey },
});
if (!response.ok) {
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
}
const data = await response.json() as { items?: Array<Record<string, unknown>> };
return {
items: (data.items || []).map((item) => ({
slug: String(item.slug ?? ''),
name: String(item.name ?? ''),
description: String(item.description ?? ''),
})),
};
}
/**
* Execute a tool action using Composio SDK
*/
export async function executeAction(
actionSlug: string,
connectedAccountId: string,
input: Record<string, unknown>
): Promise<z.infer<typeof ZExecuteActionResponse>> {
console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`);
try {
const client = getComposioClient();
const result = await client.tools.execute(actionSlug, {
userId: connectedAccountId,
arguments: input,
connectedAccountId,
dangerouslySkipVersionCheck: true,
});
console.log(`[Composio] Action completed successfully`);
return { success: true, data: result.data };
} catch (error) {
console.error(`[Composio] Action execution failed:`, error);
const message = error instanceof Error ? error.message : 'Unknown error';
return { success: false, data: null, error: message };
}
}

View file

@ -0,0 +1,5 @@
// Composio integration for Rowboat X
export * from './types.js';
export * from './client.js';
export * from './repo.js';

View file

@ -0,0 +1,140 @@
import fs from "fs";
import path from "path";
import { z } from "zod";
import { WorkDir } from "../config/config.js";
import { ZLocalConnectedAccount, LocalConnectedAccount, ConnectedAccountStatus } from "./types.js";
const ACCOUNTS_FILE = path.join(WorkDir, 'data', 'composio', 'connected_accounts.json');
/**
* Schema for the connected accounts storage file
*/
const ZConnectedAccountsStorage = z.object({
accounts: z.record(z.string(), ZLocalConnectedAccount), // keyed by toolkit slug
});
type ConnectedAccountsStorage = z.infer<typeof ZConnectedAccountsStorage>;
/**
* Interface for Composio accounts repository
*/
export interface IComposioAccountsRepo {
getAccount(toolkitSlug: string): LocalConnectedAccount | null;
getAllAccounts(): Record<string, LocalConnectedAccount>;
saveAccount(account: LocalConnectedAccount): void;
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean;
deleteAccount(toolkitSlug: string): void;
isConnected(toolkitSlug: string): boolean;
getConnectedToolkits(): string[];
}
/**
* Ensure the storage directory exists
*/
function ensureStorageDir(): void {
const dir = path.dirname(ACCOUNTS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Load connected accounts from storage
*/
function loadAccounts(): ConnectedAccountsStorage {
try {
if (fs.existsSync(ACCOUNTS_FILE)) {
const data = fs.readFileSync(ACCOUNTS_FILE, 'utf-8');
return ZConnectedAccountsStorage.parse(JSON.parse(data));
}
} catch (error) {
console.error('[ComposioRepo] Failed to load accounts:', error);
}
return { accounts: {} };
}
/**
* Save connected accounts to storage
*/
function saveAccounts(storage: ConnectedAccountsStorage): void {
ensureStorageDir();
fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(storage, null, 2));
}
/**
* Composio Connected Accounts Repository
* Stores connected account information locally
*/
export class ComposioAccountsRepo implements IComposioAccountsRepo {
/**
* Get a connected account by toolkit slug
*/
getAccount(toolkitSlug: string): LocalConnectedAccount | null {
const storage = loadAccounts();
return storage.accounts[toolkitSlug] || null;
}
/**
* Get all connected accounts
*/
getAllAccounts(): Record<string, LocalConnectedAccount> {
const storage = loadAccounts();
return storage.accounts;
}
/**
* Save a connected account
*/
saveAccount(account: LocalConnectedAccount): void {
const storage = loadAccounts();
storage.accounts[account.toolkitSlug] = account;
saveAccounts(storage);
}
/**
* Update account status
* @returns true if account was found and updated, false if account doesn't exist
*/
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean {
const storage = loadAccounts();
const account = storage.accounts[toolkitSlug];
if (!account) {
console.warn(`[ComposioRepo] Cannot update status: account '${toolkitSlug}' not found`);
return false;
}
account.status = status;
account.lastUpdatedAt = new Date().toISOString();
saveAccounts(storage);
return true;
}
/**
* Delete a connected account
*/
deleteAccount(toolkitSlug: string): void {
const storage = loadAccounts();
delete storage.accounts[toolkitSlug];
saveAccounts(storage);
}
/**
* Check if a toolkit is connected
*/
isConnected(toolkitSlug: string): boolean {
const account = this.getAccount(toolkitSlug);
return account?.status === 'ACTIVE';
}
/**
* Get list of connected toolkit slugs
*/
getConnectedToolkits(): string[] {
const storage = loadAccounts();
return Object.entries(storage.accounts)
.filter(([, account]) => account.status === 'ACTIVE')
.map(([slug]) => slug);
}
}
// Export singleton instance
export const composioAccountsRepo = new ComposioAccountsRepo();

View file

@ -0,0 +1,237 @@
import { z } from "zod";
/**
* Composio authentication schemes
*/
export const ZAuthScheme = z.enum([
'API_KEY',
'BASIC',
'BASIC_WITH_JWT',
'BEARER_TOKEN',
'COMPOSIO_LINK',
'SERVICE_ACCOUNT',
'GOOGLE_SERVICE_ACCOUNT',
'NO_AUTH',
'OAUTH1',
'OAUTH2',
]);
/**
* Connected account status
*/
export const ZConnectedAccountStatus = z.enum([
'INITIALIZING',
'INITIATED',
'ACTIVE',
'FAILED',
'EXPIRED',
'INACTIVE',
]);
/**
* Toolkit metadata
*/
export const ZToolkitMeta = z.object({
description: z.string(),
logo: z.string(),
tools_count: z.number(),
triggers_count: z.number(),
});
/**
* Toolkit schema
*/
export const ZToolkit = z.object({
slug: z.string(),
name: z.string(),
meta: ZToolkitMeta,
no_auth: z.boolean(),
auth_schemes: z.array(ZAuthScheme),
composio_managed_auth_schemes: z.array(ZAuthScheme),
});
/**
* Tool schema
*/
export const ZTool = z.object({
slug: z.string(),
name: z.string(),
description: z.string(),
toolkit: z.object({
slug: z.string(),
name: z.string(),
logo: z.string(),
}),
input_parameters: z.object({
type: z.literal('object'),
properties: z.record(z.string(), z.unknown()),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
}),
no_auth: z.boolean(),
});
/**
* Auth config schema
*/
export const ZAuthConfig = z.object({
id: z.string(),
is_composio_managed: z.boolean(),
auth_scheme: ZAuthScheme,
});
/**
* Credentials schema
*/
export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
/**
* Create auth config request
*/
export const ZCreateAuthConfigRequest = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.discriminatedUnion('type', [
z.object({
type: z.literal('use_composio_managed_auth'),
name: z.string().optional(),
credentials: ZCredentials.optional(),
}),
z.object({
type: z.literal('use_custom_auth'),
authScheme: ZAuthScheme,
credentials: ZCredentials,
name: z.string().optional(),
}),
]).optional(),
});
/**
* Create auth config response
*/
export const ZCreateAuthConfigResponse = z.object({
toolkit: z.object({
slug: z.string(),
}),
auth_config: ZAuthConfig,
});
/**
* Connection data schema
*/
export const ZConnectionData = z.object({
authScheme: ZAuthScheme,
val: z.record(z.string(), z.unknown())
.and(z.object({
status: ZConnectedAccountStatus,
})),
});
/**
* Create connected account request
*/
export const ZCreateConnectedAccountRequest = z.object({
auth_config: z.object({
id: z.string(),
}),
connection: z.object({
state: ZConnectionData.optional(),
user_id: z.string().optional(),
callback_url: z.string().optional(),
}),
});
/**
* Create connected account response
*/
export const ZCreateConnectedAccountResponse = z.object({
id: z.string(),
connectionData: ZConnectionData,
});
/**
* Connected account schema
*/
export const ZConnectedAccount = z.object({
id: z.string(),
toolkit: z.object({
slug: z.string(),
}),
auth_config: z.object({
id: z.string(),
is_composio_managed: z.boolean(),
is_disabled: z.boolean(),
}),
status: ZConnectedAccountStatus,
});
/**
* Error response schema
*/
export const ZErrorResponse = z.object({
error: z.object({
message: z.string(),
error_code: z.number(),
suggested_fix: z.string().nullable(),
errors: z.array(z.string()).nullable(),
}),
});
/**
* Delete operation response
*/
export const ZDeleteOperationResponse = z.object({
success: z.boolean(),
});
/**
* Generic list response
*/
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
items: z.array(schema),
next_cursor: z.string().nullable(),
total_pages: z.number(),
current_page: z.number(),
total_items: z.number(),
});
/**
* Execute action request
*/
export const ZExecuteActionRequest = z.object({
action: z.string(),
connected_account_id: z.string(),
input: z.record(z.string(), z.unknown()),
});
/**
* Execute action response
*/
export const ZExecuteActionResponse = z.object({
success: z.boolean(),
data: z.unknown(),
error: z.string().optional(),
});
/**
* Local connected account storage schema
*/
export const ZLocalConnectedAccount = z.object({
id: z.string(),
authConfigId: z.string(),
status: ZConnectedAccountStatus,
toolkitSlug: z.string(),
createdAt: z.string(),
lastUpdatedAt: z.string(),
});
export type AuthScheme = z.infer<typeof ZAuthScheme>;
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
export type Toolkit = z.infer<typeof ZToolkit>;
export type Tool = z.infer<typeof ZTool>;
export type AuthConfig = z.infer<typeof ZAuthConfig>;
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;

View file

@ -0,0 +1,20 @@
import container from "../di/container.js";
import type { IModelConfigRepo } from "../models/repo.js";
import type { IMcpConfigRepo } from "../mcp/repo.js";
import { ensureSecurityConfig } from "./security.js";
/**
* Initialize all config files at app startup.
* Ensures config files exist before the UI might access them.
*/
export async function initConfigs(): Promise<void> {
// Resolve repos and explicitly call their ensureConfig methods
const modelConfigRepo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const mcpConfigRepo = container.resolve<IMcpConfigRepo>("mcpConfigRepo");
await Promise.all([
modelConfigRepo.ensureConfig(),
mcpConfigRepo.ensureConfig(),
ensureSecurityConfig(),
]);
}

View file

@ -1,5 +1,6 @@
import path from "path";
import fs from "fs";
import fsPromises from "fs/promises";
import { WorkDir } from "./config.js";
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
@ -19,7 +20,26 @@ const DEFAULT_ALLOW_LIST = [
let cachedAllowList: string[] | null = null;
let cachedMtimeMs: number | null = null;
function ensureSecurityConfig() {
/**
* Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs().
*/
export async function ensureSecurityConfig(): Promise<void> {
try {
await fsPromises.access(SECURITY_CONFIG_PATH);
} catch {
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n",
"utf8",
);
}
}
/**
* Sync version for internal use by getSecurityAllowList() and readAllowList().
*/
function ensureSecurityConfigSync() {
if (!fs.existsSync(SECURITY_CONFIG_PATH)) {
fs.writeFileSync(
SECURITY_CONFIG_PATH,
@ -63,7 +83,7 @@ function parseSecurityPayload(payload: unknown): string[] {
}
function readAllowList(): string[] {
ensureSecurityConfig();
ensureSecurityConfigSync();
try {
const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8");
@ -76,7 +96,7 @@ function readAllowList(): string[] {
}
export function getSecurityAllowList(): string[] {
ensureSecurityConfig();
ensureSecurityConfigSync();
try {
const stats = fs.statSync(SECURITY_CONFIG_PATH);
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {

View file

@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -22,6 +23,7 @@ container.register({
messageQueue: asClass<IMessageQueue>(InMemoryMessageQueue).singleton(),
bus: asClass<IBus>(InMemoryBus).singleton(),
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
abortRegistry: asClass<IAbortRegistry>(InMemoryAbortRegistry).singleton(),
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),

View file

@ -2,4 +2,7 @@
export * as workspace from './workspace/workspace.js';
// Workspace watcher
export * as watcher from './workspace/watcher.js';
export * as watcher from './workspace/watcher.js';
// Config initialization
export { initConfigs } from './config/initConfigs.js';

View file

@ -29,7 +29,89 @@ const SOURCE_FOLDERS = [
'fireflies_transcripts',
'granola_notes',
];
const MAX_CONCURRENT_BATCHES = 1; // Process only 1 batch at a time to avoid overwhelming the agent
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
/**
* Get unprocessed voice memo files from knowledge/Voice Memos/
* Voice memos are created directly in this directory by the UI.
* Returns paths to files that need entity extraction.
*/
function getUnprocessedVoiceMemos(state: GraphState): string[] {
console.log(`[GraphBuilder] Checking directory: ${VOICE_MEMOS_KNOWLEDGE_DIR}`);
if (!fs.existsSync(VOICE_MEMOS_KNOWLEDGE_DIR)) {
console.log(`[GraphBuilder] Directory does not exist`);
return [];
}
const unprocessedFiles: string[] = [];
// Scan date folders (e.g., 2026-02-03)
const dateFolders = fs.readdirSync(VOICE_MEMOS_KNOWLEDGE_DIR);
console.log(`[GraphBuilder] Found ${dateFolders.length} date folders: ${dateFolders.join(', ')}`);
for (const dateFolder of dateFolders) {
const dateFolderPath = path.join(VOICE_MEMOS_KNOWLEDGE_DIR, dateFolder);
// Skip if not a directory
try {
if (!fs.statSync(dateFolderPath).isDirectory()) {
continue;
}
} catch (err) {
console.log(`[GraphBuilder] Error checking ${dateFolderPath}:`, err);
continue;
}
// Scan markdown files in this date folder
const files = fs.readdirSync(dateFolderPath);
console.log(`[GraphBuilder] Found ${files.length} files in ${dateFolder}: ${files.join(', ')}`);
for (const file of files) {
// Only process voice memo markdown files
if (!file.endsWith('.md') || !file.startsWith('voice-memo-')) {
console.log(`[GraphBuilder] Skipping ${file} - not a voice memo file`);
continue;
}
const filePath = path.join(dateFolderPath, file);
// Skip if already processed
if (state.processedFiles[filePath]) {
console.log(`[GraphBuilder] Skipping ${file} - already processed`);
continue;
}
// Check if the file has actual content (not still recording/transcribing)
try {
const content = fs.readFileSync(filePath, 'utf-8');
// Skip files that are still recording or transcribing
if (content.includes('*Recording in progress...*')) {
console.log(`[GraphBuilder] Skipping ${file} - still recording`);
continue;
}
if (content.includes('*Transcribing...*')) {
console.log(`[GraphBuilder] Skipping ${file} - still transcribing`);
continue;
}
if (content.includes('*Transcription failed')) {
console.log(`[GraphBuilder] Skipping ${file} - transcription failed`);
continue;
}
console.log(`[GraphBuilder] Found unprocessed voice memo: ${file}`);
unprocessedFiles.push(filePath);
} catch (err) {
console.log(`[GraphBuilder] Error reading ${file}:`, err);
continue;
}
}
}
console.log(`[GraphBuilder] Total unprocessed files: ${unprocessedFiles.length}`);
return unprocessedFiles;
}
/**
* Read content for specific files
@ -186,6 +268,69 @@ export async function buildGraph(sourceDir: string): Promise<void> {
console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`);
}
/**
* Process voice memos from knowledge/Voice Memos/ and run entity extraction on them
* Voice memos are now created directly in the knowledge directory by the UI.
*/
async function processVoiceMemosForKnowledge(): Promise<boolean> {
console.log(`[GraphBuilder] Starting voice memo processing...`);
const state = loadState();
// Get unprocessed voice memos from knowledge/Voice Memos/
const unprocessedFiles = getUnprocessedVoiceMemos(state);
if (unprocessedFiles.length === 0) {
console.log(`[GraphBuilder] No unprocessed voice memos found`);
return false;
}
console.log(`[GraphBuilder] Processing ${unprocessedFiles.length} voice memo transcripts for entity extraction...`);
console.log(`[GraphBuilder] Files to process: ${unprocessedFiles.map(f => path.basename(f)).join(', ')}`);
// Read the files
const contentFiles = await readFileContents(unprocessedFiles);
if (contentFiles.length === 0) {
return false;
}
// Process in batches like other sources
const BATCH_SIZE = 10;
const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE);
for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) {
const batch = contentFiles.slice(i, i + BATCH_SIZE);
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
try {
// Build knowledge index
console.log(`[GraphBuilder] Building knowledge index for batch ${batchNumber}...`);
const index = buildKnowledgeIndex();
const indexForPrompt = formatIndexForPrompt(index);
console.log(`[GraphBuilder] Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`);
await createNotesFromBatch(batch, batchNumber, indexForPrompt);
console.log(`[GraphBuilder] Batch ${batchNumber}/${totalBatches} complete`);
// Mark files as processed
for (const file of batch) {
markFileAsProcessed(file.path, state);
}
// Save state after each batch
saveState(state);
} catch (error) {
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
}
}
// Update last build time
state.lastBuildTime = new Date().toISOString();
saveState(state);
return true;
}
/**
* Process all configured source directories
*/
@ -197,6 +342,16 @@ async function processAllSources(): Promise<void> {
let anyFilesProcessed = false;
// Process voice memos first (they get moved to knowledge/)
try {
const voiceMemosProcessed = await processVoiceMemosForKnowledge();
if (voiceMemosProcessed) {
anyFilesProcessed = true;
}
} catch (error) {
console.error('[GraphBuilder] Error processing voice memos:', error);
}
for (const folder of SOURCE_FOLDERS) {
const sourceDir = path.join(WorkDir, folder);
@ -234,7 +389,7 @@ async function processAllSources(): Promise<void> {
*/
export async function init() {
console.log('[GraphBuilder] Starting Knowledge Graph Builder Service...');
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}`);
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}, knowledge/Voice Memos`);
console.log(`[GraphBuilder] Will check for new content every ${SYNC_INTERVAL_MS / 1000} seconds`);
// Initial run

View file

@ -144,9 +144,13 @@ export class FirefliesClientFactory {
if (providerConfig.client.mode === 'static') {
// Discover endpoints, use static client ID
console.log(`[Fireflies] Discovery mode: issuer with static client ID`);
const clientId = providerConfig.client.clientId;
if (!clientId) {
throw new Error('Fireflies client ID not configured.');
}
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
providerConfig.client.clientId
clientId
);
} else {
// DCR mode - need existing registration
@ -170,10 +174,14 @@ export class FirefliesClientFactory {
}
console.log(`[Fireflies] Using static endpoints (no discovery)`);
const clientId = providerConfig.client.clientId;
if (!clientId) {
throw new Error('Fireflies client ID not configured.');
}
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
providerConfig.client.clientId,
clientId,
providerConfig.discovery.revocationEndpoint
);
}

View file

@ -3,6 +3,7 @@ import container from '../di/container.js';
import { IOAuthRepo } from '../auth/repo.js';
import { IClientRegistrationRepo } from '../auth/client-repo.js';
import { getProviderConfig } from '../auth/providers.js';
import { getProviderClientIdOverride } from '../auth/provider-client-id.js';
import * as oauthClient from '../auth/oauth-client.js';
import type { Configuration } from '../auth/oauth-client.js';
import { OAuthTokens } from '../auth/types.js';
@ -17,12 +18,22 @@ export class GoogleClientFactory {
config: Configuration | null;
client: OAuth2Client | null;
tokens: OAuthTokens | null;
clientId: string | null;
} = {
config: null,
client: null,
tokens: null,
clientId: null,
};
private static resolveClientId(): string {
const override = getProviderClientIdOverride(this.PROVIDER_NAME);
if (!override) {
throw new Error('Google client ID not provided for this session.');
}
return override;
}
/**
* Get or create OAuth2Client, reusing cached instance when possible
*/
@ -36,7 +47,13 @@ export class GoogleClientFactory {
}
// Initialize config cache if needed
await this.initializeConfigCache();
try {
await this.initializeConfigCache();
} catch (error) {
console.error("[OAuth] Failed to initialize Google OAuth configuration:", error);
this.clearCache();
return null;
}
if (!this.cache.config) {
return null;
}
@ -95,6 +112,10 @@ export class GoogleClientFactory {
return false;
}
if (!getProviderClientIdOverride(this.PROVIDER_NAME)) {
return false;
}
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
if (!tokens) {
return false;
@ -116,14 +137,21 @@ export class GoogleClientFactory {
this.cache.config = null;
this.cache.client = null;
this.cache.tokens = null;
this.cache.clientId = null;
}
/**
* Initialize cached configuration (called once)
*/
private static async initializeConfigCache(): Promise<void> {
if (this.cache.config) {
return; // Already initialized
const clientId = this.resolveClientId();
if (this.cache.config && this.cache.clientId === clientId) {
return; // Already initialized for this client ID
}
if (this.cache.clientId && this.cache.clientId !== clientId) {
this.clearCache();
}
console.log(`[OAuth] Initializing Google OAuth configuration...`);
@ -135,7 +163,7 @@ export class GoogleClientFactory {
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
this.cache.config = await oauthClient.discoverConfiguration(
providerConfig.discovery.issuer,
providerConfig.client.clientId
clientId
);
} else {
// DCR mode - need existing registration
@ -162,11 +190,12 @@ export class GoogleClientFactory {
this.cache.config = oauthClient.createStaticConfiguration(
providerConfig.discovery.authorizationEndpoint,
providerConfig.discovery.tokenEndpoint,
providerConfig.client.clientId,
clientId,
providerConfig.discovery.revocationEndpoint
);
}
this.cache.clientId = clientId;
console.log(`[OAuth] Google OAuth configuration initialized`);
}
@ -174,17 +203,7 @@ export class GoogleClientFactory {
* Create OAuth2Client from OAuthTokens
*/
private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client {
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
// Get client ID from config
let clientId: string;
if (providerConfig.client.mode === 'static') {
clientId = providerConfig.client.clientId;
} else {
// For DCR, we'd need to look up the registered client ID
// This is a fallback - normally initializeConfigCache handles this
throw new Error('Cannot create client without static client ID');
}
const clientId = this.resolveClientId();
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
const client = new OAuth2Client(

View file

@ -7,19 +7,25 @@ tools:
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
executeCommand:
workspace-grep:
type: builtin
name: executeCommand
name: workspace-grep
workspace-glob:
type: builtin
name: workspace-glob
---
# Task
You are a memory agent. Given a single source file (email or meeting transcript), you will:
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
1. **Determine source type (meeting or email)**
2. **Evaluate if the source is worth processing**
@ -31,7 +37,7 @@ You are a memory agent. Given a single source file (email or meeting transcript)
8. Create new notes (meetings only) or update existing notes
9. **Apply state changes to existing notes**
The core rule: **Meetings create notes. Emails enrich them.**
The core rule: **Meetings and voice memos create notes. Emails enrich them.**
You have full read access to the existing knowledge directory. Use this extensively to:
- Find existing notes for people, organizations, projects mentioned
@ -70,20 +76,51 @@ When you need to:
# Tools Available
You have access to \`executeCommand\` to run shell commands:
You have access to these tools:
**For reading files:**
\`\`\`
executeCommand("ls {path}") # List directory contents
executeCommand("cat {path}") # Read file contents
executeCommand("head -50 {path}") # Read first 50 lines
executeCommand("write {path} {content}") # Create or overwrite file
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
\`\`\`
**Important:** Use shell escaping for paths with spaces:
**For creating NEW files:**
\`\`\`
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\`
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
**For editing EXISTING files (preferred for updates):**
\`\`\`
workspace-edit({
path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
})
\`\`\`
**For listing directories:**
\`\`\`
workspace-readdir({ path: "knowledge/People" })
\`\`\`
**For creating directories:**
\`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\`
**For searching files:**
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\`
**For finding files by pattern:**
\`\`\`
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
\`\`\`
**IMPORTANT:**
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
- Use \`workspace-writeFile\` only for creating new notes
- Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output
@ -113,7 +150,7 @@ Either:
Read the source file and determine if it's a meeting or email.
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
**Meeting indicators:**
@ -126,9 +163,15 @@ executeCommand("cat '{source_file}'")
- Has \`Subject:\` field
- Email signature
**Voice memo indicators:**
- Has \`**Type:** voice memo\` field
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Set processing mode:**
- \`source_type = "meeting"\` → Can create new notes
- \`source_type = "email"\` → Can only update existing notes
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
---
@ -301,9 +344,9 @@ If someone only appears in your memory as "CC'd on outreach emails from [Sender]
## Email-Specific Filtering
For emails, check if sender/recipients have existing notes:
\`\`\`bash
executeCommand("grep -r -i -l '{sender email}' '{knowledge_folder}/'")
executeCommand("grep -r -i -l '{sender name}' '{knowledge_folder}/People/'")
\`\`\`
workspace-grep({ pattern: "{sender email}", searchPath: "{knowledge_folder}" })
workspace-grep({ pattern: "{sender name}", searchPath: "{knowledge_folder}/People" })
\`\`\`
**If no existing note found:**
@ -343,7 +386,7 @@ If processing, continue to Step 2.
# Step 2: Read and Parse Source File
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
Extract metadata:
@ -440,7 +483,7 @@ From index, find matches for:
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
**Why read these notes:**
@ -524,27 +567,27 @@ Resolution Map:
When multiple candidates match a variant, disambiguate:
**By organization (strongest signal):**
\`\`\`bash
\`\`\`
# "David" could be David Kim or David Chen
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Kim.md'")
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Output: **Organization:** [[Acme Corp]]
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Chen.md'")
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
# Output: **Organization:** [[Other Corp]]
# Source is from Acme context "David" = "David Kim"
\`\`\`
**By email (definitive):**
\`\`\`bash
executeCommand("grep -i 'david@acme.com' '{knowledge_folder}/People/David Kim.md'")
\`\`\`
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Exact email match is definitive
\`\`\`
**By role:**
\`\`\`bash
\`\`\`
# Source mentions "their CTO"
executeCommand("grep -r -i 'Role.*CTO' '{knowledge_folder}/People/'")
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
# Filter results by organization context
\`\`\`
@ -844,7 +887,12 @@ If role is unknown but context suggests it, say so:
One line summarizing this source's relevance to the entity:
\`\`\`
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
\`\`\`
**For voice memos:** Include a link to the voice memo file using the Path field:
\`\`\`
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
\`\`\`
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
@ -968,8 +1016,8 @@ STATE CHANGES:
Before writing, compare extracted content against existing notes.
## Check Activity Log
\`\`\`bash
executeCommand("grep '2025-01-15' '{knowledge_folder}/People/Sarah Chen.md'")
\`\`\`
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
@ -999,28 +1047,28 @@ If new info contradicts existing:
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Issue exactly one write/edit command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
- Do NOT batch multiple write commands in a single response.
**For new entities (meetings only):**
\`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
**For NEW entities (use workspace-writeFile):**
\`\`\`
workspace-writeFile({
path: "{knowledge_folder}/People/Jennifer.md",
data: "# Jennifer\\n\\n## Summary\\n..."
})
\`\`\`
**For existing entities:**
- Read current content first
- Add activity entry at TOP of Activity section (reverse chronological)
- Update "Last seen" date
- Add new key facts (skip duplicates)
- Add new open items
- Add new decisions
- Add new relationships
- Update summary ONLY if significant new understanding
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
# ... modify content ...
executeCommand("write '{knowledge_folder}/People/Sarah Chen.md' '{full_updated_content}'")
**For EXISTING entities (use workspace-edit):**
- Read current content first with workspace-readFile
- Use workspace-edit to add activity entry at TOP (reverse chronological)
- Update fields using targeted edits
\`\`\`
workspace-edit({
path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
})
\`\`\`
## 9b: Emails Update Existing Notes Only
@ -1043,7 +1091,7 @@ For each state change identified in Step 7:
### Update Project Status
\`\`\`bash
# Read current project note
executeCommand("cat '{knowledge_folder}/Projects/Acme Integration.md'")
workspace-readFile({ path: "{knowledge_folder}/Projects/Acme Integration.md" })
# Update the Status field
# Change: **Status:** planning
@ -1053,7 +1101,7 @@ executeCommand("cat '{knowledge_folder}/Projects/Acme Integration.md'")
### Mark Open Items Complete
\`\`\`bash
# Read current note
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
# Find matching open item and update
# Change: - [ ] Send API documentation by Friday
@ -1063,7 +1111,7 @@ executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
### Update Role
\`\`\`bash
# Read current person note
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
# Update role field
# Change: **Role:** Engineering Lead
@ -1073,7 +1121,7 @@ executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
### Update Relationship
\`\`\`bash
# Read current org note
executeCommand("cat '{knowledge_folder}/Organizations/Acme Corp.md'")
workspace-readFile({ path: "{knowledge_folder}/Organizations/Acme Corp.md" })
# Update relationship field
# Change: **Relationship:** prospect
@ -1138,8 +1186,8 @@ This ensures:
## Check Each New Link
If you added \`[[People/Jennifer]]\` to \`Organizations/Acme Corp.md\`:
\`\`\`bash
executeCommand("grep 'Acme Corp' '{knowledge_folder}/People/Jennifer.md'")
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "{knowledge_folder}/People/Jennifer.md" })
\`\`\`
If not found, update Jennifer.md to add the link.
@ -1179,7 +1227,7 @@ If not found, update Jennifer.md to add the link.
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links} {[State changes if any]}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]}
## Key facts
{Substantive facts only. Leave empty if none. Never include data gap commentary.}
@ -1216,7 +1264,7 @@ If not found, update Jennifer.md to add the link.
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links} {[State changes if any]}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]}
## Key facts
{Substantive facts only. Leave empty if none.}
@ -1373,11 +1421,11 @@ Not mass email, not automated. Continue.
- Variants: "Sarah Chen", "sarah@acme.com", "David Kim", "David", "Jennifer", "CTO", "Acme", "the pilot"
### Step 3: Search Existing Notes
\`\`\`bash
executeCommand("grep -r -i -l 'Sarah Chen' 'knowledge/'")
\`\`\`
workspace-grep({ pattern: "Sarah Chen", searchPath: "knowledge" })
# Output: (none)
executeCommand("grep -r -i -l 'acme' 'knowledge/'")
workspace-grep({ pattern: "acme", searchPath: "knowledge" })
# Output: (none)
\`\`\`
@ -1516,8 +1564,8 @@ VP Engineering, Acme Corp
### Step 1: Filter
Check for existing relationship:
\`\`\`bash
executeCommand("grep -r -i -l 'sarah@acme.com' 'knowledge/'")
\`\`\`
workspace-grep({ pattern: "sarah@acme.com", searchPath: "knowledge" })
# Output: notes/People/Sarah Chen.md
\`\`\`
@ -1652,11 +1700,11 @@ John Smith
### Step 1: Filter
Check for existing relationship:
\`\`\`bash
executeCommand("grep -r -i -l 'randomvendor' 'knowledge/'")
\`\`\`
workspace-grep({ pattern: "randomvendor", searchPath: "knowledge" })
# Output: (none)
executeCommand("grep -r -i -l 'John Smith' 'knowledge/'")
workspace-grep({ pattern: "John Smith", searchPath: "knowledge" })
# Output: (none)
\`\`\`
@ -1699,8 +1747,8 @@ David
### Step 1: Filter
Check for sender:
\`\`\`bash
executeCommand("grep -r -i -l 'david@friendly.vc' 'knowledge/'")
\`\`\`
workspace-grep({ pattern: "david@friendly.vc", searchPath: "knowledge" })
# Output: notes/People/David Park.md
\`\`\`
@ -1803,10 +1851,16 @@ Business banking provider. Account setup completed January 2025.
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------|
| Meeting | Yes | Yes | Yes |
| Voice memo | Yes | Yes | Yes |
| Email (known contact) | No | Yes | Yes |
| Email (unknown contact) | No | No (SKIP) | No |
| Email (warm intro) | Yes (exception) | Yes | Yes |
**Voice memo activity format:** Always include a link to the source voice memo:
\`\`\`
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
\`\`\`
---
# State Change Reference

View file

@ -7,19 +7,25 @@ tools:
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
executeCommand:
workspace-grep:
type: builtin
name: executeCommand
name: workspace-grep
workspace-glob:
type: builtin
name: workspace-glob
---
# Task
You are a memory agent. Given a single source file (email or meeting transcript), you will:
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
1. **Determine source type (meeting or email)**
2. **Evaluate if the source is worth processing**
@ -31,7 +37,7 @@ You are a memory agent. Given a single source file (email or meeting transcript)
8. Create new notes or update existing notes
9. **Apply state changes to existing notes**
The core rule: **Capture broadly. Both meetings and emails create notes for most external contacts.**
The core rule: **Capture broadly. Meetings, voice memos, and emails create notes for most external contacts.**
You have full read access to the existing knowledge directory. Use this extensively to:
- Find existing notes for people, organizations, projects mentioned
@ -70,20 +76,51 @@ When you need to:
# Tools Available
You have access to \`executeCommand\` to run shell commands:
You have access to these tools:
**For reading files:**
\`\`\`
executeCommand("ls {path}") # List directory contents
executeCommand("cat {path}") # Read file contents
executeCommand("head -50 {path}") # Read first 50 lines
executeCommand("write {path} {content}") # Create or overwrite file
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
\`\`\`
**Important:** Use shell escaping for paths with spaces:
**For creating NEW files:**
\`\`\`
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\`
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
**For editing EXISTING files (preferred for updates):**
\`\`\`
workspace-edit({
path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
})
\`\`\`
**For listing directories:**
\`\`\`
workspace-readdir({ path: "knowledge/People" })
\`\`\`
**For creating directories:**
\`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\`
**For searching files:**
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\`
**For finding files by pattern:**
\`\`\`
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
\`\`\`
**IMPORTANT:**
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
- Use \`workspace-writeFile\` only for creating new notes
- Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output
@ -120,7 +157,7 @@ This mode prioritizes comprehensive capture over selectivity. The goal is to nev
Read the source file and determine if it's a meeting or email.
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
**Meeting indicators:**
@ -133,9 +170,15 @@ executeCommand("cat '{source_file}'")
- Has \`Subject:\` field
- Email signature
**Voice memo indicators:**
- Has \`**Type:** voice memo\` field
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Set processing mode:**
- \`source_type = "meeting"\` → Create notes for all external attendees
- \`source_type = "email"\` → Create notes for sender if identifiable human
- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings)
---
@ -204,7 +247,7 @@ If processing, continue to Step 2.
# Step 2: Read and Parse Source File
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
Extract metadata:
@ -291,8 +334,8 @@ From index, find matches for:
## 3d: Read Full Notes When Needed
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
\`\`\`
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
**Why read these notes:**
@ -509,7 +552,12 @@ Write 2-3 sentences covering their role/function, context of the relationship, a
One line summarizing this source's relevance to the entity:
\`\`\`
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
\`\`\`
**For voice memos:** Include a link to the voice memo file using the Path field:
\`\`\`
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
\`\`\`
---
@ -555,21 +603,29 @@ Before writing:
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Issue exactly one write/edit command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
- Do NOT batch multiple write commands in a single response.
**For new entities:**
\`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
**For NEW entities (use workspace-writeFile):**
\`\`\`
workspace-writeFile({
path: "{knowledge_folder}/People/Jennifer.md",
data: "# Jennifer\\n\\n## Summary\\n..."
})
\`\`\`
**For existing entities:**
- Read current content first
- Add activity entry at TOP (reverse chronological)
- Update "Last seen" date
- Add new key facts (skip duplicates)
- Add new open items
**For EXISTING entities (use workspace-edit):**
- Read current content first with workspace-readFile
- Use workspace-edit to add activity entry at TOP (reverse chronological)
- Update fields using targeted edits
\`\`\`
workspace-edit({
path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
})
\`\`\`
## 9b: Apply State Changes
@ -638,7 +694,7 @@ After writing, verify links go both ways.
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
@ -673,7 +729,7 @@ After writing, verify links go both ways.
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary}
## Key facts
{Substantive facts only. Leave empty if none.}
@ -705,7 +761,7 @@ After writing, verify links go both ways.
- [[Topics/{Topic}]] {relationship}
## Timeline
**{YYYY-MM-DD}** ({meeting|email})
**{YYYY-MM-DD}** ({meeting|email|voice memo})
{What happened.}
## Decisions
@ -756,9 +812,15 @@ After writing, verify links go both ways.
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------|
| Meeting | Yes ALL external attendees | Yes | Yes |
| Voice memo | Yes all mentioned entities | Yes | Yes |
| Email (any human sender) | Yes | Yes | Yes |
| Email (automated/newsletter) | No (SKIP) | No | No |
**Voice memo activity format:** Always include a link to the source voice memo:
\`\`\`
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
\`\`\`
**Philosophy:** Capture broadly, filter later if needed.
---

View file

@ -7,19 +7,25 @@ tools:
workspace-readFile:
type: builtin
name: workspace-readFile
workspace-edit:
type: builtin
name: workspace-edit
workspace-readdir:
type: builtin
name: workspace-readdir
workspace-mkdir:
type: builtin
name: workspace-mkdir
executeCommand:
workspace-grep:
type: builtin
name: executeCommand
name: workspace-grep
workspace-glob:
type: builtin
name: workspace-glob
---
# Task
You are a memory agent. Given a single source file (email or meeting transcript), you will:
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
1. **Determine source type (meeting or email)**
2. **Evaluate if the source is worth processing**
@ -70,20 +76,51 @@ When you need to:
# Tools Available
You have access to \`executeCommand\` to run shell commands:
You have access to these tools:
**For reading files:**
\`\`\`
executeCommand("ls {path}") # List directory contents
executeCommand("cat {path}") # Read file contents
executeCommand("head -50 {path}") # Read first 50 lines
executeCommand("write {path} {content}") # Create or overwrite file
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
\`\`\`
**Important:** Use shell escaping for paths with spaces:
**For creating NEW files:**
\`\`\`
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\`
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
**For editing EXISTING files (preferred for updates):**
\`\`\`
workspace-edit({
path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
})
\`\`\`
**For listing directories:**
\`\`\`
workspace-readdir({ path: "knowledge/People" })
\`\`\`
**For creating directories:**
\`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\`
**For searching files:**
\`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\`
**For finding files by pattern:**
\`\`\`
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
\`\`\`
**IMPORTANT:**
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
- Use \`workspace-writeFile\` only for creating new notes
- Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output
@ -119,7 +156,7 @@ Either:
Read the source file and determine if it's a meeting or email.
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
**Meeting indicators:**
@ -132,9 +169,15 @@ executeCommand("cat '{source_file}'")
- Has \`Subject:\` field
- Email signature
**Voice memo indicators:**
- Has \`**Type:** voice memo\` field
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
- Has \`## Transcript\` section
**Set processing mode:**
- \`source_type = "meeting"\` → Can create new notes
- \`source_type = "email"\` → Can create notes if personalized and relevant
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
---
@ -344,7 +387,7 @@ If processing, continue to Step 2.
# Step 2: Read and Parse Source File
\`\`\`
executeCommand("cat '{source_file}'")
workspace-readFile({ path: "{source_file}" })
\`\`\`
Extract metadata:
@ -441,7 +484,7 @@ From index, find matches for:
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
**Why read these notes:**
@ -525,27 +568,27 @@ Resolution Map:
When multiple candidates match a variant, disambiguate:
**By organization (strongest signal):**
\`\`\`bash
\`\`\`
# "David" could be David Kim or David Chen
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Kim.md'")
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Output: **Organization:** [[Acme Corp]]
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Chen.md'")
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
# Output: **Organization:** [[Other Corp]]
# Source is from Acme context "David" = "David Kim"
\`\`\`
**By email (definitive):**
\`\`\`bash
executeCommand("grep -i 'david@acme.com' '{knowledge_folder}/People/David Kim.md'")
\`\`\`
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Exact email match is definitive
\`\`\`
**By role:**
\`\`\`bash
\`\`\`
# Source mentions "their CTO"
executeCommand("grep -r -i 'Role.*CTO' '{knowledge_folder}/People/'")
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
# Filter results by organization context
\`\`\`
@ -784,7 +827,12 @@ The summary should answer: **"Who is this person and why do I know them?"**
One line summarizing this source's relevance to the entity:
\`\`\`
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
\`\`\`
**For voice memos:** Include a link to the voice memo file using the Path field:
\`\`\`
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
\`\`\`
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
@ -877,8 +925,8 @@ STATE CHANGES:
Before writing, compare extracted content against existing notes.
## Check Activity Log
\`\`\`bash
executeCommand("grep '2025-01-15' '{knowledge_folder}/People/Sarah Chen.md'")
\`\`\`
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\`
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
@ -908,28 +956,28 @@ If new info contradicts existing:
**IMPORTANT: Write sequentially, one file at a time.**
- Generate content for exactly one note.
- Issue exactly one \`write\` command.
- Issue exactly one write/edit command.
- Wait for the tool to return before generating the next note.
- Do NOT batch multiple \`write\` commands in a single response.
- Do NOT batch multiple write commands in a single response.
**For new entities (meetings and qualifying emails):**
\`\`\`bash
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
**For NEW entities (use workspace-writeFile):**
\`\`\`
workspace-writeFile({
path: "{knowledge_folder}/People/Jennifer.md",
data: "# Jennifer\\n\\n## Summary\\n..."
})
\`\`\`
**For existing entities:**
- Read current content first
- Add activity entry at TOP of Activity section (reverse chronological)
- Update "Last seen" date
- Add new key facts (skip duplicates)
- Add new open items
- Add new decisions
- Add new relationships
- Update summary ONLY if significant new understanding
\`\`\`bash
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
# ... modify content ...
executeCommand("write '{knowledge_folder}/People/Sarah Chen.md' '{full_updated_content}'")
**For EXISTING entities (use workspace-edit):**
- Read current content first with workspace-readFile
- Use workspace-edit to add activity entry at TOP (reverse chronological)
- Update fields using targeted edits
\`\`\`
workspace-edit({
path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
})
\`\`\`
## 9b: Apply State Changes
@ -1000,7 +1048,7 @@ After writing, verify links go both ways.
- [[Projects/{Project}]] {role}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
@ -1035,7 +1083,7 @@ After writing, verify links go both ways.
- [[Projects/{Project}]] {relationship}
## Activity
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
## Key facts
{Substantive facts only. Leave empty if none.}
@ -1119,11 +1167,17 @@ After writing, verify links go both ways.
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|-------------|---------------|----------------|------------------------|
| Meeting | Yes | Yes | Yes |
| Voice memo | Yes | Yes | Yes |
| Email (personalized, business-relevant) | Yes | Yes | Yes |
| Email (mass/automated/consumer) | No (SKIP) | No | No |
| Email (cold outreach with personalization) | Yes | Yes | Yes |
| Email (generic cold outreach) | No | No | No |
**Voice memo activity format:** Always include a link to the source voice memo:
\`\`\`
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
\`\`\`
---
# Error Handling

View file

@ -84,6 +84,22 @@ export async function cleanup() {
}
}
/**
* Force-close all MCP client connections.
* Used during force abort to immediately reject any pending MCP tool calls.
* Clients will be lazily reconnected on next use.
*/
export async function forceCloseAllMcpClients(): Promise<void> {
for (const [serverName, { client }] of Object.entries(clients)) {
try {
await client?.close();
} catch {
// Ignore errors during force close
}
delete clients[serverName];
}
}
export async function listServers(): Promise<z.infer<typeof McpServerList>> {
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
const { mcpServers } = await repo.getConfig();

View file

@ -4,7 +4,15 @@ import fs from "fs/promises";
import path from "path";
import z from "zod";
const DEFAULT_MCP_SERVERS = {
exa: {
type: "http" as const,
url: "https://mcp.exa.ai/mcp",
},
};
export interface IMcpConfigRepo {
ensureConfig(): Promise<void>;
getConfig(): Promise<z.infer<typeof McpServerConfig>>;
upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void>;
delete(serverName: string): Promise<void>;
@ -13,15 +21,11 @@ export interface IMcpConfigRepo {
export class FSMcpConfigRepo implements IMcpConfigRepo {
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
constructor() {
this.ensureDefaultConfig();
}
private async ensureDefaultConfig(): Promise<void> {
async ensureConfig(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2));
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2));
}
}

View file

@ -0,0 +1,174 @@
import fs from "node:fs/promises";
import path from "node:path";
import z from "zod";
import { WorkDir } from "../config/config.js";
const CACHE_PATH = path.join(WorkDir, "config", "models.dev.json");
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
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();
const ModelsDevProvider = z.object({
id: z.string().optional(),
name: z.string(),
models: z.record(z.string(), ModelsDevModel),
}).passthrough();
const ModelsDevResponse = z.record(z.string(), ModelsDevProvider);
type ProviderSummary = {
id: string;
name: string;
models: Array<{
id: string;
name?: string;
release_date?: string;
}>;
};
type CacheFile = {
fetchedAt: string;
data: unknown;
};
async function readCache(): Promise<CacheFile | null> {
try {
const raw = await fs.readFile(CACHE_PATH, "utf8");
return JSON.parse(raw) as CacheFile;
} catch {
return null;
}
}
async function writeCache(data: unknown): Promise<void> {
const payload: CacheFile = {
fetchedAt: new Date().toISOString(),
data,
};
await fs.writeFile(CACHE_PATH, JSON.stringify(payload, null, 2));
}
async function fetchModelsDev(): Promise<unknown> {
const response = await fetch("https://models.dev/api.json", {
headers: { "User-Agent": "Rowboat" },
});
if (!response.ok) {
throw new Error(`models.dev fetch failed: ${response.status}`);
}
return response.json();
}
function isCacheFresh(fetchedAt: string): boolean {
const age = Date.now() - new Date(fetchedAt).getTime();
return age < CACHE_TTL_MS;
}
async function getModelsDevData(): Promise<{ data: z.infer<typeof ModelsDevResponse>; fetchedAt?: string }> {
const cached = await readCache();
if (cached?.fetchedAt && isCacheFresh(cached.fetchedAt)) {
const parsed = ModelsDevResponse.safeParse(cached.data);
if (parsed.success) {
return { data: parsed.data, fetchedAt: cached.fetchedAt };
}
}
try {
const fresh = await fetchModelsDev();
const parsed = ModelsDevResponse.parse(fresh);
await writeCache(parsed);
return { data: parsed, fetchedAt: new Date().toISOString() };
} catch (error) {
if (cached) {
const parsed = ModelsDevResponse.safeParse(cached.data);
if (parsed.success) {
return { data: parsed.data, fetchedAt: cached.fetchedAt };
}
}
throw error;
}
}
function scoreProvider(flavor: string, id: string, name: string): number {
const normalizedId = id.toLowerCase();
const normalizedName = name.toLowerCase();
let score = 0;
if (normalizedId === flavor) score += 100;
if (normalizedName.includes(flavor)) score += 20;
if (flavor === "google") {
if (normalizedName.includes("gemini")) score += 10;
if (normalizedName.includes("vertex")) score -= 5;
}
return score;
}
function pickProvider(
data: z.infer<typeof ModelsDevResponse>,
flavor: "openai" | "anthropic" | "google",
): z.infer<typeof ModelsDevProvider> | null {
if (data[flavor]) return data[flavor];
let best: { score: number; provider: z.infer<typeof ModelsDevProvider> } | null = null;
for (const [id, provider] of Object.entries(data)) {
const s = scoreProvider(flavor, id, provider.name);
if (s <= 0) continue;
if (!best || s > best.score) {
best = { score: s, provider };
}
}
return best?.provider ?? null;
}
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;
}
function supportsToolCall(model: z.infer<typeof ModelsDevModel>): boolean {
return model.tool_call === true;
}
function normalizeModels(models: Record<string, z.infer<typeof ModelsDevModel>>): ProviderSummary["models"] {
const list = Object.entries(models)
.map(([id, model]) => ({
id: model.id ?? id,
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))
.map(({ id, name, release_date }) => ({ id, name, release_date }));
list.sort((a, b) => {
const aDate = a.release_date ? Date.parse(a.release_date) : 0;
const bDate = b.release_date ? Date.parse(b.release_date) : 0;
return bDate - aDate;
});
return list;
}
export async function listOnboardingModels(): Promise<{ providers: ProviderSummary[]; lastUpdated?: string }> {
const { data, fetchedAt } = await getModelsDevData();
const providers: ProviderSummary[] = [];
const flavors: Array<"openai" | "anthropic" | "google"> = ["openai", "anthropic", "google"];
for (const flavor of flavors) {
const provider = pickProvider(data, flavor);
if (!provider) continue;
providers.push({
id: flavor,
name: provider.name,
models: normalizeModels(provider.models),
});
}
return { providers, lastUpdated: fetchedAt };
}

View file

@ -1,119 +1,87 @@
import { ProviderV2 } from "@ai-sdk/provider";
import { createGateway } from "ai";
import { createGateway, generateText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createOllama } from "ollama-ai-provider-v2";
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { IModelConfigRepo } from "./repo.js";
import container from "../di/container.js";
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
import z from "zod";
export const Flavor = z.enum([
"rowboat [free]",
"aigateway",
"anthropic",
"google",
"ollama",
"openai",
"openai-compatible",
"openrouter",
]);
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
export const Provider = z.object({
flavor: Flavor,
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const ModelConfig = z.object({
providers: z.record(z.string(), Provider),
defaults: z.object({
provider: z.string(),
model: z.string(),
}),
});
const providerMap: Record<string, ProviderV2> = {};
export async function getProvider(name: string = ""): Promise<ProviderV2> {
// get model conf
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
const modelConfig = await repo.getConfig();
if (!modelConfig) {
throw new Error("Model config not found");
}
if (!name) {
name = modelConfig.defaults.provider;
}
if (providerMap[name]) {
return providerMap[name];
}
const providerConfig = modelConfig.providers[name];
if (!providerConfig) {
throw new Error(`Provider ${name} not found`);
}
const { apiKey, baseURL, headers } = providerConfig;
switch (providerConfig.flavor) {
case "rowboat [free]":
providerMap[name] = createGateway({
apiKey: "rowboatx",
baseURL: "https://ai-gateway.rowboatlabs.com/v1/ai",
});
break;
case "openai":
providerMap[name] = createOpenAI({
export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
const { apiKey, baseURL, headers } = config;
switch (config.flavor) {
case "openai":
return createOpenAI({
apiKey,
baseURL,
headers,
});
break;
case "aigateway":
providerMap[name] = createGateway({
return createGateway({
apiKey,
baseURL,
headers
});
break;
case "anthropic":
providerMap[name] = createAnthropic({
apiKey,
baseURL,
headers
});
break;
case "google":
providerMap[name] = createGoogleGenerativeAI({
apiKey,
baseURL,
headers
});
break;
case "ollama":
providerMap[name] = createOllama({
baseURL,
headers
});
break;
case "openai-compatible":
providerMap[name] = createOpenAICompatible({
name,
apiKey,
baseURL : baseURL || "",
headers,
});
break;
case "openrouter":
providerMap[name] = createOpenRouter({
case "anthropic":
return createAnthropic({
apiKey,
baseURL,
headers
headers,
});
case "google":
return createGoogleGenerativeAI({
apiKey,
baseURL,
headers,
});
case "ollama":
return createOllama({
baseURL,
headers,
});
case "openai-compatible":
return createOpenAICompatible({
name: "openai-compatible",
apiKey,
baseURL: baseURL || "",
headers,
});
case "openrouter":
return createOpenRouter({
apiKey,
baseURL,
headers,
});
break;
default:
throw new Error(`Provider ${name} not found`);
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
}
return providerMap[name];
}
}
export async function testModelConnection(
providerConfig: z.infer<typeof Provider>,
model: string,
timeoutMs: number = 8000,
): Promise<{ success: boolean; error?: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const provider = createProvider(providerConfig);
const languageModel = provider.languageModel(model);
await generateText({
model: languageModel,
prompt: "ping",
abortSignal: controller.signal,
});
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : "Connection test failed";
return { success: false, error: message };
} finally {
clearTimeout(timeout);
}
}

View file

@ -1,36 +1,26 @@
import { ModelConfig, Provider } from "./models.js";
import { ModelConfig } from "./models.js";
import { WorkDir } from "../config/config.js";
import fs from "fs/promises";
import path from "path";
import z from "zod";
export interface IModelConfigRepo {
ensureConfig(): Promise<void>;
getConfig(): Promise<z.infer<typeof ModelConfig>>;
upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void>;
delete(providerName: string): Promise<void>;
setDefault(providerName: string, model: string): Promise<void>;
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
}
const defaultConfig: z.infer<typeof ModelConfig> = {
providers: {
"rowboat": {
flavor: "rowboat [free]",
}
provider: {
flavor: "openai",
},
defaults: {
provider: "rowboat",
model: "gpt-5.1",
}
model: "gpt-4.1",
};
export class FSModelConfigRepo implements IModelConfigRepo {
private readonly configPath = path.join(WorkDir, "config", "models.json");
constructor() {
this.ensureDefaultConfig();
}
private async ensureDefaultConfig(): Promise<void> {
async ensureConfig(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
@ -43,28 +33,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
return ModelConfig.parse(JSON.parse(config));
}
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
}
async upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void> {
const conf = await this.getConfig();
conf.providers[providerName] = config;
await this.setConfig(conf);
}
async delete(providerName: string): Promise<void> {
const conf = await this.getConfig();
delete conf.providers[providerName];
await this.setConfig(conf);
}
async setDefault(providerName: string, model: string): Promise<void> {
const conf = await this.getConfig();
conf.defaults = {
provider: providerName,
model,
};
await this.setConfig(conf);
}
}
}

View file

@ -0,0 +1,170 @@
import { ChildProcess } from "child_process";
export interface IAbortRegistry {
/**
* Create and track an AbortController for a run.
* Returns the AbortSignal to thread through all operations.
*/
createForRun(runId: string): AbortSignal;
/**
* Track a child process for a run (so we can kill it on abort).
*/
registerProcess(runId: string, process: ChildProcess): void;
/**
* Untrack a child process after it exits.
*/
unregisterProcess(runId: string, process: ChildProcess): void;
/**
* Graceful abort:
* 1. Fires the AbortSignal (cancels LLM streaming, etc.)
* 2. Sends SIGTERM to all tracked process groups
* 3. Schedules SIGKILL fallback after grace period
*/
abort(runId: string): void;
/**
* Force abort:
* 1. Fires AbortSignal if not already fired
* 2. Sends SIGKILL to all tracked process groups immediately
*/
forceAbort(runId: string): void;
/**
* Check if a run has been aborted.
*/
isAborted(runId: string): boolean;
/**
* Clean up tracking state after a run completes or is fully stopped.
*/
cleanup(runId: string): void;
}
interface RunAbortState {
controller: AbortController;
processes: Set<ChildProcess>;
killTimers: Set<ReturnType<typeof setTimeout>>;
}
const SIGKILL_GRACE_MS = 200;
export class InMemoryAbortRegistry implements IAbortRegistry {
private runs: Map<string, RunAbortState> = new Map();
createForRun(runId: string): AbortSignal {
// If a previous run state exists, clean it up first
this.cleanup(runId);
const state: RunAbortState = {
controller: new AbortController(),
processes: new Set(),
killTimers: new Set(),
};
this.runs.set(runId, state);
return state.controller.signal;
}
registerProcess(runId: string, process: ChildProcess): void {
const state = this.runs.get(runId);
if (!state) return;
state.processes.add(process);
// Auto-unregister when process exits
const onExit = () => {
state.processes.delete(process);
};
process.once("exit", onExit);
process.once("error", onExit);
}
unregisterProcess(runId: string, process: ChildProcess): void {
const state = this.runs.get(runId);
if (!state) return;
state.processes.delete(process);
}
abort(runId: string): void {
const state = this.runs.get(runId);
if (!state) return;
// 1. Fire the abort signal
if (!state.controller.signal.aborted) {
state.controller.abort();
}
// 2. SIGTERM all tracked process groups
for (const proc of state.processes) {
this.killProcessTree(proc, "SIGTERM");
// 3. Schedule SIGKILL fallback
const timer = setTimeout(() => {
if (!proc.killed) {
this.killProcessTree(proc, "SIGKILL");
}
state.killTimers.delete(timer);
}, SIGKILL_GRACE_MS);
state.killTimers.add(timer);
}
}
forceAbort(runId: string): void {
const state = this.runs.get(runId);
if (!state) return;
// 1. Fire abort signal if not already
if (!state.controller.signal.aborted) {
state.controller.abort();
}
// 2. Clear any pending graceful kill timers
for (const timer of state.killTimers) {
clearTimeout(timer);
}
state.killTimers.clear();
// 3. SIGKILL all tracked process groups immediately
for (const proc of state.processes) {
this.killProcessTree(proc, "SIGKILL");
}
}
isAborted(runId: string): boolean {
const state = this.runs.get(runId);
return state?.controller.signal.aborted ?? false;
}
cleanup(runId: string): void {
const state = this.runs.get(runId);
if (!state) return;
// Clear any pending kill timers
for (const timer of state.killTimers) {
clearTimeout(timer);
}
this.runs.delete(runId);
}
/**
* Kill a process tree using negative PID (process group kill on Unix).
* Falls back to direct kill if group kill fails.
*/
private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
if (!proc.pid || proc.killed) return;
try {
// Negative PID kills the entire process group (Unix)
process.kill(-proc.pid, signal);
} catch {
// Fallback: kill just the process directly
try {
proc.kill(signal);
} catch {
// Process may already be dead
}
}
}
}

View file

@ -5,6 +5,8 @@ import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, R
import { IRunsRepo } from "./repo.js";
import { IAgentRuntime } from "../agents/runtime.js";
import { IBus } from "../application/lib/bus.js";
import { IAbortRegistry } from "./abort-registry.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
const repo = container.resolve<IRunsRepo>('runsRepo');
@ -46,9 +48,21 @@ export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof
runtime.trigger(runId);
}
export async function stop(runId: string): Promise<void> {
console.log(`Stopping run ${runId}`);
throw new Error('Not implemented');
export async function stop(runId: string, force: boolean = false): Promise<void> {
const abortRegistry = container.resolve<IAbortRegistry>('abortRegistry');
if (force && abortRegistry.isAborted(runId)) {
// Second click: aggressive cleanup — SIGKILL + force close MCP clients
console.log(`Force stopping run ${runId}`);
abortRegistry.forceAbort(runId);
await forceCloseAllMcpClients();
} else {
// First click: graceful — fires AbortSignal + SIGTERM
console.log(`Gracefully stopping run ${runId}`);
abortRegistry.abort(runId);
}
// Note: The run-stopped event is emitted by AgentRuntime.trigger() when it detects the abort.
// This avoids duplicate events and ensures proper sequencing.
}
export async function fetchRun(runId: string): Promise<z.infer<typeof Run>> {

View file

@ -4,7 +4,8 @@
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"types": ["node"]
"types": ["node"],
"jsx": "react-jsx"
},
"include": [
"src"

View file

@ -1,6 +1,7 @@
import { PrefixLogger } from './prefix-logger.js';
export * as ipc from './ipc.js';
export * as models from './models.js';
export * as workspace from './workspace.js';
export * as mcp from './mcp.js';
export { PrefixLogger };
export { PrefixLogger };

View file

@ -2,6 +2,7 @@ import { z } from 'zod';
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
import { ListToolsResponse } from './mcp.js';
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
import { LlmModelConfig } from './models.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -151,6 +152,7 @@ const ipcSchemas = {
'runs:stop': {
req: z.object({
runId: z.string(),
force: z.boolean().optional().default(false),
}),
res: z.object({
success: z.literal(true),
@ -172,9 +174,38 @@ const ipcSchemas = {
req: z.null(),
res: z.null(),
},
'models:list': {
req: z.null(),
res: z.object({
providers: z.array(z.object({
id: z.string(),
name: z.string(),
models: z.array(z.object({
id: z.string(),
name: z.string().optional(),
release_date: z.string().optional(),
})),
})),
lastUpdated: z.string().optional(),
}),
},
'models:test': {
req: LlmModelConfig,
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'models:saveConfig': {
req: LlmModelConfig,
res: z.object({
success: z.literal(true),
}),
},
'oauth:connect': {
req: z.object({
provider: z.string(),
clientId: z.string().optional(),
}),
res: z.object({
success: z.boolean(),
@ -243,6 +274,85 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Composio integration channels
'composio:is-configured': {
req: z.null(),
res: z.object({
configured: z.boolean(),
}),
},
'composio:set-api-key': {
req: z.object({
apiKey: z.string(),
}),
res: z.object({
success: z.boolean(),
error: z.string().optional(),
}),
},
'composio:initiate-connection': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
success: z.boolean(),
redirectUrl: z.string().optional(),
connectedAccountId: z.string().optional(),
error: z.string().optional(),
}),
},
'composio:get-connection-status': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
isConnected: z.boolean(),
status: z.string().optional(),
}),
},
'composio:sync-connection': {
req: z.object({
toolkitSlug: z.string(),
connectedAccountId: z.string(),
}),
res: z.object({
status: z.string(),
}),
},
'composio:disconnect': {
req: z.object({
toolkitSlug: z.string(),
}),
res: z.object({
success: z.boolean(),
}),
},
'composio:list-connected': {
req: z.null(),
res: z.object({
toolkits: z.array(z.string()),
}),
},
'composio:execute-action': {
req: z.object({
actionSlug: z.string(),
toolkitSlug: z.string(),
input: z.record(z.string(), z.unknown()),
}),
res: z.object({
success: z.boolean(),
data: z.unknown(),
error: z.string().optional(),
}),
},
'composio:didConnect': {
req: z.object({
toolkitSlug: z.string(),
success: z.boolean(),
error: z.string().optional(),
}),
res: z.null(),
},
} as const;
// ============================================================================
@ -292,4 +402,4 @@ export function validateResponse<K extends keyof IPCChannels>(
): IPCChannels[K]['res'] {
const schema = ipcSchemas[channel].res;
return schema.parse(data) as IPCChannels[K]['res'];
}
}

View file

@ -0,0 +1,13 @@
import { z } from "zod";
export const LlmProvider = z.object({
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]),
apiKey: z.string().optional(),
baseURL: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const LlmModelConfig = z.object({
provider: LlmProvider,
model: z.string(),
});

View file

@ -80,6 +80,11 @@ export const RunErrorEvent = BaseRunEvent.extend({
error: z.string(),
});
export const RunStoppedEvent = BaseRunEvent.extend({
type: z.literal("run-stopped"),
reason: z.enum(["user-requested", "force-stopped"]).optional(),
});
export const RunEvent = z.union([
RunProcessingStartEvent,
RunProcessingEndEvent,
@ -94,6 +99,7 @@ export const RunEvent = z.union([
ToolPermissionRequestEvent,
ToolPermissionResponseEvent,
RunErrorEvent,
RunStoppedEvent,
]);
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({

462
apps/x/pnpm-lock.yaml generated
View file

@ -290,6 +290,9 @@ importers:
'@ai-sdk/provider':
specifier: ^2.0.0
version: 2.0.1
'@composio/core':
specifier: ^0.6.0
version: 0.6.2(zod@4.2.1)
'@google-cloud/local-auth':
specifier: ^3.0.1
version: 3.0.1(encoding@0.1.13)
@ -299,6 +302,12 @@ importers:
'@openrouter/ai-sdk-provider':
specifier: ^1.2.6
version: 1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)
'@react-pdf/renderer':
specifier: ^4.3.2
version: 4.3.2(react@19.2.3)
'@types/react':
specifier: ^19.2.7
version: 19.2.7
'@x/shared':
specifier: workspace:*
version: link:../shared
@ -329,6 +338,9 @@ importers:
openid-client:
specifier: ^6.8.1
version: 6.8.1
react:
specifier: ^19.2.3
version: 19.2.3
yaml:
specifier: ^2.8.2
version: 2.8.2
@ -631,6 +643,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.6':
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
@ -661,6 +677,19 @@ packages:
'@chevrotain/utils@11.0.3':
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
'@composio/client@0.1.0-alpha.56':
resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==}
'@composio/core@0.6.2':
resolution: {integrity: sha512-q4ICwbKdSfFcdWHkgaKf8FBUu7/dohYOCn04MhPU2JcbaVOZ2haGEGv7TdACxMx6zUQpx6HVGKCA0ewH+9MBQA==}
peerDependencies:
zod: ^3.25 || ^4
'@composio/json-schema-to-zod@0.1.20':
resolution: {integrity: sha512-d4V34itLrUWG/VBh7ciznKcxF/T22MBLHmuEzHoX0zsBOHsUmjYz5qtDh20S2p3FE+HHvLZxpXiv8yfdd4yI+Q==}
peerDependencies:
zod: '>=3.25.76 <5'
'@electron-forge/cli@7.11.1':
resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==}
engines: {node: '>= 16.4.0'}
@ -1947,6 +1976,49 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-pdf/fns@3.1.2':
resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
'@react-pdf/font@4.0.4':
resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
'@react-pdf/image@3.0.4':
resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
'@react-pdf/layout@4.4.2':
resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
'@react-pdf/pdfkit@4.1.0':
resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
'@react-pdf/png-js@3.0.0':
resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
'@react-pdf/primitives@4.1.1':
resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
'@react-pdf/reconciler@2.0.0':
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/render@4.3.2':
resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
'@react-pdf/renderer@4.3.2':
resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@react-pdf/stylesheet@6.1.2':
resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
'@react-pdf/textkit@6.1.0':
resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
'@react-pdf/types@2.9.2':
resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
'@remirror/core-constants@3.0.0':
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
@ -2307,6 +2379,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@swc/helpers@0.5.18':
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
'@szmarczak/http-timer@4.0.6':
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
engines: {node: '>=10'}
@ -2931,6 +3006,9 @@ packages:
abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
abs-svg-path@0.1.1:
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@ -3071,6 +3149,10 @@ packages:
base32-encode@1.2.0:
resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==}
base64-js@0.0.8:
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
engines: {node: '>= 0.4'}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
@ -3078,6 +3160,9 @@ packages:
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
bignumber.js@9.3.1:
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
@ -3114,6 +3199,12 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
browserify-zlib@0.2.0:
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
browserslist@4.28.1:
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@ -3251,6 +3342,10 @@ packages:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
clone@2.1.2:
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
engines: {node: '>=0.8'}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@ -3271,6 +3366,9 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@ -3369,6 +3467,9 @@ packages:
resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==}
engines: {node: '>=12.10'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@ -3612,6 +3713,9 @@ packages:
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
dfa@1.2.0:
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
dir-compare@4.2.0:
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
@ -3674,6 +3778,9 @@ packages:
engines: {node: '>= 12.20.55'}
hasBin: true
emoji-regex-xs@1.0.0:
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -3975,6 +4082,9 @@ packages:
debug:
optional: true
fontkit@2.0.4:
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@ -4298,6 +4408,12 @@ packages:
hosted-git-info@2.8.9:
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
hsl-to-hex@1.0.0:
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
hsl-to-rgb-for-reals@1.1.1:
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@ -4330,6 +4446,9 @@ packages:
humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
hyphen@1.14.1:
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -4419,6 +4538,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-core-module@2.16.1:
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
engines: {node: '>= 0.4'}
@ -4506,6 +4628,9 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
jay-peg@1.1.1:
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@ -4679,6 +4804,9 @@ packages:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
linebreak@1.1.0:
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
linkify-it@5.0.0:
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
@ -4735,6 +4863,10 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
lower-case@2.0.2:
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
@ -4855,6 +4987,9 @@ packages:
mdurl@2.0.0:
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
media-engine@1.0.3:
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@ -5200,6 +5335,9 @@ packages:
normalize-package-data@2.5.0:
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
normalize-svg-path@1.1.0:
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
normalize-url@6.1.0:
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
engines: {node: '>=10'}
@ -5253,6 +5391,18 @@ packages:
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
engines: {node: '>=8'}
openai@6.17.0:
resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
openid-client@6.8.1:
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
@ -5317,6 +5467,12 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -5335,6 +5491,9 @@ packages:
resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==}
engines: {node: '>=0.10.0'}
parse-svg-path@0.1.2:
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
@ -5428,6 +5587,9 @@ packages:
points-on-path@0.2.1:
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@ -5472,6 +5634,9 @@ packages:
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
engines: {node: '>=10'}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@ -5555,6 +5720,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
pusher-js@8.4.0:
resolution: {integrity: sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==}
qs@6.14.1:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
@ -5565,6 +5733,9 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
quick-lru@5.1.1:
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
engines: {node: '>=10'}
@ -5588,6 +5759,9 @@ packages:
peerDependencies:
react: ^19.2.3
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@ -5745,6 +5919,9 @@ packages:
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
restructure@3.0.2:
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
retry@0.12.0:
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
engines: {node: '>= 4'}
@ -5807,6 +5984,9 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
scheduler@0.25.0-rc-603e6108-20241029:
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -5897,6 +6077,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
engines: {node: '>=12'}
@ -6034,6 +6217,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svg-arc-to-cubic-bezier@3.2.0:
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
tailwind-merge@3.4.0:
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
@ -6077,6 +6263,9 @@ packages:
tiny-each-async@2.0.3:
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
tiny-inflate@1.0.3:
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@ -6152,6 +6341,9 @@ packages:
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -6201,6 +6393,12 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
unicode-properties@1.4.1:
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
unicode-trie@2.0.0:
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
unified@11.0.5:
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
@ -6325,6 +6523,10 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite-compatible-readable-stream@3.6.1:
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
engines: {node: '>= 6'}
vite@7.3.0:
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -6508,6 +6710,9 @@ packages:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
yoga-layout@3.2.1:
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
zod-to-json-schema@3.25.1:
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
peerDependencies:
@ -7159,6 +7364,8 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-plugin-utils': 7.27.1
'@babel/runtime@7.28.6': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
@ -7201,6 +7408,26 @@ snapshots:
'@chevrotain/utils@11.0.3': {}
'@composio/client@0.1.0-alpha.56': {}
'@composio/core@0.6.2(zod@4.2.1)':
dependencies:
'@composio/client': 0.1.0-alpha.56
'@composio/json-schema-to-zod': 0.1.20(zod@4.2.1)
'@types/json-schema': 7.0.15
chalk: 4.1.2
openai: 6.17.0(zod@4.2.1)
pusher-js: 8.4.0
semver: 7.7.3
zod: 4.2.1
zod-to-json-schema: 3.25.1(zod@4.2.1)
transitivePeerDependencies:
- ws
'@composio/json-schema-to-zod@0.1.20(zod@4.2.1)':
dependencies:
zod: 4.2.1
'@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)':
dependencies:
'@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2)
@ -8648,6 +8875,107 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-pdf/fns@3.1.2': {}
'@react-pdf/font@4.0.4':
dependencies:
'@react-pdf/pdfkit': 4.1.0
'@react-pdf/types': 2.9.2
fontkit: 2.0.4
is-url: 1.2.4
'@react-pdf/image@3.0.4':
dependencies:
'@react-pdf/png-js': 3.0.0
jay-peg: 1.1.1
'@react-pdf/layout@4.4.2':
dependencies:
'@react-pdf/fns': 3.1.2
'@react-pdf/image': 3.0.4
'@react-pdf/primitives': 4.1.1
'@react-pdf/stylesheet': 6.1.2
'@react-pdf/textkit': 6.1.0
'@react-pdf/types': 2.9.2
emoji-regex-xs: 1.0.0
queue: 6.0.2
yoga-layout: 3.2.1
'@react-pdf/pdfkit@4.1.0':
dependencies:
'@babel/runtime': 7.28.6
'@react-pdf/png-js': 3.0.0
browserify-zlib: 0.2.0
crypto-js: 4.2.0
fontkit: 2.0.4
jay-peg: 1.1.1
linebreak: 1.1.0
vite-compatible-readable-stream: 3.6.1
'@react-pdf/png-js@3.0.0':
dependencies:
browserify-zlib: 0.2.0
'@react-pdf/primitives@4.1.1': {}
'@react-pdf/reconciler@2.0.0(react@19.2.3)':
dependencies:
object-assign: 4.1.1
react: 19.2.3
scheduler: 0.25.0-rc-603e6108-20241029
'@react-pdf/render@4.3.2':
dependencies:
'@babel/runtime': 7.28.6
'@react-pdf/fns': 3.1.2
'@react-pdf/primitives': 4.1.1
'@react-pdf/textkit': 6.1.0
'@react-pdf/types': 2.9.2
abs-svg-path: 0.1.1
color-string: 1.9.1
normalize-svg-path: 1.1.0
parse-svg-path: 0.1.2
svg-arc-to-cubic-bezier: 3.2.0
'@react-pdf/renderer@4.3.2(react@19.2.3)':
dependencies:
'@babel/runtime': 7.28.6
'@react-pdf/fns': 3.1.2
'@react-pdf/font': 4.0.4
'@react-pdf/layout': 4.4.2
'@react-pdf/pdfkit': 4.1.0
'@react-pdf/primitives': 4.1.1
'@react-pdf/reconciler': 2.0.0(react@19.2.3)
'@react-pdf/render': 4.3.2
'@react-pdf/types': 2.9.2
events: 3.3.0
object-assign: 4.1.1
prop-types: 15.8.1
queue: 6.0.2
react: 19.2.3
'@react-pdf/stylesheet@6.1.2':
dependencies:
'@react-pdf/fns': 3.1.2
'@react-pdf/types': 2.9.2
color-string: 1.9.1
hsl-to-hex: 1.0.0
media-engine: 1.0.3
postcss-value-parser: 4.2.0
'@react-pdf/textkit@6.1.0':
dependencies:
'@react-pdf/fns': 3.1.2
bidi-js: 1.0.3
hyphen: 1.14.1
unicode-properties: 1.4.1
'@react-pdf/types@2.9.2':
dependencies:
'@react-pdf/font': 4.0.4
'@react-pdf/primitives': 4.1.1
'@react-pdf/stylesheet': 6.1.2
'@remirror/core-constants@3.0.0': {}
'@rolldown/pluginutils@1.0.0-beta.53': {}
@ -9093,6 +9421,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@swc/helpers@0.5.18':
dependencies:
tslib: 2.8.1
'@szmarczak/http-timer@4.0.6':
dependencies:
defer-to-connect: 2.0.1
@ -9827,6 +10159,8 @@ snapshots:
abbrev@1.1.1: {}
abs-svg-path@0.1.1: {}
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@ -9966,10 +10300,16 @@ snapshots:
to-data-view: 1.1.0
optional: true
base64-js@0.0.8: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.9.11: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
bignumber.js@9.3.1: {}
bl@4.1.0:
@ -10019,6 +10359,14 @@ snapshots:
dependencies:
fill-range: 7.1.1
brotli@1.3.3:
dependencies:
base64-js: 1.5.1
browserify-zlib@0.2.0:
dependencies:
pako: 1.0.11
browserslist@4.28.1:
dependencies:
baseline-browser-mapping: 2.9.11
@ -10180,6 +10528,8 @@ snapshots:
clone@1.0.4: {}
clone@2.1.2: {}
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
@ -10203,6 +10553,11 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
colorette@2.0.20: {}
combined-stream@1.0.8:
@ -10283,6 +10638,8 @@ snapshots:
cross-zip@4.0.1: {}
crypto-js@4.2.0: {}
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@ -10542,6 +10899,8 @@ snapshots:
dependencies:
dequal: 2.0.3
dfa@1.2.0: {}
dir-compare@4.2.0:
dependencies:
minimatch: 3.1.2
@ -10655,6 +11014,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
emoji-regex-xs@1.0.0: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@ -11046,6 +11407,18 @@ snapshots:
follow-redirects@1.15.11: {}
fontkit@2.0.4:
dependencies:
'@swc/helpers': 0.5.18
brotli: 1.3.3
clone: 2.1.2
dfa: 1.2.0
fast-deep-equal: 3.1.3
restructure: 3.0.2
tiny-inflate: 1.0.3
unicode-properties: 1.4.1
unicode-trie: 2.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@ -11535,6 +11908,12 @@ snapshots:
hosted-git-info@2.8.9: {}
hsl-to-hex@1.0.0:
dependencies:
hsl-to-rgb-for-reals: 1.1.1
hsl-to-rgb-for-reals@1.1.1: {}
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@ -11580,6 +11959,8 @@ snapshots:
dependencies:
ms: 2.1.3
hyphen@1.14.1: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -11645,6 +12026,8 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-core-module@2.16.1:
dependencies:
hasown: 2.0.2
@ -11712,6 +12095,10 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
jay-peg@1.1.1:
dependencies:
restructure: 3.0.2
jest-worker@27.5.1:
dependencies:
'@types/node': 25.0.3
@ -11865,6 +12252,11 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
linebreak@1.1.0:
dependencies:
base64-js: 0.0.8
unicode-trie: 2.0.0
linkify-it@5.0.0:
dependencies:
uc.micro: 2.1.0
@ -11925,6 +12317,10 @@ snapshots:
longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lower-case@2.0.2:
dependencies:
tslib: 2.8.1
@ -12173,6 +12569,8 @@ snapshots:
mdurl@2.0.0: {}
media-engine@1.0.3: {}
media-typer@1.1.0: {}
mem@4.3.0:
@ -12628,6 +13026,10 @@ snapshots:
semver: 5.7.2
validate-npm-package-license: 3.0.4
normalize-svg-path@1.1.0:
dependencies:
svg-arc-to-cubic-bezier: 3.2.0
normalize-url@6.1.0: {}
npm-run-path@2.0.2:
@ -12678,6 +13080,10 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openai@6.17.0(zod@4.2.1):
optionalDependencies:
zod: 4.2.1
openid-client@6.8.1:
dependencies:
jose: 6.1.3
@ -12742,6 +13148,10 @@ snapshots:
package-manager-detector@1.6.0: {}
pako@0.2.9: {}
pako@1.0.11: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@ -12769,6 +13179,8 @@ snapshots:
dependencies:
error-ex: 1.3.4
parse-svg-path@0.1.2: {}
parse5@7.3.0:
dependencies:
entities: 6.0.1
@ -12845,6 +13257,8 @@ snapshots:
path-data-parser: 0.1.0
points-on-curve: 0.2.0
postcss-value-parser@4.2.0: {}
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@ -12888,6 +13302,12 @@ snapshots:
err-code: 2.0.3
retry: 0.12.0
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
property-information@7.1.0: {}
prosemirror-changeset@2.3.1:
@ -13024,6 +13444,10 @@ snapshots:
punycode@2.3.1: {}
pusher-js@8.4.0:
dependencies:
tweetnacl: 1.0.3
qs@6.14.1:
dependencies:
side-channel: 1.1.0
@ -13032,6 +13456,10 @@ snapshots:
queue-microtask@1.2.3: {}
queue@6.0.2:
dependencies:
inherits: 2.0.4
quick-lru@5.1.1: {}
random-path@0.1.2:
@ -13058,6 +13486,8 @@ snapshots:
react: 19.2.3
scheduler: 0.27.0
react-is@16.13.1: {}
react-refresh@0.18.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3):
@ -13248,6 +13678,8 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
restructure@3.0.2: {}
retry@0.12.0: {}
reusify@1.1.0: {}
@ -13340,6 +13772,8 @@ snapshots:
safer-buffer@2.1.2: {}
scheduler@0.25.0-rc-603e6108-20241029: {}
scheduler@0.27.0: {}
schema-utils@4.3.3:
@ -13453,6 +13887,10 @@ snapshots:
signal-exit@4.1.0: {}
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
slice-ansi@5.0.0:
dependencies:
ansi-styles: 6.2.3
@ -13618,6 +14056,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svg-arc-to-cubic-bezier@3.2.0: {}
tailwind-merge@3.4.0: {}
tailwindcss@4.1.18: {}
@ -13660,6 +14100,8 @@ snapshots:
tiny-each-async@2.0.3:
optional: true
tiny-inflate@1.0.3: {}
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@ -13730,6 +14172,8 @@ snapshots:
tw-animate-css@1.4.0: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -13770,6 +14214,16 @@ snapshots:
undici-types@7.16.0: {}
unicode-properties@1.4.1:
dependencies:
base64-js: 1.5.1
unicode-trie: 2.0.0
unicode-trie@2.0.0:
dependencies:
pako: 0.2.9
tiny-inflate: 1.0.3
unified@11.0.5:
dependencies:
'@types/unist': 3.0.3
@ -13903,6 +14357,12 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite-compatible-readable-stream@3.6.1:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
@ -14082,6 +14542,8 @@ snapshots:
yoctocolors-cjs@2.1.3: {}
yoga-layout@3.2.1: {}
zod-to-json-schema@3.25.1(zod@4.2.1):
dependencies:
zod: 4.2.1