From 4a47006aa916bdf54dc443b08e314d20b2b816da Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 29 Jan 2026 14:12:12 +0530 Subject: [PATCH 01/21] dont download calendar attachments --- apps/x/packages/core/src/auth/providers.ts | 1 - apps/x/packages/core/src/knowledge/sync_calendar.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index b6afe2a2..edda5447 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -63,7 +63,6 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly', - 'https://www.googleapis.com/auth/drive.readonly', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 46ec2e1e..426de091 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -12,7 +12,7 @@ const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 14; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', - 'https://www.googleapis.com/auth/drive.readonly' + // 'https://www.googleapis.com/auth/drive.readonly' ]; const nhm = new NodeHtmlMarkdown(); @@ -186,7 +186,7 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD for (const event of events) { if (event.id) { await saveEvent(event, syncDir); - await processAttachments(drive, event, syncDir); + // await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); } } From 7a59b2865148be4dce50df3f89c0eec5451c4677 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:50:46 +0530 Subject: [PATCH 02/21] fix: clean up OAuth server when flow is abandoned or restarted Cancel any existing OAuth flow before starting a new one to prevent "Port 8080 is already in use" errors. Adds module-level tracking of the active flow and a cancelActiveFlow() helper for proper cleanup. Also reduces timeout from 5 minutes to 2 minutes. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/oauth-handler.ts | 77 +++++++++++++++++++++------ 1 file changed, 62 insertions(+), 15 deletions(-) diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 3e694daa..5b55e8b7 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -1,4 +1,5 @@ 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'; @@ -14,12 +15,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(); +// 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 */ @@ -98,6 +135,10 @@ async function getProviderConfiguration(provider: string): Promise { 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); @@ -122,9 +163,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 +178,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 +210,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()); From f5cc8033405d535fedc3d08d288425e62e95b93f Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 29 Jan 2026 16:17:51 +0530 Subject: [PATCH 03/21] feat: auto-initialize config files on Electron app startup Ensure models.json, mcp.json, and security.json config files are created when the app starts, before the Settings UI can access them. - Add ensureConfig() method to IModelConfigRepo and IMcpConfigRepo interfaces - Add async ensureSecurityConfig() function for security config initialization - Create initConfigs.ts with centralized initialization that calls all config ensure methods - Call initConfigs() in main.ts before setupIpcHandlers() Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/main.ts | 6 ++++- .../x/packages/core/src/config/initConfigs.ts | 20 ++++++++++++++ apps/x/packages/core/src/config/security.ts | 26 ++++++++++++++++--- apps/x/packages/core/src/index.ts | 5 +++- apps/x/packages/core/src/mcp/repo.ts | 7 ++--- apps/x/packages/core/src/models/repo.ts | 7 ++--- 6 files changed, 56 insertions(+), 15 deletions(-) create mode 100644 apps/x/packages/core/src/config/initConfigs.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 7ae6ed46..6ddab7bc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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(); diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts new file mode 100644 index 00000000..1c447e37 --- /dev/null +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -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 { + // Resolve repos and explicitly call their ensureConfig methods + const modelConfigRepo = container.resolve("modelConfigRepo"); + const mcpConfigRepo = container.resolve("mcpConfigRepo"); + + await Promise.all([ + modelConfigRepo.ensureConfig(), + mcpConfigRepo.ensureConfig(), + ensureSecurityConfig(), + ]); +} diff --git a/apps/x/packages/core/src/config/security.ts b/apps/x/packages/core/src/config/security.ts index 9419e76e..d69eb241 100644 --- a/apps/x/packages/core/src/config/security.ts +++ b/apps/x/packages/core/src/config/security.ts @@ -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 { + 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) { diff --git a/apps/x/packages/core/src/index.ts b/apps/x/packages/core/src/index.ts index b004d263..b7718cba 100644 --- a/apps/x/packages/core/src/index.ts +++ b/apps/x/packages/core/src/index.ts @@ -2,4 +2,7 @@ export * as workspace from './workspace/workspace.js'; // Workspace watcher -export * as watcher from './workspace/watcher.js'; \ No newline at end of file +export * as watcher from './workspace/watcher.js'; + +// Config initialization +export { initConfigs } from './config/initConfigs.js'; \ No newline at end of file diff --git a/apps/x/packages/core/src/mcp/repo.ts b/apps/x/packages/core/src/mcp/repo.ts index b3ebee33..841f162b 100644 --- a/apps/x/packages/core/src/mcp/repo.ts +++ b/apps/x/packages/core/src/mcp/repo.ts @@ -5,6 +5,7 @@ import path from "path"; import z from "zod"; export interface IMcpConfigRepo { + ensureConfig(): Promise; getConfig(): Promise>; upsert(serverName: string, config: z.infer): Promise; delete(serverName: string): Promise; @@ -13,11 +14,7 @@ export interface IMcpConfigRepo { export class FSMcpConfigRepo implements IMcpConfigRepo { private readonly configPath = path.join(WorkDir, "config", "mcp.json"); - constructor() { - this.ensureDefaultConfig(); - } - - private async ensureDefaultConfig(): Promise { + async ensureConfig(): Promise { try { await fs.access(this.configPath); } catch { diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index cc60937e..33ad2502 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -5,6 +5,7 @@ import path from "path"; import z from "zod"; export interface IModelConfigRepo { + ensureConfig(): Promise; getConfig(): Promise>; upsert(providerName: string, config: z.infer): Promise; delete(providerName: string): Promise; @@ -26,11 +27,7 @@ const defaultConfig: z.infer = { export class FSModelConfigRepo implements IModelConfigRepo { private readonly configPath = path.join(WorkDir, "config", "models.json"); - constructor() { - this.ensureDefaultConfig(); - } - - private async ensureDefaultConfig(): Promise { + async ensureConfig(): Promise { try { await fs.access(this.configPath); } catch { From 9828ea830176deff244ba1d65b041a31a845ca3e Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Thu, 29 Jan 2026 19:48:31 +0530 Subject: [PATCH 04/21] docs: add CLAUDE.md for AI coding agents Adds a context file at the repo root to help AI coding agents understand the Electron app (apps/x) structure, build commands, and common development tasks. Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c59ed5b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,147 @@ +# 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 + +### 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 ` +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, Anthropic/OpenAI/Google providers | +| 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. From a3e681a7c40ca311e145fded0b692b294a9a0e32 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:53:50 +0530 Subject: [PATCH 05/21] feat: add stop execution with hybrid graceful/force abort Implement a stop execution feature that allows users to abort ongoing LLM streaming, kill running tool calls, and clear pending permission/human input requests. Uses a hybrid approach: first click sends graceful SIGTERM, second click within 2s sends SIGKILL and force-closes MCP clients. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/ipc.ts | 2 +- apps/x/apps/renderer/src/App.tsx | 105 +++++++++-- .../renderer/src/components/chat-sidebar.tsx | 52 ++++-- apps/x/packages/core/src/agents/runtime.ts | 110 ++++++++++-- .../core/src/application/lib/builtin-tools.ts | 39 +++- .../src/application/lib/command-executor.ts | 155 +++++++++++++++- .../core/src/application/lib/exec-tool.ts | 18 +- apps/x/packages/core/src/di/container.ts | 2 + apps/x/packages/core/src/mcp/mcp.ts | 16 ++ .../packages/core/src/runs/abort-registry.ts | 170 ++++++++++++++++++ apps/x/packages/core/src/runs/runs.ts | 20 ++- apps/x/packages/shared/src/ipc.ts | 1 + apps/x/packages/shared/src/runs.ts | 6 + 13 files changed, 642 insertions(+), 54 deletions(-) create mode 100644 apps/x/packages/core/src/runs/abort-registry.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 01644e90..be662f29 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -295,7 +295,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) => { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d1a6f1fe..69576938 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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, @@ -327,19 +331,39 @@ function ChatInputInner({ focusTrigger={runId} className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none" /> - + {isProcessing ? ( + + ) : ( + + )} ) } @@ -350,7 +374,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 +387,9 @@ function ChatInputWithMentions({ recentFiles, visibleFiles, onSubmit, + onStop, isProcessing, + isStopping, presetMessage, onPresetMessageConsumed, runId, @@ -370,7 +398,9 @@ function ChatInputWithMentions({ (null) const runIdRef = useRef(null) const [isProcessing, setIsProcessing] = useState(false) + const [isStopping, setIsStopping] = useState(false) + const [stopClickedAt, setStopClickedAt] = useState(null) const [agentId] = useState('copilot') const [presetMessage, setPresetMessage] = useState(undefined) @@ -758,6 +790,8 @@ function App() { case 'run-processing-end': setIsProcessing(false) + setIsStopping(false) + setStopClickedAt(null) break case 'start': @@ -936,8 +970,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 +1067,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 @@ -1779,7 +1852,9 @@ function App() { recentFiles={recentWikiFiles} visibleFiles={visibleKnowledgeFiles} onSubmit={handlePromptSubmit} + onStop={handleStop} isProcessing={isProcessing} + isStopping={isStopping} presetMessage={presetMessage} onPresetMessageConsumed={() => setPresetMessage(undefined)} runId={runId} @@ -1801,6 +1876,8 @@ function App() { currentAssistantMessage={currentAssistantMessage} currentReasoning={currentReasoning} isProcessing={isProcessing} + isStopping={isStopping} + onStop={handleStop} message={message} onMessageChange={setMessage} onSubmit={handlePromptSubmit} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 4cddfdf0..4c97d581 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -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} /> - + {isProcessing ? ( + + ) : ( + + )} {knowledgeFiles.length > 0 && ( { @@ -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 = { + 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[] { + const results: z.infer[] = []; + 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, void, unknown> { const logger = new PrefixLogger(`run-${runId}-${state.agentName}`); @@ -557,6 +628,9 @@ export async function* streamAgent({ 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 = { @@ -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, instructions: string, tools: ToolSet, + signal?: AbortSignal, ): AsyncGenerator, 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": diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 2fbf0c10..c6ac1475 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -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,13 +11,14 @@ 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 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(), execute: z.function({ - input: z.any(), + input: z.any(), // (input, ctx?) => Promise output: z.promise(z.any()), }), })); @@ -611,15 +612,15 @@ export const BuiltinTools: z.infer = { 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)'), }), - 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 +630,32 @@ export const BuiltinTools: z.infer = { // }; // } + // 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, diff --git a/apps/x/packages/core/src/application/lib/command-executor.ts b/apps/x/packages/core/src/application/lib/command-executor.ts index 01be2ee7..e865e98d 100644 --- a/apps/x/packages/core/src/application/lib/command-executor.ts +++ b/apps/x/packages/core/src/application/lib/command-executor.ts @@ -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; 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((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 | 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 diff --git a/apps/x/packages/core/src/application/lib/exec-tool.ts b/apps/x/packages/core/src/application/lib/exec-tool.ts index fbe46f57..09983402 100644 --- a/apps/x/packages/core/src/application/lib/exec-tool.ts +++ b/apps/x/packages/core/src/application/lib/exec-tool.ts @@ -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 & { type: "mcp" }, input: Record): Promise { const result = await executeTool(agentTool.mcpServerName, agentTool.name, input); return result; } -export async function execTool(agentTool: z.infer, input: Record): Promise { +export async function execTool(agentTool: z.infer, input: Record, ctx?: ToolContext): Promise { + // 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); } } } \ No newline at end of file diff --git a/apps/x/packages/core/src/di/container.ts b/apps/x/packages/core/src/di/container.ts index 2e85d34a..2b3fd2d7 100644 --- a/apps/x/packages/core/src/di/container.ts +++ b/apps/x/packages/core/src/di/container.ts @@ -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(InMemoryMessageQueue).singleton(), bus: asClass(InMemoryBus).singleton(), runsLock: asClass(InMemoryRunsLock).singleton(), + abortRegistry: asClass(InMemoryAbortRegistry).singleton(), agentRuntime: asClass(AgentRuntime).singleton(), mcpConfigRepo: asClass(FSMcpConfigRepo).singleton(), diff --git a/apps/x/packages/core/src/mcp/mcp.ts b/apps/x/packages/core/src/mcp/mcp.ts index 4cf664d9..22c8740b 100644 --- a/apps/x/packages/core/src/mcp/mcp.ts +++ b/apps/x/packages/core/src/mcp/mcp.ts @@ -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 { + 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> { const repo = container.resolve('mcpConfigRepo'); const { mcpServers } = await repo.getConfig(); diff --git a/apps/x/packages/core/src/runs/abort-registry.ts b/apps/x/packages/core/src/runs/abort-registry.ts new file mode 100644 index 00000000..7d3ca334 --- /dev/null +++ b/apps/x/packages/core/src/runs/abort-registry.ts @@ -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; + killTimers: Set>; +} + +const SIGKILL_GRACE_MS = 200; + +export class InMemoryAbortRegistry implements IAbortRegistry { + private runs: Map = 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 + } + } + } +} diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 7d7a8ebb..80ffc80f 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -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): Promise> { const repo = container.resolve('runsRepo'); @@ -46,9 +48,21 @@ export async function replyToHumanInputRequest(runId: string, ev: z.infer { - console.log(`Stopping run ${runId}`); - throw new Error('Not implemented'); +export async function stop(runId: string, force: boolean = false): Promise { + const abortRegistry = container.resolve('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> { diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 93b797a9..ca69646a 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -151,6 +151,7 @@ const ipcSchemas = { 'runs:stop': { req: z.object({ runId: z.string(), + force: z.boolean().optional().default(false), }), res: z.object({ success: z.literal(true), diff --git a/apps/x/packages/shared/src/runs.ts b/apps/x/packages/shared/src/runs.ts index 429d827b..eccfb6a7 100644 --- a/apps/x/packages/shared/src/runs.ts +++ b/apps/x/packages/shared/src/runs.ts @@ -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({ From e6c6571b07cb264930a288c3834cae134a99386e Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:30:06 +0530 Subject: [PATCH 06/21] feat: extract presentation generator into executable code with builtin tool Move the presentation implementation out of the skill string into real TypeScript files (types.ts, presentation-generator.tsx) and add a generatePresentation builtin tool so the agent calls it directly instead of writing code. Rewrite the skill to guidance-only with content limits, preference gathering, and JSON examples for each slide type. Co-Authored-By: Claude Opus 4.5 --- apps/x/packages/core/package.json | 3 + .../src/application/assistant/instructions.ts | 2 + .../presentation-generator.tsx | 446 ++++++++++++++++++ .../skills/create-presentations/skill.ts | 367 ++++++++++++++ .../skills/create-presentations/types.ts | 100 ++++ .../src/application/assistant/skills/index.ts | 8 + .../core/src/application/lib/builtin-tools.ts | 66 +++ apps/x/packages/core/tsconfig.json | 3 +- apps/x/pnpm-lock.yaml | 398 ++++++++++++++++ 9 files changed, 1392 insertions(+), 1 deletion(-) create mode 100644 apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx create mode 100644 apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 3e162a02..2633e4d0 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -17,6 +17,8 @@ "@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 +29,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" }, diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 3814064e..d70ef66a 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -26,6 +26,8 @@ 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. ## Memory That Compounds diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx b/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx new file mode 100644 index 00000000..ffeef70e --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx @@ -0,0 +1,446 @@ +import React from 'react'; +import { + Document, + Page, + Text, + View, + Image, + StyleSheet, + renderToFile, +} from '@react-pdf/renderer'; +import type { Slide, Theme, PresentationData, TitleSlide, ContentSlide, SectionSlide, StatsSlide, TwoColumnSlide, QuoteSlide, ImageSlide, TeamSlide, CTASlide } from './types.js'; + +const defaultTheme: Theme = { + primaryColor: '#6366f1', + secondaryColor: '#8b5cf6', + accentColor: '#f59e0b', + textColor: '#1f2937', + textLight: '#6b7280', + background: '#ffffff', + backgroundAlt: '#f9fafb', + fontFamily: 'Helvetica', +}; + +const SLIDE_WIDTH = 1280; +const SLIDE_HEIGHT = 720; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + slide: { + width: SLIDE_WIDTH, + height: SLIDE_HEIGHT, + padding: 60, + backgroundColor: theme.background, + position: 'relative', + }, + slideAlt: { + backgroundColor: theme.backgroundAlt, + }, + slideGradient: { + backgroundColor: theme.primaryColor, + }, + pageNumber: { + position: 'absolute', + bottom: 30, + right: 40, + fontSize: 14, + color: theme.textLight, + }, + slideTitle: { + fontSize: 42, + fontWeight: 'bold', + color: theme.textColor, + marginBottom: 30, + }, + slideBody: { + fontSize: 24, + color: theme.textColor, + lineHeight: 1.6, + }, + titleSlide: { + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + mainTitle: { + fontSize: 64, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center' as const, + marginBottom: 20, + }, + mainSubtitle: { + fontSize: 28, + color: 'rgba(255, 255, 255, 0.9)', + textAlign: 'center' as const, + marginBottom: 30, + }, + presenter: { + fontSize: 20, + color: 'rgba(255, 255, 255, 0.8)', + textAlign: 'center' as const, + }, + titleDecoration: { + position: 'absolute' as const, + bottom: 0, + left: 0, + right: 0, + height: 8, + backgroundColor: theme.accentColor, + }, + sectionNumber: { + fontSize: 80, + fontWeight: 'bold', + color: theme.primaryColor, + opacity: 0.2, + marginBottom: -20, + }, + sectionTitle: { + fontSize: 56, + fontWeight: 'bold', + color: theme.textColor, + }, + sectionSubtitle: { + fontSize: 24, + color: theme.textLight, + marginTop: 15, + }, + contentList: { + marginTop: 10, + }, + listItem: { + flexDirection: 'row' as const, + marginBottom: 16, + alignItems: 'flex-start' as const, + }, + listBullet: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: theme.primaryColor, + marginRight: 20, + marginTop: 8, + }, + listText: { + flex: 1, + fontSize: 24, + color: theme.textColor, + lineHeight: 1.5, + }, + columnsContainer: { + flexDirection: 'row' as const, + flex: 1, + gap: 60, + }, + column: { + flex: 1, + }, + columnTitle: { + fontSize: 24, + fontWeight: 'bold', + color: theme.primaryColor, + marginBottom: 15, + }, + statsGrid: { + flexDirection: 'row' as const, + justifyContent: 'space-around' as const, + alignItems: 'center' as const, + flex: 1, + }, + statItem: { + alignItems: 'center' as const, + padding: 30, + }, + statValue: { + fontSize: 72, + fontWeight: 'bold', + color: theme.primaryColor, + marginBottom: 10, + }, + statLabel: { + fontSize: 20, + color: theme.textLight, + textTransform: 'uppercase' as const, + letterSpacing: 1, + }, + statsNote: { + textAlign: 'center' as const, + fontSize: 18, + color: theme.textLight, + marginTop: 20, + }, + quoteSlide: { + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + quoteText: { + fontSize: 36, + fontStyle: 'italic', + color: theme.textColor, + textAlign: 'center' as const, + maxWidth: 900, + lineHeight: 1.5, + }, + quoteAttribution: { + fontSize: 20, + color: theme.textLight, + marginTop: 30, + textAlign: 'center' as const, + }, + imageContainer: { + flex: 1, + justifyContent: 'center' as const, + alignItems: 'center' as const, + marginVertical: 20, + }, + slideImage: { + maxWidth: '100%', + maxHeight: 450, + objectFit: 'contain' as const, + }, + imageCaption: { + textAlign: 'center' as const, + fontSize: 18, + color: theme.textLight, + }, + teamGrid: { + flexDirection: 'row' as const, + justifyContent: 'center' as const, + gap: 50, + flex: 1, + alignItems: 'center' as const, + }, + teamMember: { + alignItems: 'center' as const, + maxWidth: 200, + }, + memberPhotoPlaceholder: { + width: 120, + height: 120, + borderRadius: 60, + backgroundColor: theme.primaryColor, + marginBottom: 15, + }, + memberPhoto: { + width: 120, + height: 120, + borderRadius: 60, + marginBottom: 15, + }, + memberName: { + fontSize: 20, + fontWeight: 'bold', + color: theme.textColor, + textAlign: 'center' as const, + }, + memberRole: { + fontSize: 16, + color: theme.primaryColor, + marginTop: 5, + textAlign: 'center' as const, + }, + memberBio: { + fontSize: 14, + color: theme.textLight, + marginTop: 10, + textAlign: 'center' as const, + lineHeight: 1.4, + }, + ctaSlide: { + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + ctaTitle: { + fontSize: 56, + fontWeight: 'bold', + color: '#ffffff', + textAlign: 'center' as const, + marginBottom: 20, + }, + ctaSubtitle: { + fontSize: 24, + color: 'rgba(255, 255, 255, 0.9)', + textAlign: 'center' as const, + marginBottom: 40, + }, + ctaContact: { + fontSize: 20, + color: 'rgba(255, 255, 255, 0.8)', + textAlign: 'center' as const, + }, + }); + +type Styles = ReturnType; + +const TitleSlideComponent: React.FC<{ slide: TitleSlide; styles: Styles }> = ({ slide, styles }) => ( + + {slide.title} + {slide.subtitle && {slide.subtitle}} + {slide.presenter && {slide.presenter}} + + +); + +const SectionSlideComponent: React.FC<{ slide: SectionSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + + {String(pageNum).padStart(2, '0')} + {slide.title} + {slide.subtitle && {slide.subtitle}} + + {pageNum} + +); + +const ContentSlideComponent: React.FC<{ slide: ContentSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + {slide.content && {slide.content}} + {slide.items && ( + + {slide.items.map((item, i) => ( + + + {item} + + ))} + + )} + {pageNum} + +); + +const TwoColumnSlideComponent: React.FC<{ slide: TwoColumnSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + + {slide.columns.map((col, i) => ( + + {col.title && {col.title}} + {col.content && {col.content}} + {col.items && ( + + {col.items.map((item, j) => ( + + + {item} + + ))} + + )} + + ))} + + {pageNum} + +); + +const StatsSlideComponent: React.FC<{ slide: StatsSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + + {slide.stats.map((stat, i) => ( + + {stat.value} + {stat.label} + + ))} + + {slide.note && {slide.note}} + {pageNum} + +); + +const QuoteSlideComponent: React.FC<{ slide: QuoteSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + "{slide.quote}" + {slide.attribution && — {slide.attribution}} + {pageNum} + +); + +const ImageSlideComponent: React.FC<{ slide: ImageSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + + + + {slide.caption && {slide.caption}} + {pageNum} + +); + +const TeamSlideComponent: React.FC<{ slide: TeamSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + + {slide.members.map((member, i) => ( + + {member.photoPath ? ( + + ) : ( + + )} + {member.name} + {member.role} + {member.bio && {member.bio}} + + ))} + + {pageNum} + +); + +const CTASlideComponent: React.FC<{ slide: CTASlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( + + {slide.title} + {slide.subtitle && {slide.subtitle}} + {slide.contact && {slide.contact}} + {pageNum} + +); + +const renderSlide = ( + slide: Slide, + index: number, + styles: Styles +): React.ReactElement => { + const pageNum = index + 1; + + switch (slide.type) { + case 'title': + return ; + case 'section': + return ; + case 'content': + return ; + case 'two-column': + return ; + case 'stats': + return ; + case 'quote': + return ; + case 'image': + return ; + case 'team': + return ; + case 'cta': + return ; + default: + return ; + } +}; + +const Presentation: React.FC = ({ slides, theme }) => { + const mergedTheme = { ...defaultTheme, ...theme }; + const styles = createStyles(mergedTheme); + + return {slides.map((slide, i) => renderSlide(slide, i, styles))}; +}; + +export async function generatePresentation( + data: PresentationData, + outputPath: string +): Promise { + await renderToFile(, outputPath); + return outputPath; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts new file mode 100644 index 00000000..467bc057 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -0,0 +1,367 @@ +export const skill = String.raw` +# PDF Presentation Generator Skill + +## Overview + +This skill enables Rowboat to create stunning PDF presentations from natural language requests. Use the built-in **generatePresentation** tool to render slides to PDF. + +## When to Use This Skill + +Activate this skill when the user requests: +- Creating presentations, slide decks, or pitch decks +- Making PDF slides for meetings, talks, or pitches +- Generating visual summaries or reports in presentation format +- Keywords: "presentation", "slides", "deck", "pitch deck", "slide deck", "PDF presentation" + +## Knowledge Sources + +Before creating any presentation, gather context from the user's knowledge base: + +~~~ +~/.rowboat/knowledge/ +├── company/ +│ ├── about.md # Company description, mission, vision +│ ├── team.md # Founder bios, team members +│ ├── metrics.md # KPIs, growth numbers, financials +│ ├── product.md # Product description, features, roadmap +│ └── branding.md # Colors, fonts, logo paths, style guide +├── fundraising/ +│ ├── previous-rounds.md # Past funding history +│ ├── investors.md # Current investors, target investors +│ ├── use-of-funds.md # How funds will be allocated +│ └── projections.md # Financial projections +├── market/ +│ ├── problem.md # Problem statement +│ ├── solution.md # How product solves it +│ ├── competitors.md # Competitive landscape +│ ├── tam-sam-som.md # Market size analysis +│ └── traction.md # Customer testimonials, case studies +└── assets/ + ├── logo.png # Company logo + ├── product-screenshots/ + └── team-photos/ +~~~ + +**Important:** Always check for and read relevant files from ~/.rowboat/knowledge/ before generating content. If files don't exist, ask the user for the information and offer to save it for future use. + +## Workflow + +### Step 1: Understand the Request & Gather Preferences + +Before doing anything else, ask the user about their preferences: + +1. **Content density**: Should the slides be text-heavy with detailed explanations, or minimal with just key points and big numbers? +2. **Color / theme**: Do they have brand colors or a color preference? (e.g., "use our brand blue #2563eb" or "dark theme" or "keep it default") +3. **Presentation type**: pitch deck, product demo, team intro, investor update, etc. +4. **Audience**: investors, customers, internal team, conference +5. **Tone**: formal, casual, technical, inspirational +6. **Length**: number of slides (default: 10-12 for pitch decks) + +Ask these as a concise set of questions in a single message. Use any answers the user already provided in their initial request and only ask about what's missing. + +### Step 2: Gather Knowledge + +~~~bash +# Check what knowledge exists +ls -la ~/.rowboat/knowledge/ 2>/dev/null || echo "No knowledge directory found" + +# Read relevant files based on presentation type +# For a pitch deck, prioritize: +cat ~/.rowboat/knowledge/company/about.md 2>/dev/null +cat ~/.rowboat/knowledge/market/problem.md 2>/dev/null +cat ~/.rowboat/knowledge/company/metrics.md 2>/dev/null +cat ~/.rowboat/knowledge/company/branding.md 2>/dev/null +~~~ + +### Step 3: Present the Outline for Approval + +Before generating slides, present a structured outline to the user: + +~~~ +## Proposed Presentation Outline + +**Title:** [Presentation Title] +**Slides:** [N] slides +**Estimated read time:** [X] minutes + +### Flow: + +1. **Title Slide** + - Company name, tagline, presenter name + +2. **Problem** + - [One sentence summary of the problem] + +3. **Solution** + - [One sentence summary of your solution] + +... + +--- + +Does this look good? I can adjust the outline, then I'll go ahead and generate the PDF for you. +- Add/remove slides +- Reorder sections +- Adjust emphasis on any area +~~~ + +After the user approves (or after incorporating their feedback), immediately ask: **"I'll generate the PDF now — where should I save it?"** If the user has already indicated a path or preference, skip asking and generate directly. + +**IMPORTANT:** Always generate the PDF. Never suggest the user copy content into Keynote, Google Slides, or any other tool. The whole point of this skill is to produce a finished PDF. + +### Step 4: Generate the Presentation + +Once approved, call the **generatePresentation** tool with the slides JSON and output path. Apply the user's theme/color preferences from Step 1. + +## Slide Types Reference + +| Type | Description | Required Fields | +|------|-------------|-----------------| +| title | Opening slide with gradient background | title | +| section | Section divider with large number | title | +| content | Standard content slide | title, content or items | +| two-column | Two column layout | title, columns (array of 2) | +| stats | Big numbers display | title, stats (array of {value, label}) | +| quote | Testimonial/quote | quote | +| image | Image with caption | title, imagePath | +| team | Team member grid | title, members (array) | +| cta | Call to action / closing | title | + +## Slide Type Details + +### title +~~~json +{ + "type": "title", + "title": "Company Name", + "subtitle": "Tagline or description", + "presenter": "Name • Context • Date" +} +~~~ + +### content +~~~json +{ + "type": "content", + "title": "Slide Title", + "content": "Optional paragraph text", + "items": ["Bullet point 1", "Bullet point 2", "Bullet point 3"] +} +~~~ + +### section +~~~json +{ + "type": "section", + "title": "Section Title", + "subtitle": "Optional subtitle" +} +~~~ + +### stats +~~~json +{ + "type": "stats", + "title": "Key Metrics", + "stats": [ + { "value": "$5M", "label": "Revenue" }, + { "value": "150%", "label": "YoY Growth" }, + { "value": "10K+", "label": "Users" } + ], + "note": "Optional footnote" +} +~~~ + +### two-column +~~~json +{ + "type": "two-column", + "title": "Comparison", + "columns": [ + { + "title": "Column A", + "content": "Optional text", + "items": ["Item 1", "Item 2"] + }, + { + "title": "Column B", + "content": "Optional text", + "items": ["Item 1", "Item 2"] + } + ] +} +~~~ + +### quote +~~~json +{ + "type": "quote", + "quote": "The quote text goes here.", + "attribution": "Person Name, Title" +} +~~~ + +### image +~~~json +{ + "type": "image", + "title": "Product Screenshot", + "imagePath": "/absolute/path/to/image.png", + "caption": "Optional caption" +} +~~~ + +### team +~~~json +{ + "type": "team", + "title": "Our Team", + "members": [ + { + "name": "Jane Doe", + "role": "CEO", + "bio": "Optional short bio", + "photoPath": "/absolute/path/to/photo.png" + } + ] +} +~~~ + +### cta +~~~json +{ + "type": "cta", + "title": "Let's Build Together", + "subtitle": "email@company.com", + "contact": "website.com • github.com/org" +} +~~~ + +## Theme Customization + +Pass an optional theme object to customize colors: + +~~~json +{ + "primaryColor": "#2563eb", + "secondaryColor": "#7c3aed", + "accentColor": "#f59e0b", + "textColor": "#1f2937", + "textLight": "#6b7280", + "background": "#ffffff", + "backgroundAlt": "#f9fafb", + "fontFamily": "Helvetica" +} +~~~ + +All theme fields are optional — defaults are used for any omitted fields. + +## Example: Calling generatePresentation + +~~~json +{ + "slides": [ + { + "type": "title", + "title": "Acme Corp", + "subtitle": "Revolutionizing Widget Manufacturing", + "presenter": "Jane Doe • Series A • 2025" + }, + { + "type": "content", + "title": "The Problem", + "items": [ + "Widget production is slow and expensive", + "Legacy systems can't keep up with demand", + "Quality control remains manual" + ] + }, + { + "type": "stats", + "title": "Traction", + "stats": [ + { "value": "500+", "label": "Customers" }, + { "value": "$2M", "label": "ARR" }, + { "value": "3x", "label": "YoY Growth" } + ] + }, + { + "type": "cta", + "title": "Let's Talk", + "subtitle": "jane@acme.com", + "contact": "acme.com" + } + ], + "theme": { + "primaryColor": "#2563eb" + }, + "outputPath": "/Users/user/Desktop/acme_pitch.pdf" +} +~~~ + +## Pitch Deck Templates + +### Series A Pitch Deck (12 slides) + +Standard flow for investor presentations: + +1. **Title** (type: title) - Company name, tagline, presenter +2. **Problem** (type: content) - What pain point you solve +3. **Solution** (type: content) - Your product/service +4. **Product** (type: image) - Demo/screenshots +5. **Market** (type: stats) - TAM/SAM/SOM +6. **Business Model** (type: content) - How you make money +7. **Traction** (type: stats) - Metrics and growth +8. **Competition** (type: two-column) - Your differentiation +9. **Team** (type: team) - Key team members +10. **Financials** (type: content or stats) - Projections +11. **The Ask** (type: content) - Funding amount and use +12. **Contact** (type: cta) - CTA with contact info + +### Product Demo Deck (8 slides) + +1. **Title** - Product name and tagline +2. **Problem** - User pain points +3. **Solution** - High-level approach +4. **Features** - Key capabilities (two-column) +5. **Demo** - Screenshots (image) +6. **Pricing** - Plans and pricing +7. **Testimonials** - Customer quotes (quote) +8. **Get Started** - CTA + +## Content Limits Per Slide (IMPORTANT) + +Each slide is a fixed 1280x720 page. Content that exceeds the available space will be clipped. Follow these limits strictly: + +| Slide Type | Max Items / Content | +|------------|-------------------| +| content | 5 bullet points max (keep each bullet to 1 line, ~80 chars). If using paragraph text instead, max ~4 lines. | +| two-column | 4 bullet points per column max. Keep bullets short (~60 chars). | +| stats | 3-4 stats max. Keep labels short (1-2 words). | +| team | 4 members max per slide. Split into multiple team slides if needed. | +| quote | Keep quotes under ~200 characters. | +| image | Caption should be 1 line. | + +**If the user's content needs more space**, split it across multiple slides of the same type rather than cramming it into one. For example, if there are 8 bullet points, use two content slides (4 each) with titles like "Key Benefits (1/2)" and "Key Benefits (2/2)". + +## Best Practices + +1. **Keep slides simple** - One idea per slide +2. **Use stats slides for numbers** - Big, bold metrics +3. **Limit bullet points** - 3-5 max per slide, keep them short +4. **Use two-column for comparisons** - Us vs. them, before/after +5. **End with clear CTA** - What do you want them to do? +6. **Gather knowledge first** - Check ~/.rowboat/knowledge/ before generating +7. **Use absolute paths** for images (PNG, JPG supported) +8. **Never overflow** - If content doesn't fit, split across multiple slides + +## Output + +The generatePresentation tool produces: +- **PDF file** at the specified outputPath +- **16:9 aspect ratio** (1280x720px per slide) +- **Print-ready** quality +- **Embedded fonts** for portability +`; + +export default skill; diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts new file mode 100644 index 00000000..888eb2e7 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts @@ -0,0 +1,100 @@ +export interface SlideBase { + type: string; + title?: string; + subtitle?: string; + content?: string; +} + +export interface TitleSlide extends SlideBase { + type: 'title'; + title: string; + subtitle?: string; + presenter?: string; +} + +export interface ContentSlide extends SlideBase { + type: 'content'; + title: string; + content?: string; + items?: string[]; +} + +export interface SectionSlide extends SlideBase { + type: 'section'; + title: string; + subtitle?: string; +} + +export interface StatsSlide extends SlideBase { + type: 'stats'; + title: string; + stats: Array<{ value: string; label: string }>; + note?: string; +} + +export interface TwoColumnSlide extends SlideBase { + type: 'two-column'; + title: string; + columns: [ + { title?: string; content?: string; items?: string[] }, + { title?: string; content?: string; items?: string[] } + ]; +} + +export interface QuoteSlide extends SlideBase { + type: 'quote'; + quote: string; + attribution?: string; +} + +export interface ImageSlide extends SlideBase { + type: 'image'; + title: string; + imagePath: string; + caption?: string; +} + +export interface TeamSlide extends SlideBase { + type: 'team'; + title: string; + members: Array<{ + name: string; + role: string; + bio?: string; + photoPath?: string; + }>; +} + +export interface CTASlide extends SlideBase { + type: 'cta'; + title: string; + subtitle?: string; + contact?: string; +} + +export type Slide = + | TitleSlide + | ContentSlide + | SectionSlide + | StatsSlide + | TwoColumnSlide + | QuoteSlide + | ImageSlide + | TeamSlide + | CTASlide; + +export interface Theme { + primaryColor: string; + secondaryColor: string; + accentColor: string; + textColor: string; + textLight: string; + background: string; + backgroundAlt: string; + fontFamily: string; +} + +export interface PresentationData { + slides: Slide[]; + theme?: Partial; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index ab2ca83f..391f9523 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -8,6 +8,7 @@ import mcpIntegrationSkill from "./mcp-integration/skill.js"; import meetingPrepSkill from "./meeting-prep/skill.js"; import organizeFilesSkill from "./organize-files/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); @@ -29,6 +30,13 @@ type ResolvedSkill = { }; const definitions: SkillDefinition[] = [ + { + id: "create-presentations", + title: "Create Presentations", + folder: "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", diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index c6ac1475..0d3756f8 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -12,6 +12,7 @@ import * as workspace from "../../workspace/workspace.js"; import { IAgentsRepo } from "../../agents/repo.js"; import { WorkDir } from "../../config/config.js"; import type { ToolContext } from "./exec-tool.js"; +import { generatePresentation } from "../assistant/skills/create-presentations/presentation-generator.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const BuiltinToolsSchema = z.record(z.string(), z.object({ @@ -606,6 +607,71 @@ export const BuiltinTools: z.infer = { }, }, + generatePresentation: { + description: 'Generate a PDF presentation from slide data. Creates a 16:9 PDF with styled slides.', + inputSchema: z.object({ + slides: z.array(z.object({ + type: z.enum(['title', 'content', 'section', 'stats', 'two-column', 'quote', 'image', 'team', 'cta']), + title: z.string().optional(), + subtitle: z.string().optional(), + content: z.string().optional(), + presenter: z.string().optional(), + items: z.array(z.string()).optional(), + stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(), + note: z.string().optional(), + columns: z.array(z.object({ + title: z.string().optional(), + content: z.string().optional(), + items: z.array(z.string()).optional(), + })).optional(), + quote: z.string().optional(), + attribution: z.string().optional(), + imagePath: z.string().optional(), + caption: z.string().optional(), + members: z.array(z.object({ + name: z.string(), + role: z.string(), + bio: z.string().optional(), + photoPath: z.string().optional(), + })).optional(), + contact: z.string().optional(), + })).describe('Array of slide objects'), + theme: z.object({ + primaryColor: z.string().optional(), + secondaryColor: z.string().optional(), + accentColor: z.string().optional(), + textColor: z.string().optional(), + textLight: z.string().optional(), + background: z.string().optional(), + backgroundAlt: z.string().optional(), + fontFamily: z.string().optional(), + }).optional().describe('Optional theme customization'), + outputPath: z.string().describe('Absolute path for the output PDF file'), + }), + execute: async ({ slides, theme, outputPath }: { + slides: Array>; + theme?: Record; + outputPath: string; + }) => { + try { + const result = await generatePresentation( + { slides: slides as never, theme }, + outputPath, + ); + return { + success: true, + outputPath: result, + slideCount: slides.length, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }, + }, + executeCommand: { description: 'Execute a shell command and return the output. Use this to run bash/shell commands.', inputSchema: z.object({ diff --git a/apps/x/packages/core/tsconfig.json b/apps/x/packages/core/tsconfig.json index 3e5b01a3..d9f2b13c 100644 --- a/apps/x/packages/core/tsconfig.json +++ b/apps/x/packages/core/tsconfig.json @@ -4,7 +4,8 @@ "declaration": true, "outDir": "dist", "rootDir": "src", - "types": ["node"] + "types": ["node"], + "jsx": "react-jsx" }, "include": [ "src" diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 76d41173..5ea5f4d3 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -299,6 +299,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 +335,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 +640,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'} @@ -1947,6 +1960,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 +2363,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 +2990,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 +3133,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 +3144,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 +3183,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 +3326,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 +3350,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 +3451,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 +3697,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 +3762,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 +4066,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 +4392,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 +4430,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 +4522,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 +4612,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 +4788,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 +4847,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 +4971,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 +5319,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'} @@ -5317,6 +5439,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 +5463,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 +5559,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 +5606,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==} @@ -5565,6 +5702,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 +5728,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 +5888,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 +5953,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 +6046,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 +6186,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 +6232,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'} @@ -6201,6 +6359,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 +6489,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 +6676,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 +7330,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 @@ -8648,6 +8821,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 +9367,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 +10105,8 @@ snapshots: abbrev@1.1.1: {} + abs-svg-path@0.1.1: {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -9966,10 +10246,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 +10305,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 +10474,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 +10499,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 +10584,8 @@ snapshots: cross-zip@4.0.1: {} + crypto-js@4.2.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -10542,6 +10845,8 @@ snapshots: dependencies: dequal: 2.0.3 + dfa@1.2.0: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -10655,6 +10960,8 @@ snapshots: transitivePeerDependencies: - supports-color + emoji-regex-xs@1.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -11046,6 +11353,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 +11854,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 +11905,8 @@ snapshots: dependencies: ms: 2.1.3 + hyphen@1.14.1: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -11645,6 +11972,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 +12041,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 +12198,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 +12263,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 +12515,8 @@ snapshots: mdurl@2.0.0: {} + media-engine@1.0.3: {} + media-typer@1.1.0: {} mem@4.3.0: @@ -12628,6 +12972,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: @@ -12742,6 +13090,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 +13121,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 +13199,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 +13244,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: @@ -13032,6 +13394,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 +13424,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 +13616,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 +13710,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 +13825,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 +13994,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 +14038,8 @@ snapshots: tiny-each-async@2.0.3: optional: true + tiny-inflate@1.0.3: {} + tinyexec@1.0.2: {} tinyglobby@0.2.15: @@ -13770,6 +14150,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 +14293,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 +14478,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 From 9cd7d11969807f9378065e1e819c2463842a9942 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 30 Jan 2026 13:35:31 +0530 Subject: [PATCH 07/21] feat: rewrite presentation skill to give agent full code freedom The skill now treats the existing generator as a minimal reference and tells the agent it can write and execute its own code, install any npm packages, generate charts/visualizations, and use whatever libraries it wants. Adds visual quality guidelines emphasizing color, charts, and polish over plain text-on-white slides. Co-Authored-By: Claude Opus 4.5 --- .../skills/create-presentations/skill.ts | 287 +++++------------- 1 file changed, 68 insertions(+), 219 deletions(-) diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts index 467bc057..efb57478 100644 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -3,7 +3,19 @@ export const skill = String.raw` ## Overview -This skill enables Rowboat to create stunning PDF presentations from natural language requests. Use the built-in **generatePresentation** tool to render slides to PDF. +This skill enables Rowboat to create visually compelling PDF presentations from natural language requests. You have full freedom to write and execute your own code to generate presentations — install any npm packages you need, generate charts, use custom layouts, and make the output look polished and professional. + +A minimal reference implementation using @react-pdf/renderer exists in the codebase at: +- **Types:** src/application/assistant/skills/create-presentations/types.ts +- **Generator:** src/application/assistant/skills/create-presentations/presentation-generator.tsx + +**This code is just a starting point.** It shows one basic approach to PDF generation. You are NOT limited to it. Feel free to: +- Write your own code from scratch +- Use different libraries (e.g., pdfkit, puppeteer with HTML/CSS, jsPDF, or anything else) +- Install any npm packages you need via executeCommand +- Generate charts and visualizations (e.g., chartjs-node-canvas, d3-node, vega-lite, mermaid) +- Render charts as PNG images and embed them in slides +- Create custom layouts, gradients, decorative elements — whatever makes the presentation look great ## When to Use This Skill @@ -51,7 +63,7 @@ Before creating any presentation, gather context from the user's knowledge base: Before doing anything else, ask the user about their preferences: 1. **Content density**: Should the slides be text-heavy with detailed explanations, or minimal with just key points and big numbers? -2. **Color / theme**: Do they have brand colors or a color preference? (e.g., "use our brand blue #2563eb" or "dark theme" or "keep it default") +2. **Color / theme**: Do they have brand colors or a color preference? (e.g., "use our brand blue #2563eb", "dark theme", "warm tones", "professional and clean") 3. **Presentation type**: pitch deck, product demo, team intro, investor update, etc. 4. **Audience**: investors, customers, internal team, conference 5. **Tone**: formal, casual, technical, inspirational @@ -82,7 +94,7 @@ Before generating slides, present a structured outline to the user: **Title:** [Presentation Title] **Slides:** [N] slides -**Estimated read time:** [X] minutes +**Style:** [Color scheme / theme description] ### Flow: @@ -111,212 +123,71 @@ After the user approves (or after incorporating their feedback), immediately ask ### Step 4: Generate the Presentation -Once approved, call the **generatePresentation** tool with the slides JSON and output path. Apply the user's theme/color preferences from Step 1. +Write code to generate the presentation. You have complete freedom here: -## Slide Types Reference +1. **Install any packages you need** via executeCommand (e.g., npm install @react-pdf/renderer chartjs-node-canvas) +2. **Write a script** that generates the PDF — you can use the reference code as inspiration or write something entirely different +3. **Generate charts** for any data that would benefit from visualization (revenue growth, market size, traction metrics, competitive positioning, etc.) — use chartjs-node-canvas, d3, vega, or any charting library +4. **Execute the script** to produce the final PDF -| Type | Description | Required Fields | -|------|-------------|-----------------| -| title | Opening slide with gradient background | title | -| section | Section divider with large number | title | -| content | Standard content slide | title, content or items | -| two-column | Two column layout | title, columns (array of 2) | -| stats | Big numbers display | title, stats (array of {value, label}) | -| quote | Testimonial/quote | quote | -| image | Image with caption | title, imagePath | -| team | Team member grid | title, members (array) | -| cta | Call to action / closing | title | +## Visual Quality Guidelines -## Slide Type Details +**Do NOT produce plain, boring slides.** Make them look professional and visually engaging: -### title -~~~json -{ - "type": "title", - "title": "Company Name", - "subtitle": "Tagline or description", - "presenter": "Name • Context • Date" -} -~~~ +- **Use color intentionally** — gradient backgrounds on title/CTA slides, accent colors for bullets and highlights, colored stat numbers +- **Apply the user's brand colors** throughout — not just on the title slide, but as accents, backgrounds, and highlights across all slides +- **Charts and visualizations** — whenever there are numbers (revenue, growth, market size, user counts), generate a chart instead of just listing numbers. Bar charts, line charts, pie charts, and simple diagrams make slides far more impactful +- **Visual hierarchy** — large bold headings, generous whitespace, clear separation between sections +- **Consistent theming** — every slide should feel like part of the same deck, with consistent colors, fonts, and spacing +- **Decorative elements** — subtle accent bars, colored bullets, gradient sections, and background tints add polish -### content -~~~json -{ - "type": "content", - "title": "Slide Title", - "content": "Optional paragraph text", - "items": ["Bullet point 1", "Bullet point 2", "Bullet point 3"] -} -~~~ +## Slide Types (Reference) -### section -~~~json -{ - "type": "section", - "title": "Section Title", - "subtitle": "Optional subtitle" -} -~~~ +These are common slide patterns. You can implement these or create your own: -### stats -~~~json -{ - "type": "stats", - "title": "Key Metrics", - "stats": [ - { "value": "$5M", "label": "Revenue" }, - { "value": "150%", "label": "YoY Growth" }, - { "value": "10K+", "label": "Users" } - ], - "note": "Optional footnote" -} -~~~ +| Type | Description | When to Use | +|------|-------------|-------------| +| Title | Bold opening with gradient/colored background | First slide | +| Section | Section divider between topics | Between major sections | +| Content | Text with bullet points | Explaining concepts, lists | +| Two-column | Side-by-side comparison | Us vs. them, before/after | +| Stats | Big bold numbers | Key metrics, traction, market size | +| Chart | Data visualization | Revenue growth, market breakdown, trends | +| Quote | Testimonial or notable quote | Customer feedback, press quotes | +| Image | Full or partial image with caption | Product screenshots, team photos | +| Team | Grid of team member cards | Team introduction | +| CTA | Call to action / closing | Final slide | -### two-column -~~~json -{ - "type": "two-column", - "title": "Comparison", - "columns": [ - { - "title": "Column A", - "content": "Optional text", - "items": ["Item 1", "Item 2"] - }, - { - "title": "Column B", - "content": "Optional text", - "items": ["Item 1", "Item 2"] - } - ] -} -~~~ +## Content Limits Per Slide -### quote -~~~json -{ - "type": "quote", - "quote": "The quote text goes here.", - "attribution": "Person Name, Title" -} -~~~ +Each slide is a fixed page. Content that exceeds the available space will overflow. Follow these limits: -### image -~~~json -{ - "type": "image", - "title": "Product Screenshot", - "imagePath": "/absolute/path/to/image.png", - "caption": "Optional caption" -} -~~~ +| Slide Type | Max Items / Content | +|------------|-------------------| +| Content | 5 bullet points max (~80 chars each). Paragraph text: max ~4 lines. | +| Two-column | 4 bullet points per column max (~60 chars each). | +| Stats | 3-4 stats max. Keep labels short. | +| Team | 4 members max per slide. Split into multiple slides if needed. | +| Quote | Keep quotes under ~200 characters. | -### team -~~~json -{ - "type": "team", - "title": "Our Team", - "members": [ - { - "name": "Jane Doe", - "role": "CEO", - "bio": "Optional short bio", - "photoPath": "/absolute/path/to/photo.png" - } - ] -} -~~~ - -### cta -~~~json -{ - "type": "cta", - "title": "Let's Build Together", - "subtitle": "email@company.com", - "contact": "website.com • github.com/org" -} -~~~ - -## Theme Customization - -Pass an optional theme object to customize colors: - -~~~json -{ - "primaryColor": "#2563eb", - "secondaryColor": "#7c3aed", - "accentColor": "#f59e0b", - "textColor": "#1f2937", - "textLight": "#6b7280", - "background": "#ffffff", - "backgroundAlt": "#f9fafb", - "fontFamily": "Helvetica" -} -~~~ - -All theme fields are optional — defaults are used for any omitted fields. - -## Example: Calling generatePresentation - -~~~json -{ - "slides": [ - { - "type": "title", - "title": "Acme Corp", - "subtitle": "Revolutionizing Widget Manufacturing", - "presenter": "Jane Doe • Series A • 2025" - }, - { - "type": "content", - "title": "The Problem", - "items": [ - "Widget production is slow and expensive", - "Legacy systems can't keep up with demand", - "Quality control remains manual" - ] - }, - { - "type": "stats", - "title": "Traction", - "stats": [ - { "value": "500+", "label": "Customers" }, - { "value": "$2M", "label": "ARR" }, - { "value": "3x", "label": "YoY Growth" } - ] - }, - { - "type": "cta", - "title": "Let's Talk", - "subtitle": "jane@acme.com", - "contact": "acme.com" - } - ], - "theme": { - "primaryColor": "#2563eb" - }, - "outputPath": "/Users/user/Desktop/acme_pitch.pdf" -} -~~~ +**If the user's content needs more space**, split it across multiple slides rather than cramming it into one. ## Pitch Deck Templates ### Series A Pitch Deck (12 slides) -Standard flow for investor presentations: - -1. **Title** (type: title) - Company name, tagline, presenter -2. **Problem** (type: content) - What pain point you solve -3. **Solution** (type: content) - Your product/service -4. **Product** (type: image) - Demo/screenshots -5. **Market** (type: stats) - TAM/SAM/SOM -6. **Business Model** (type: content) - How you make money -7. **Traction** (type: stats) - Metrics and growth -8. **Competition** (type: two-column) - Your differentiation -9. **Team** (type: team) - Key team members -10. **Financials** (type: content or stats) - Projections -11. **The Ask** (type: content) - Funding amount and use -12. **Contact** (type: cta) - CTA with contact info +1. **Title** - Company name, tagline, presenter +2. **Problem** - What pain point you solve +3. **Solution** - Your product/service +4. **Product** - Demo/screenshots +5. **Market** - TAM/SAM/SOM (use a chart!) +6. **Business Model** - How you make money +7. **Traction** - Metrics and growth (use charts!) +8. **Competition** - Positioning (two-column or matrix chart) +9. **Team** - Key team members +10. **Financials** - Projections (use a chart!) +11. **The Ask** - Funding amount and use (pie chart for allocation) +12. **Contact** - CTA with contact info ### Product Demo Deck (8 slides) @@ -324,44 +195,22 @@ Standard flow for investor presentations: 2. **Problem** - User pain points 3. **Solution** - High-level approach 4. **Features** - Key capabilities (two-column) -5. **Demo** - Screenshots (image) +5. **Demo** - Screenshots 6. **Pricing** - Plans and pricing -7. **Testimonials** - Customer quotes (quote) +7. **Testimonials** - Customer quotes 8. **Get Started** - CTA -## Content Limits Per Slide (IMPORTANT) - -Each slide is a fixed 1280x720 page. Content that exceeds the available space will be clipped. Follow these limits strictly: - -| Slide Type | Max Items / Content | -|------------|-------------------| -| content | 5 bullet points max (keep each bullet to 1 line, ~80 chars). If using paragraph text instead, max ~4 lines. | -| two-column | 4 bullet points per column max. Keep bullets short (~60 chars). | -| stats | 3-4 stats max. Keep labels short (1-2 words). | -| team | 4 members max per slide. Split into multiple team slides if needed. | -| quote | Keep quotes under ~200 characters. | -| image | Caption should be 1 line. | - -**If the user's content needs more space**, split it across multiple slides of the same type rather than cramming it into one. For example, if there are 8 bullet points, use two content slides (4 each) with titles like "Key Benefits (1/2)" and "Key Benefits (2/2)". - ## Best Practices 1. **Keep slides simple** - One idea per slide -2. **Use stats slides for numbers** - Big, bold metrics +2. **Use charts for numbers** - Never just list numbers when a chart would be more impactful 3. **Limit bullet points** - 3-5 max per slide, keep them short 4. **Use two-column for comparisons** - Us vs. them, before/after 5. **End with clear CTA** - What do you want them to do? 6. **Gather knowledge first** - Check ~/.rowboat/knowledge/ before generating 7. **Use absolute paths** for images (PNG, JPG supported) 8. **Never overflow** - If content doesn't fit, split across multiple slides - -## Output - -The generatePresentation tool produces: -- **PDF file** at the specified outputPath -- **16:9 aspect ratio** (1280x720px per slide) -- **Print-ready** quality -- **Embedded fonts** for portability +9. **Make it visually rich** - Colors, charts, gradients — not just text on white backgrounds `; export default skill; From 7133dbe1d9e77f707cc796cf1b708be5cf98d11f Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Fri, 30 Jan 2026 22:57:20 +0530 Subject: [PATCH 08/21] improved presentation skill --- .../src/application/assistant/instructions.ts | 21 +- .../presentation-generator.tsx | 446 ------------------ .../skills/create-presentations/skill.ts | 253 +++------- .../skills/create-presentations/types.ts | 100 ---- .../core/src/application/lib/builtin-tools.ts | 68 +-- 5 files changed, 72 insertions(+), 816 deletions(-) delete mode 100644 apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx delete mode 100644 apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index d70ef66a..3785c493 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -150,15 +150,9 @@ 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 @@ -166,13 +160,20 @@ When a user asks for ANY task that might require external capabilities (web sear - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`loadSkill\` - Skill loading -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.`; diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx b/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx deleted file mode 100644 index ffeef70e..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/presentation-generator.tsx +++ /dev/null @@ -1,446 +0,0 @@ -import React from 'react'; -import { - Document, - Page, - Text, - View, - Image, - StyleSheet, - renderToFile, -} from '@react-pdf/renderer'; -import type { Slide, Theme, PresentationData, TitleSlide, ContentSlide, SectionSlide, StatsSlide, TwoColumnSlide, QuoteSlide, ImageSlide, TeamSlide, CTASlide } from './types.js'; - -const defaultTheme: Theme = { - primaryColor: '#6366f1', - secondaryColor: '#8b5cf6', - accentColor: '#f59e0b', - textColor: '#1f2937', - textLight: '#6b7280', - background: '#ffffff', - backgroundAlt: '#f9fafb', - fontFamily: 'Helvetica', -}; - -const SLIDE_WIDTH = 1280; -const SLIDE_HEIGHT = 720; - -const createStyles = (theme: Theme) => - StyleSheet.create({ - slide: { - width: SLIDE_WIDTH, - height: SLIDE_HEIGHT, - padding: 60, - backgroundColor: theme.background, - position: 'relative', - }, - slideAlt: { - backgroundColor: theme.backgroundAlt, - }, - slideGradient: { - backgroundColor: theme.primaryColor, - }, - pageNumber: { - position: 'absolute', - bottom: 30, - right: 40, - fontSize: 14, - color: theme.textLight, - }, - slideTitle: { - fontSize: 42, - fontWeight: 'bold', - color: theme.textColor, - marginBottom: 30, - }, - slideBody: { - fontSize: 24, - color: theme.textColor, - lineHeight: 1.6, - }, - titleSlide: { - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - mainTitle: { - fontSize: 64, - fontWeight: 'bold', - color: '#ffffff', - textAlign: 'center' as const, - marginBottom: 20, - }, - mainSubtitle: { - fontSize: 28, - color: 'rgba(255, 255, 255, 0.9)', - textAlign: 'center' as const, - marginBottom: 30, - }, - presenter: { - fontSize: 20, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center' as const, - }, - titleDecoration: { - position: 'absolute' as const, - bottom: 0, - left: 0, - right: 0, - height: 8, - backgroundColor: theme.accentColor, - }, - sectionNumber: { - fontSize: 80, - fontWeight: 'bold', - color: theme.primaryColor, - opacity: 0.2, - marginBottom: -20, - }, - sectionTitle: { - fontSize: 56, - fontWeight: 'bold', - color: theme.textColor, - }, - sectionSubtitle: { - fontSize: 24, - color: theme.textLight, - marginTop: 15, - }, - contentList: { - marginTop: 10, - }, - listItem: { - flexDirection: 'row' as const, - marginBottom: 16, - alignItems: 'flex-start' as const, - }, - listBullet: { - width: 12, - height: 12, - borderRadius: 6, - backgroundColor: theme.primaryColor, - marginRight: 20, - marginTop: 8, - }, - listText: { - flex: 1, - fontSize: 24, - color: theme.textColor, - lineHeight: 1.5, - }, - columnsContainer: { - flexDirection: 'row' as const, - flex: 1, - gap: 60, - }, - column: { - flex: 1, - }, - columnTitle: { - fontSize: 24, - fontWeight: 'bold', - color: theme.primaryColor, - marginBottom: 15, - }, - statsGrid: { - flexDirection: 'row' as const, - justifyContent: 'space-around' as const, - alignItems: 'center' as const, - flex: 1, - }, - statItem: { - alignItems: 'center' as const, - padding: 30, - }, - statValue: { - fontSize: 72, - fontWeight: 'bold', - color: theme.primaryColor, - marginBottom: 10, - }, - statLabel: { - fontSize: 20, - color: theme.textLight, - textTransform: 'uppercase' as const, - letterSpacing: 1, - }, - statsNote: { - textAlign: 'center' as const, - fontSize: 18, - color: theme.textLight, - marginTop: 20, - }, - quoteSlide: { - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - quoteText: { - fontSize: 36, - fontStyle: 'italic', - color: theme.textColor, - textAlign: 'center' as const, - maxWidth: 900, - lineHeight: 1.5, - }, - quoteAttribution: { - fontSize: 20, - color: theme.textLight, - marginTop: 30, - textAlign: 'center' as const, - }, - imageContainer: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - marginVertical: 20, - }, - slideImage: { - maxWidth: '100%', - maxHeight: 450, - objectFit: 'contain' as const, - }, - imageCaption: { - textAlign: 'center' as const, - fontSize: 18, - color: theme.textLight, - }, - teamGrid: { - flexDirection: 'row' as const, - justifyContent: 'center' as const, - gap: 50, - flex: 1, - alignItems: 'center' as const, - }, - teamMember: { - alignItems: 'center' as const, - maxWidth: 200, - }, - memberPhotoPlaceholder: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: theme.primaryColor, - marginBottom: 15, - }, - memberPhoto: { - width: 120, - height: 120, - borderRadius: 60, - marginBottom: 15, - }, - memberName: { - fontSize: 20, - fontWeight: 'bold', - color: theme.textColor, - textAlign: 'center' as const, - }, - memberRole: { - fontSize: 16, - color: theme.primaryColor, - marginTop: 5, - textAlign: 'center' as const, - }, - memberBio: { - fontSize: 14, - color: theme.textLight, - marginTop: 10, - textAlign: 'center' as const, - lineHeight: 1.4, - }, - ctaSlide: { - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - ctaTitle: { - fontSize: 56, - fontWeight: 'bold', - color: '#ffffff', - textAlign: 'center' as const, - marginBottom: 20, - }, - ctaSubtitle: { - fontSize: 24, - color: 'rgba(255, 255, 255, 0.9)', - textAlign: 'center' as const, - marginBottom: 40, - }, - ctaContact: { - fontSize: 20, - color: 'rgba(255, 255, 255, 0.8)', - textAlign: 'center' as const, - }, - }); - -type Styles = ReturnType; - -const TitleSlideComponent: React.FC<{ slide: TitleSlide; styles: Styles }> = ({ slide, styles }) => ( - - {slide.title} - {slide.subtitle && {slide.subtitle}} - {slide.presenter && {slide.presenter}} - - -); - -const SectionSlideComponent: React.FC<{ slide: SectionSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - - {String(pageNum).padStart(2, '0')} - {slide.title} - {slide.subtitle && {slide.subtitle}} - - {pageNum} - -); - -const ContentSlideComponent: React.FC<{ slide: ContentSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - {slide.content && {slide.content}} - {slide.items && ( - - {slide.items.map((item, i) => ( - - - {item} - - ))} - - )} - {pageNum} - -); - -const TwoColumnSlideComponent: React.FC<{ slide: TwoColumnSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - - {slide.columns.map((col, i) => ( - - {col.title && {col.title}} - {col.content && {col.content}} - {col.items && ( - - {col.items.map((item, j) => ( - - - {item} - - ))} - - )} - - ))} - - {pageNum} - -); - -const StatsSlideComponent: React.FC<{ slide: StatsSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - - {slide.stats.map((stat, i) => ( - - {stat.value} - {stat.label} - - ))} - - {slide.note && {slide.note}} - {pageNum} - -); - -const QuoteSlideComponent: React.FC<{ slide: QuoteSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - "{slide.quote}" - {slide.attribution && — {slide.attribution}} - {pageNum} - -); - -const ImageSlideComponent: React.FC<{ slide: ImageSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - - - - {slide.caption && {slide.caption}} - {pageNum} - -); - -const TeamSlideComponent: React.FC<{ slide: TeamSlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - - {slide.members.map((member, i) => ( - - {member.photoPath ? ( - - ) : ( - - )} - {member.name} - {member.role} - {member.bio && {member.bio}} - - ))} - - {pageNum} - -); - -const CTASlideComponent: React.FC<{ slide: CTASlide; pageNum: number; styles: Styles }> = ({ slide, pageNum, styles }) => ( - - {slide.title} - {slide.subtitle && {slide.subtitle}} - {slide.contact && {slide.contact}} - {pageNum} - -); - -const renderSlide = ( - slide: Slide, - index: number, - styles: Styles -): React.ReactElement => { - const pageNum = index + 1; - - switch (slide.type) { - case 'title': - return ; - case 'section': - return ; - case 'content': - return ; - case 'two-column': - return ; - case 'stats': - return ; - case 'quote': - return ; - case 'image': - return ; - case 'team': - return ; - case 'cta': - return ; - default: - return ; - } -}; - -const Presentation: React.FC = ({ slides, theme }) => { - const mergedTheme = { ...defaultTheme, ...theme }; - const styles = createStyles(mergedTheme); - - return {slides.map((slide, i) => renderSlide(slide, i, styles))}; -}; - -export async function generatePresentation( - data: PresentationData, - outputPath: string -): Promise { - await renderToFile(, outputPath); - return outputPath; -} diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts index efb57478..594c0f7b 100644 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts +++ b/apps/x/packages/core/src/application/assistant/skills/create-presentations/skill.ts @@ -1,216 +1,83 @@ export const skill = String.raw` # PDF Presentation Generator Skill -## Overview +## When to Use -This skill enables Rowboat to create visually compelling PDF presentations from natural language requests. You have full freedom to write and execute your own code to generate presentations — install any npm packages you need, generate charts, use custom layouts, and make the output look polished and professional. - -A minimal reference implementation using @react-pdf/renderer exists in the codebase at: -- **Types:** src/application/assistant/skills/create-presentations/types.ts -- **Generator:** src/application/assistant/skills/create-presentations/presentation-generator.tsx - -**This code is just a starting point.** It shows one basic approach to PDF generation. You are NOT limited to it. Feel free to: -- Write your own code from scratch -- Use different libraries (e.g., pdfkit, puppeteer with HTML/CSS, jsPDF, or anything else) -- Install any npm packages you need via executeCommand -- Generate charts and visualizations (e.g., chartjs-node-canvas, d3-node, vega-lite, mermaid) -- Render charts as PNG images and embed them in slides -- Create custom layouts, gradients, decorative elements — whatever makes the presentation look great - -## When to Use This Skill - -Activate this skill when the user requests: -- Creating presentations, slide decks, or pitch decks -- Making PDF slides for meetings, talks, or pitches -- Generating visual summaries or reports in presentation format -- Keywords: "presentation", "slides", "deck", "pitch deck", "slide deck", "PDF presentation" - -## Knowledge Sources - -Before creating any presentation, gather context from the user's knowledge base: - -~~~ -~/.rowboat/knowledge/ -├── company/ -│ ├── about.md # Company description, mission, vision -│ ├── team.md # Founder bios, team members -│ ├── metrics.md # KPIs, growth numbers, financials -│ ├── product.md # Product description, features, roadmap -│ └── branding.md # Colors, fonts, logo paths, style guide -├── fundraising/ -│ ├── previous-rounds.md # Past funding history -│ ├── investors.md # Current investors, target investors -│ ├── use-of-funds.md # How funds will be allocated -│ └── projections.md # Financial projections -├── market/ -│ ├── problem.md # Problem statement -│ ├── solution.md # How product solves it -│ ├── competitors.md # Competitive landscape -│ ├── tam-sam-som.md # Market size analysis -│ └── traction.md # Customer testimonials, case studies -└── assets/ - ├── logo.png # Company logo - ├── product-screenshots/ - └── team-photos/ -~~~ - -**Important:** Always check for and read relevant files from ~/.rowboat/knowledge/ before generating content. If files don't exist, ask the user for the information and offer to save it for future use. +Activate when the user wants to create presentations, slide decks, or pitch decks. ## Workflow -### Step 1: Understand the Request & Gather Preferences +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: -Before doing anything else, ask the user about their preferences: +~~~javascript +// save as /tmp/convert.js +const { chromium } = require('playwright'); +const path = require('path'); -1. **Content density**: Should the slides be text-heavy with detailed explanations, or minimal with just key points and big numbers? -2. **Color / theme**: Do they have brand colors or a color preference? (e.g., "use our brand blue #2563eb", "dark theme", "warm tones", "professional and clean") -3. **Presentation type**: pitch deck, product demo, team intro, investor update, etc. -4. **Audience**: investors, customers, internal team, conference -5. **Tone**: formal, casual, technical, inspirational -6. **Length**: number of slides (default: 10-12 for pitch decks) - -Ask these as a concise set of questions in a single message. Use any answers the user already provided in their initial request and only ask about what's missing. - -### Step 2: Gather Knowledge - -~~~bash -# Check what knowledge exists -ls -la ~/.rowboat/knowledge/ 2>/dev/null || echo "No knowledge directory found" - -# Read relevant files based on presentation type -# For a pitch deck, prioritize: -cat ~/.rowboat/knowledge/company/about.md 2>/dev/null -cat ~/.rowboat/knowledge/market/problem.md 2>/dev/null -cat ~/.rowboat/knowledge/company/metrics.md 2>/dev/null -cat ~/.rowboat/knowledge/company/branding.md 2>/dev/null +(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'); +})(); ~~~ -### Step 3: Present the Outline for Approval +5. Run it: 'node /tmp/convert.js' +6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" -Before generating slides, present a structured outline to the user: +Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it. -~~~ -## Proposed Presentation Outline +## PDF Export Rules -**Title:** [Presentation Title] -**Slides:** [N] slides -**Style:** [Color scheme / theme description] +**These rules prevent rendering issues in PDF. Violating them causes overlapping rectangles and broken layouts.** -### Flow: +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\`. -1. **Title Slide** - - Company name, tagline, presenter name +## Required CSS -2. **Problem** - - [One sentence summary of the problem] - -3. **Solution** - - [One sentence summary of your solution] - -... - ---- - -Does this look good? I can adjust the outline, then I'll go ahead and generate the PDF for you. -- Add/remove slides -- Reorder sections -- Adjust emphasis on any area +~~~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; } ~~~ -After the user approves (or after incorporating their feedback), immediately ask: **"I'll generate the PDF now — where should I save it?"** If the user has already indicated a path or preference, skip asking and generate directly. +## Playwright Export -**IMPORTANT:** Always generate the PDF. Never suggest the user copy content into Keynote, Google Slides, or any other tool. The whole point of this skill is to produce a finished PDF. +~~~typescript +import { chromium } from 'playwright'; -### Step 4: Generate the Presentation - -Write code to generate the presentation. You have complete freedom here: - -1. **Install any packages you need** via executeCommand (e.g., npm install @react-pdf/renderer chartjs-node-canvas) -2. **Write a script** that generates the PDF — you can use the reference code as inspiration or write something entirely different -3. **Generate charts** for any data that would benefit from visualization (revenue growth, market size, traction metrics, competitive positioning, etc.) — use chartjs-node-canvas, d3, vega, or any charting library -4. **Execute the script** to produce the final PDF - -## Visual Quality Guidelines - -**Do NOT produce plain, boring slides.** Make them look professional and visually engaging: - -- **Use color intentionally** — gradient backgrounds on title/CTA slides, accent colors for bullets and highlights, colored stat numbers -- **Apply the user's brand colors** throughout — not just on the title slide, but as accents, backgrounds, and highlights across all slides -- **Charts and visualizations** — whenever there are numbers (revenue, growth, market size, user counts), generate a chart instead of just listing numbers. Bar charts, line charts, pie charts, and simple diagrams make slides far more impactful -- **Visual hierarchy** — large bold headings, generous whitespace, clear separation between sections -- **Consistent theming** — every slide should feel like part of the same deck, with consistent colors, fonts, and spacing -- **Decorative elements** — subtle accent bars, colored bullets, gradient sections, and background tints add polish - -## Slide Types (Reference) - -These are common slide patterns. You can implement these or create your own: - -| Type | Description | When to Use | -|------|-------------|-------------| -| Title | Bold opening with gradient/colored background | First slide | -| Section | Section divider between topics | Between major sections | -| Content | Text with bullet points | Explaining concepts, lists | -| Two-column | Side-by-side comparison | Us vs. them, before/after | -| Stats | Big bold numbers | Key metrics, traction, market size | -| Chart | Data visualization | Revenue growth, market breakdown, trends | -| Quote | Testimonial or notable quote | Customer feedback, press quotes | -| Image | Full or partial image with caption | Product screenshots, team photos | -| Team | Grid of team member cards | Team introduction | -| CTA | Call to action / closing | Final slide | - -## Content Limits Per Slide - -Each slide is a fixed page. Content that exceeds the available space will overflow. Follow these limits: - -| Slide Type | Max Items / Content | -|------------|-------------------| -| Content | 5 bullet points max (~80 chars each). Paragraph text: max ~4 lines. | -| Two-column | 4 bullet points per column max (~60 chars each). | -| Stats | 3-4 stats max. Keep labels short. | -| Team | 4 members max per slide. Split into multiple slides if needed. | -| Quote | Keep quotes under ~200 characters. | - -**If the user's content needs more space**, split it across multiple slides rather than cramming it into one. - -## Pitch Deck Templates - -### Series A Pitch Deck (12 slides) - -1. **Title** - Company name, tagline, presenter -2. **Problem** - What pain point you solve -3. **Solution** - Your product/service -4. **Product** - Demo/screenshots -5. **Market** - TAM/SAM/SOM (use a chart!) -6. **Business Model** - How you make money -7. **Traction** - Metrics and growth (use charts!) -8. **Competition** - Positioning (two-column or matrix chart) -9. **Team** - Key team members -10. **Financials** - Projections (use a chart!) -11. **The Ask** - Funding amount and use (pie chart for allocation) -12. **Contact** - CTA with contact info - -### Product Demo Deck (8 slides) - -1. **Title** - Product name and tagline -2. **Problem** - User pain points -3. **Solution** - High-level approach -4. **Features** - Key capabilities (two-column) -5. **Demo** - Screenshots -6. **Pricing** - Plans and pricing -7. **Testimonials** - Customer quotes -8. **Get Started** - CTA - -## Best Practices - -1. **Keep slides simple** - One idea per slide -2. **Use charts for numbers** - Never just list numbers when a chart would be more impactful -3. **Limit bullet points** - 3-5 max per slide, keep them short -4. **Use two-column for comparisons** - Us vs. them, before/after -5. **End with clear CTA** - What do you want them to do? -6. **Gather knowledge first** - Check ~/.rowboat/knowledge/ before generating -7. **Use absolute paths** for images (PNG, JPG supported) -8. **Never overflow** - If content doesn't fit, split across multiple slides -9. **Make it visually rich** - Colors, charts, gradients — not just text on white backgrounds +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; +export default skill; \ No newline at end of file diff --git a/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts b/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts deleted file mode 100644 index 888eb2e7..00000000 --- a/apps/x/packages/core/src/application/assistant/skills/create-presentations/types.ts +++ /dev/null @@ -1,100 +0,0 @@ -export interface SlideBase { - type: string; - title?: string; - subtitle?: string; - content?: string; -} - -export interface TitleSlide extends SlideBase { - type: 'title'; - title: string; - subtitle?: string; - presenter?: string; -} - -export interface ContentSlide extends SlideBase { - type: 'content'; - title: string; - content?: string; - items?: string[]; -} - -export interface SectionSlide extends SlideBase { - type: 'section'; - title: string; - subtitle?: string; -} - -export interface StatsSlide extends SlideBase { - type: 'stats'; - title: string; - stats: Array<{ value: string; label: string }>; - note?: string; -} - -export interface TwoColumnSlide extends SlideBase { - type: 'two-column'; - title: string; - columns: [ - { title?: string; content?: string; items?: string[] }, - { title?: string; content?: string; items?: string[] } - ]; -} - -export interface QuoteSlide extends SlideBase { - type: 'quote'; - quote: string; - attribution?: string; -} - -export interface ImageSlide extends SlideBase { - type: 'image'; - title: string; - imagePath: string; - caption?: string; -} - -export interface TeamSlide extends SlideBase { - type: 'team'; - title: string; - members: Array<{ - name: string; - role: string; - bio?: string; - photoPath?: string; - }>; -} - -export interface CTASlide extends SlideBase { - type: 'cta'; - title: string; - subtitle?: string; - contact?: string; -} - -export type Slide = - | TitleSlide - | ContentSlide - | SectionSlide - | StatsSlide - | TwoColumnSlide - | QuoteSlide - | ImageSlide - | TeamSlide - | CTASlide; - -export interface Theme { - primaryColor: string; - secondaryColor: string; - accentColor: string; - textColor: string; - textLight: string; - background: string; - backgroundAlt: string; - fontFamily: string; -} - -export interface PresentationData { - slides: Slide[]; - theme?: Partial; -} diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 0d3756f8..49cb7d46 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -12,7 +12,6 @@ import * as workspace from "../../workspace/workspace.js"; import { IAgentsRepo } from "../../agents/repo.js"; import { WorkDir } from "../../config/config.js"; import type { ToolContext } from "./exec-tool.js"; -import { generatePresentation } from "../assistant/skills/create-presentations/presentation-generator.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars const BuiltinToolsSchema = z.record(z.string(), z.object({ @@ -607,76 +606,11 @@ export const BuiltinTools: z.infer = { }, }, - generatePresentation: { - description: 'Generate a PDF presentation from slide data. Creates a 16:9 PDF with styled slides.', - inputSchema: z.object({ - slides: z.array(z.object({ - type: z.enum(['title', 'content', 'section', 'stats', 'two-column', 'quote', 'image', 'team', 'cta']), - title: z.string().optional(), - subtitle: z.string().optional(), - content: z.string().optional(), - presenter: z.string().optional(), - items: z.array(z.string()).optional(), - stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(), - note: z.string().optional(), - columns: z.array(z.object({ - title: z.string().optional(), - content: z.string().optional(), - items: z.array(z.string()).optional(), - })).optional(), - quote: z.string().optional(), - attribution: z.string().optional(), - imagePath: z.string().optional(), - caption: z.string().optional(), - members: z.array(z.object({ - name: z.string(), - role: z.string(), - bio: z.string().optional(), - photoPath: z.string().optional(), - })).optional(), - contact: z.string().optional(), - })).describe('Array of slide objects'), - theme: z.object({ - primaryColor: z.string().optional(), - secondaryColor: z.string().optional(), - accentColor: z.string().optional(), - textColor: z.string().optional(), - textLight: z.string().optional(), - background: z.string().optional(), - backgroundAlt: z.string().optional(), - fontFamily: z.string().optional(), - }).optional().describe('Optional theme customization'), - outputPath: z.string().describe('Absolute path for the output PDF file'), - }), - execute: async ({ slides, theme, outputPath }: { - slides: Array>; - theme?: Record; - outputPath: string; - }) => { - try { - const result = await generatePresentation( - { slides: slides as never, theme }, - outputPath, - ); - return { - success: true, - outputPath: result, - slideCount: slides.length, - }; - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } - }, - }, - executeCommand: { 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 }, ctx?: ToolContext) => { try { From 2105f6d6cde9c4485152cd275cdd88c94d2d3096 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Sat, 31 Jan 2026 00:15:12 +0530 Subject: [PATCH 09/21] dropping files into the chat add the path --- apps/x/apps/preload/src/preload.ts | 8 +++++-- apps/x/apps/renderer/src/App.tsx | 31 ++++++++++++++++++++++++++++ apps/x/apps/renderer/src/global.d.ts | 3 +++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/preload/src/preload.ts b/apps/x/apps/preload/src/preload.ts index 1def7bdd..7d7d53e4 100644 --- a/apps/x/apps/preload/src/preload.ts +++ b/apps/x/apps/preload/src/preload.ts @@ -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); \ No newline at end of file +contextBridge.exposeInMainWorld('ipc', ipc); + +contextBridge.exposeInMainWorld('electronUtils', { + getPathForFile: (file: File) => webUtils.getPathForFile(file), +}); \ No newline at end of file diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 69576938..f100b9bb 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -322,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 (
void ): () => void; }; + electronUtils: { + getPathForFile: (file: File) => string; + }; } } From 84c101fa210addd24075f972580dc5d1e823480d Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Sat, 31 Jan 2026 18:59:43 +0530 Subject: [PATCH 10/21] fix to: UX issue: if I scroll the text before the copilot has finished generating, then it scrolls back up after the end of generation. Rename tasks -> Chats and make it the default view --- apps/x/apps/renderer/src/App.tsx | 2 +- .../apps/renderer/src/components/ai-elements/conversation.tsx | 1 - apps/x/apps/renderer/src/components/sidebar-content.tsx | 2 +- apps/x/apps/renderer/src/components/sidebar-icon.tsx | 4 ++-- apps/x/apps/renderer/src/contexts/sidebar-context.tsx | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index f100b9bb..7e9ceac8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1666,7 +1666,7 @@ function App() { return ( - +
{/* Icon sidebar - always visible, fixed position */} diff --git a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx index aa380f57..35baf6c4 100644 --- a/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx +++ b/apps/x/apps/renderer/src/components/ai-elements/conversation.tsx @@ -13,7 +13,6 @@ export const Conversation = ({ className, ...props }: ConversationProps) => ( diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 8820dfed..b82cb95a 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -95,7 +95,7 @@ type SidebarContentPanelProps = { const sectionTitles = { knowledge: "Knowledge", - tasks: "Tasks", + tasks: "Chats", } export function SidebarContentPanel({ diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index 93709fb8..54aa0622 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -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() { diff --git a/apps/x/apps/renderer/src/contexts/sidebar-context.tsx b/apps/x/apps/renderer/src/contexts/sidebar-context.tsx index 438d79e3..61e4bb65 100644 --- a/apps/x/apps/renderer/src/contexts/sidebar-context.tsx +++ b/apps/x/apps/renderer/src/contexts/sidebar-context.tsx @@ -20,7 +20,7 @@ export function useSidebarSection() { } export function SidebarSectionProvider({ - defaultSection = "knowledge", + defaultSection = "tasks", onSectionChange, children, }: { From bbe82c124d18d855a5341c571fe9db5c20370f63 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 3 Feb 2026 07:25:33 +0530 Subject: [PATCH 11/21] feat: integrate Supabase OAuth with OIDC discovery for authentication Add rowboat auth flow using Supabase as the OIDC provider. User info is fetched via the standard OIDC userinfo endpoint (discovered from issuer metadata) instead of a hard-coded Supabase URL. Includes login screen, auth state hook, IPC handlers, logout button, and id_token_sub persistence for userinfo fetches across app restarts. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/ipc.ts | 20 ++++ apps/x/apps/main/src/oauth-handler.ts | 82 +++++++++++++++- apps/x/apps/renderer/src/App.tsx | 22 ++++- .../renderer/src/components/login-screen.tsx | 51 ++++++++++ .../renderer/src/components/sidebar-icon.tsx | 25 ++++- .../apps/renderer/src/hooks/useRowboatAuth.ts | 97 +++++++++++++++++++ apps/x/packages/core/src/auth/oauth-client.ts | 28 +++++- apps/x/packages/core/src/auth/providers.ts | 17 +++- apps/x/packages/core/src/auth/types.ts | 1 + apps/x/packages/shared/src/ipc.ts | 33 +++++++ 10 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/login-screen.tsx create mode 100644 apps/x/apps/renderer/src/hooks/useRowboatAuth.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index be662f29..03253c72 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -6,6 +6,8 @@ import { isConnected, getConnectedProviders, listProviders, + getAuthStatus, + logoutRowboat, } from './oauth-handler.js'; import { watcher as watcherCore, workspace } from '@x/core'; import { workspace as workspaceShared } from '@x/shared'; @@ -220,6 +222,15 @@ export function emitOAuthEvent(event: { provider: string; success: boolean; erro } } +export function emitAuthEvent(event: { isAuthenticated: boolean; user: { email: string; name?: string } | null }): void { + const windows = BrowserWindow.getAllWindows(); + for (const win of windows) { + if (!win.isDestroyed() && win.webContents) { + win.webContents.send('auth:didAuthenticate', event); + } + } +} + let runsWatcher: (() => void) | null = null; export async function startRunsWatcher(): Promise { if (runsWatcher) { @@ -344,5 +355,14 @@ export function setupIpcHandlers() { markOnboardingComplete(); return { success: true }; }, + 'auth:getStatus': async () => { + return await getAuthStatus(); + }, + 'auth:login': async () => { + return await connectProvider('rowboat'); + }, + 'auth:logout': async () => { + return await logoutRowboat(); + }, }); } \ No newline at end of file diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 5b55e8b7..11caf424 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -10,10 +10,13 @@ import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js'; import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; -import { emitOAuthEvent } from './ipc.js'; +import { emitOAuthEvent, emitAuthEvent } from './ipc.js'; const REDIRECT_URI = 'http://localhost:8080/oauth/callback'; +// Cached user info for the rowboat provider +let cachedRowboatUser: { email: string; name?: string } | null = null; + // Store active OAuth flows (state -> { codeVerifier, provider, config }) const activeFlows = new Map { + try { + const oauthRepo = getOAuthRepo(); + const connected = await oauthRepo.isConnected('rowboat'); + if (!connected) { + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // If we have cached user info, return it + if (cachedRowboatUser) { + return { isAuthenticated: true, user: cachedRowboatUser }; + } + + // Get stored tokens to check for id_token_sub + const storedTokens = await oauthRepo.getTokens('rowboat'); + if (!storedTokens?.id_token_sub) { + // Legacy tokens without sub claim — require re-login + console.log('[OAuth] No id_token_sub in stored tokens, requiring re-login'); + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // Try to get access token (will refresh if needed) + const accessToken = await getAccessToken('rowboat'); + if (!accessToken) { + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + // Fetch user info via OIDC discovery + try { + const config = await getProviderConfiguration('rowboat'); + cachedRowboatUser = await oauthClient.fetchUserInfo(config, accessToken, storedTokens.id_token_sub); + } catch (error) { + console.error('[OAuth] Failed to fetch user info via OIDC:', error); + cachedRowboatUser = null; + return { isAuthenticated: false, user: null }; + } + + return { isAuthenticated: true, user: cachedRowboatUser }; + } catch (error) { + console.error('[OAuth] Auth status check failed:', error); + return { isAuthenticated: false, user: null }; + } +} + +/** + * Logout from rowboat (clear tokens and cached user) + */ +export async function logoutRowboat(): Promise<{ success: boolean }> { + cachedRowboatUser = null; + return disconnectProvider('rowboat'); +} + /** * Initiate OAuth flow for a provider */ @@ -181,13 +242,18 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Exchange code for tokens console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`); - const tokens = await oauthClient.exchangeCodeForTokens( + const { tokens, sub } = await oauthClient.exchangeCodeForTokens( flow.config, callbackUrl, flow.codeVerifier, state ); + // Persist the subject claim for future userinfo fetches + if (sub) { + tokens.id_token_sub = sub; + } + // Save tokens console.log(`[OAuth] Token exchange successful for ${provider}`); await oauthRepo.saveTokens(provider, tokens); @@ -200,6 +266,18 @@ export async function connectProvider(provider: string): Promise<{ success: bool triggerFirefliesSync(); } + // For rowboat provider, fetch user info and emit auth event + if (provider === 'rowboat' && sub) { + try { + const userInfo = await oauthClient.fetchUserInfo(flow.config, tokens.access_token, sub); + cachedRowboatUser = userInfo; + emitAuthEvent({ isAuthenticated: true, user: userInfo }); + } catch (error) { + console.error('[OAuth] Failed to fetch user info via OIDC:', error); + emitAuthEvent({ isAuthenticated: true, user: null }); + } + } + // Emit success event to renderer emitOAuthEvent({ provider, success: true }); } catch (error) { diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 7e9ceac8..e31c6240 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -50,6 +50,8 @@ import { Separator } from "@/components/ui/separator" import { Toaster } from "@/components/ui/sonner" import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { OnboardingModal } from '@/components/onboarding-modal' +import { useRowboatAuth } from '@/hooks/useRowboatAuth' +import { LoginScreen } from '@/components/login-screen' type DirEntry = z.infer type RunEventType = z.infer @@ -441,6 +443,24 @@ function ChatInputWithMentions({ } function App() { + const auth = useRowboatAuth() + + if (auth.isLoading) { + return ( +
+ +
+ ) + } + + if (!auth.isAuthenticated) { + return + } + + return +} + +function AppContent({ auth }: { auth: ReturnType }) { // File browser state (for Knowledge section) const [selectedPath, setSelectedPath] = useState(null) const [fileHistoryBack, setFileHistoryBack] = useState([]) @@ -1669,7 +1689,7 @@ function App() {
{/* Icon sidebar - always visible, fixed position */} - + {/* Spacer for the fixed icon sidebar */}
diff --git a/apps/x/apps/renderer/src/components/login-screen.tsx b/apps/x/apps/renderer/src/components/login-screen.tsx new file mode 100644 index 00000000..acf69609 --- /dev/null +++ b/apps/x/apps/renderer/src/components/login-screen.tsx @@ -0,0 +1,51 @@ +import { Button } from './ui/button'; +import { LoaderIcon } from 'lucide-react'; + +interface LoginScreenProps { + isLoggingIn: boolean; + error: string | null; + login: () => Promise; +} + +export function LoginScreen({ isLoggingIn, error, login }: LoginScreenProps) { + return ( +
+
+
+ Rowboat +
+

+ Sign in to your Rowboat account to continue. +

+ + {error && ( +
+ {error} +
+ )} + + + + {isLoggingIn && ( +

+ Complete sign-in in your browser, then return here. +

+ )} +
+
+ ); +} diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index 54aa0622..e45e1093 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -4,6 +4,7 @@ import * as React from "react" import { Brain, HelpCircle, + LogOut, MessageSquare, Plug, Settings, @@ -31,7 +32,12 @@ const navItems: NavItem[] = [ { id: "knowledge", title: "Knowledge", icon: Brain }, ] -export function SidebarIcon() { +interface SidebarIconProps { + user?: { email: string; name?: string } | null; + onLogout?: () => void; +} + +export function SidebarIcon({ user, onLogout }: SidebarIconProps = {}) { const { activeSection, setActiveSection } = useSidebarSection() return ( @@ -88,6 +94,23 @@ export function SidebarIcon() { + + {/* Sign out */} + {onLogout && ( + + + + + + {user?.email ? `Sign out (${user.email})` : 'Sign out'} + + + )}
) diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts b/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts new file mode 100644 index 00000000..caa9c969 --- /dev/null +++ b/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts @@ -0,0 +1,97 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface RowboatAuthState { + isAuthenticated: boolean; + isLoading: boolean; + isLoggingIn: boolean; + user: { email: string; name?: string } | null; + error: string | null; + login: () => Promise; + logout: () => Promise; +} + +export function useRowboatAuth(): RowboatAuthState { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isLoggingIn, setIsLoggingIn] = useState(false); + const [user, setUser] = useState<{ email: string; name?: string } | null>(null); + const [error, setError] = useState(null); + + // Check auth status on mount + useEffect(() => { + async function checkStatus() { + try { + const result = await window.ipc.invoke('auth:getStatus', null); + setIsAuthenticated(result.isAuthenticated); + setUser(result.user); + } catch (err) { + console.error('Failed to check auth status:', err); + setIsAuthenticated(false); + setUser(null); + } finally { + setIsLoading(false); + } + } + checkStatus(); + }, []); + + // Listen for auth events + useEffect(() => { + const cleanup = window.ipc.on('auth:didAuthenticate', (event) => { + setIsAuthenticated(event.isAuthenticated); + setUser(event.user); + setIsLoggingIn(false); + setError(null); + }); + return cleanup; + }, []); + + // Also listen for oauth:didConnect for the rowboat provider (handles errors) + useEffect(() => { + const cleanup = window.ipc.on('oauth:didConnect', (event) => { + if (event.provider !== 'rowboat') return; + if (!event.success) { + setIsLoggingIn(false); + setError(event.error || 'Login failed'); + } + }); + return cleanup; + }, []); + + const login = useCallback(async () => { + try { + setIsLoggingIn(true); + setError(null); + const result = await window.ipc.invoke('auth:login', null); + if (!result.success) { + setIsLoggingIn(false); + setError(result.error || 'Failed to start login'); + } + // If success, the OAuth flow has started - wait for auth:didAuthenticate event + } catch (err) { + console.error('Login failed:', err); + setIsLoggingIn(false); + setError('Failed to start login'); + } + }, []); + + const logout = useCallback(async () => { + try { + await window.ipc.invoke('auth:logout', null); + setIsAuthenticated(false); + setUser(null); + } catch (err) { + console.error('Logout failed:', err); + } + }, []); + + return { + isAuthenticated, + isLoading, + isLoggingIn, + user, + error, + login, + logout, + }; +} diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts index 613cee2e..719c8af1 100644 --- a/apps/x/packages/core/src/auth/oauth-client.ts +++ b/apps/x/packages/core/src/auth/oauth-client.ts @@ -159,13 +159,16 @@ export function buildAuthorizationUrl( state: string; } ): URL { - return client.buildAuthorizationUrl(config, { + const url = client.buildAuthorizationUrl(config, { redirect_uri: params.redirectUri, scope: params.scope, code_challenge: params.codeChallenge, code_challenge_method: 'S256', state: params.state, }); + + console.log(`[OAuth] Authorization URL: ${url}`); + return url; } /** @@ -176,7 +179,7 @@ export async function exchangeCodeForTokens( callbackUrl: URL, codeVerifier: string, expectedState: string -): Promise { +): Promise<{ tokens: OAuthTokens; sub?: string }> { console.log(`[OAuth] Exchanging authorization code for tokens...`); const response = await client.authorizationCodeGrant(config, callbackUrl, { @@ -184,8 +187,27 @@ export async function exchangeCodeForTokens( expectedState, }); + const claims = response.claims(); console.log(`[OAuth] Token exchange successful`); - return toOAuthTokens(response); + return { + tokens: toOAuthTokens(response), + sub: claims?.sub, + }; +} + +/** + * Fetch user info from the OIDC userinfo endpoint (discovered via issuer metadata) + */ +export async function fetchUserInfo( + config: client.Configuration, + accessToken: string, + expectedSubject: string +): Promise<{ email: string; name?: string }> { + const userInfo = await client.fetchUserInfo(config, accessToken, expectedSubject); + return { + email: userInfo.email ?? '', + name: userInfo.name, + }; } /** diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index edda5447..9be05314 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,7 +77,22 @@ const providerConfigs: ProviderConfig = { 'profile', 'email', ] - } + }, + rowboat: { + discovery: { + mode: 'issuer', + issuer: 'https://yhafoahozylbdyyyqjep.supabase.co/auth/v1', + }, + client: { + mode: 'static', + clientId: '0b8a99ec-b5b2-4ddf-8e14-69a3a1675114', + }, + scopes: [ + 'openid', + 'email', + 'profile', + ], + }, }; /** diff --git a/apps/x/packages/core/src/auth/types.ts b/apps/x/packages/core/src/auth/types.ts index 249d63b4..bd4bb319 100644 --- a/apps/x/packages/core/src/auth/types.ts +++ b/apps/x/packages/core/src/auth/types.ts @@ -9,6 +9,7 @@ export const OAuthTokens = z.object({ expires_at: z.number(), // Unix timestamp token_type: z.literal('Bearer').optional(), scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response + id_token_sub: z.string().optional(), // Subject claim from ID token }); export type OAuthTokens = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index ca69646a..e85247fe 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -244,6 +244,39 @@ const ipcSchemas = { success: z.literal(true), }), }, + 'auth:getStatus': { + req: z.null(), + res: z.object({ + isAuthenticated: z.boolean(), + user: z.object({ + email: z.string(), + name: z.string().optional(), + }).nullable(), + }), + }, + 'auth:login': { + req: z.null(), + res: z.object({ + success: z.boolean(), + error: z.string().optional(), + }), + }, + 'auth:logout': { + req: z.null(), + res: z.object({ + success: z.boolean(), + }), + }, + 'auth:didAuthenticate': { + req: z.object({ + isAuthenticated: z.boolean(), + user: z.object({ + email: z.string(), + name: z.string().optional(), + }).nullable(), + }), + res: z.null(), + }, } as const; // ============================================================================ From aa2a830f237ae67b53ed30c8f5b6b38ae9590c64 Mon Sep 17 00:00:00 2001 From: tusharmagar Date: Mon, 2 Feb 2026 21:34:47 +0530 Subject: [PATCH 12/21] feat: slack integration with composio Allow users to ask copilot to use Slack on their behalf via Composio integration. Adds composio client, OAuth flow, slack skill with tool catalog, and UI for connecting Slack in onboarding and connectors popover. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/auth-server.ts | 78 ++-- apps/x/apps/main/src/composio-handler.ts | 296 ++++++++++++++ apps/x/apps/main/src/ipc.ts | 28 +- .../src/components/composio-api-key-modal.tsx | 94 +++++ .../src/components/connectors-popover.tsx | 171 +++++++- .../src/components/onboarding-modal.tsx | 158 ++++++- apps/x/packages/core/package.json | 1 + .../src/application/assistant/instructions.ts | 3 + .../src/application/assistant/skills/index.ts | 44 +- .../assistant/skills/slack/skill.ts | 121 ++++++ .../assistant/skills/slack/tool-catalog.ts | 117 ++++++ .../core/src/application/lib/builtin-tools.ts | 387 ++++++++++++++++++ apps/x/packages/core/src/composio/client.ts | 358 ++++++++++++++++ apps/x/packages/core/src/composio/index.ts | 5 + apps/x/packages/core/src/composio/repo.ts | 140 +++++++ apps/x/packages/core/src/composio/types.ts | 237 +++++++++++ apps/x/packages/shared/src/ipc.ts | 81 +++- apps/x/pnpm-lock.yaml | 71 ++++ 18 files changed, 2309 insertions(+), 81 deletions(-) create mode 100644 apps/x/apps/main/src/composio-handler.ts create mode 100644 apps/x/apps/renderer/src/components/composio-api-key-modal.tsx create mode 100644 apps/x/packages/core/src/application/assistant/skills/slack/skill.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts create mode 100644 apps/x/packages/core/src/composio/client.ts create mode 100644 apps/x/packages/core/src/composio/index.ts create mode 100644 apps/x/packages/core/src/composio/repo.ts create mode 100644 apps/x/packages/core/src/composio/types.ts diff --git a/apps/x/apps/main/src/auth-server.ts b/apps/x/apps/main/src/auth-server.ts index 8309e3e6..b0b890c0 100644 --- a/apps/x/apps/main/src/auth-server.ts +++ b/apps/x/apps/main/src/auth-server.ts @@ -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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + 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 ): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => { @@ -46,7 +56,7 @@ export function createAuthServer(

Authorization Failed

-

Error: ${error}

+

Error: ${escapeHtml(error)}

You can close this window.

@@ -55,48 +65,28 @@ export function createAuthServer( return; } - if (code && state) { - onCallback(code, state); - - res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(` - - - - Authorization Successful - - - -

Authorization Successful

-

You can close this window.

- - - - `); - } else { - res.writeHead(400, { 'Content-Type': 'text/html' }); - res.end(` - - - - OAuth Error - - - -

Invalid Request

-

Missing code or state parameter.

-

You can close this window.

- - - - `); - } + // 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(` + + + + Authorization Successful + + + +

Authorization Successful

+

You can close this window.

+ + + + `); } else { res.writeHead(404); res.end('Not Found'); diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts new file mode 100644 index 00000000..e5b25d1a --- /dev/null +++ b/apps/x/apps/main/src/composio-handler.ts @@ -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(); + +/** + * 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).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 +): 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', + }; + } +} diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 03253c72..42690bda 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -17,11 +17,12 @@ 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 { 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; @@ -364,5 +365,30 @@ export function setupIpcHandlers() { 'auth:logout': async () => { return await logoutRowboat(); }, + // 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); + }, }); } \ No newline at end of file diff --git a/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx new file mode 100644 index 00000000..826fe04f --- /dev/null +++ b/apps/x/apps/renderer/src/components/composio-api-key-modal.tsx @@ -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 ( + + + + Enter Composio API Key + + Get your API key from{" "} + + app.composio.dev/settings + + + +
+ + setApiKey(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index ced6064f..1799ab75 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -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,7 @@ 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 { toast } from "sonner" interface ProviderState { @@ -40,6 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) 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 +93,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 +202,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,6 +246,26 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, [refreshAllStatuses]) + // 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 + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -289,6 +394,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } return ( + <> {tooltip ? ( @@ -368,10 +474,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) {/* Fireflies */} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')} + + + + {/* Team Communication Section - Slack */} +
+ Team Communication +
+ + {/* Slack */} +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( + + ) : ( + + )} +
+
)}
+ + ) } diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 48119934..074ad645 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -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, @@ -14,6 +14,7 @@ import { import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" import { cn } from "@/lib/utils" +import { ComposioApiKeyModal } from "@/components/composio-api-key-modal" import { toast } from "sonner" interface ProviderState { @@ -41,6 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { 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) + // Track connected providers for the completion step const connectedProviders = Object.entries(providerStates) .filter(([, state]) => state.isConnected) @@ -94,11 +101,70 @@ 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]) + // 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 +191,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,6 +225,26 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) + // 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 + }, []) + // Connect to a provider const handleConnect = useCallback(async (provider: string) => { setProviderStates(prev => ({ @@ -291,6 +377,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + // Render Slack row + const renderSlackRow = () => ( +
+
+
+ +
+
+ Slack + {slackLoading ? ( + Checking... + ) : ( + + Send messages and view channels + + )} +
+
+
+ {slackLoading ? ( + + ) : slackConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Step 0: Welcome const WelcomeStep = () => (
@@ -358,6 +488,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { {renderGranolaRow()} {providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', , 'AI meeting transcripts')}
+ + {/* Team Communication Section */} +
+
+ Team Communication +
+ {renderSlackRow()} +
)}
@@ -375,7 +513,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected return (
@@ -416,6 +554,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { Granola (Local meeting notes)
)} + {slackConnected && ( +
+ + Slack (Team communication) +
+ )} @@ -429,6 +573,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } return ( + <> + {}}> } + ) } diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index 2633e4d0..d30c199c 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,6 +12,7 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", + "@composio/core": "^0.1.48", "@ai-sdk/openai-compatible": "^1.0.27", "@ai-sdk/provider": "^2.0.0", "@google-cloud/local-auth": "^3.0.1", diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 3785c493..57f3a446 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -30,6 +30,8 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects, **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. @@ -159,6 +161,7 @@ When a user asks for ANY task that might require external capabilities (web sear - \`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. **Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`. diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 391f9523..6ef19e8d 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -7,18 +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; }; @@ -33,70 +32,66 @@ const definitions: SkillDefinition[] = [ { id: "create-presentations", title: "Create Presentations", - folder: "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, }, @@ -104,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) => [ @@ -154,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, @@ -164,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, ]; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts new file mode 100644 index 00000000..140c6ab7 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/skill.ts @@ -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; diff --git a/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts new file mode 100644 index 00000000..d720c9d9 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/slack/tool-catalog.ts @@ -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"); diff --git a/apps/x/packages/core/src/application/lib/builtin-tools.ts b/apps/x/packages/core/src/application/lib/builtin-tools.ts index 49cb7d46..9411eb41 100644 --- a/apps/x/packages/core/src/application/lib/builtin-tools.ts +++ b/apps/x/packages/core/src/application/lib/builtin-tools.ts @@ -11,6 +11,9 @@ 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 @@ -23,6 +26,232 @@ const BuiltinToolsSchema = z.record(z.string(), z.object({ }), })); +type SlackToolHint = { + search?: string; + patterns: string[]; + fallbackSlugs?: string[]; + preferSlugIncludes?: string[]; + excludePatterns?: string[]; + minScore?: number; +}; + +const slackToolHints: Record = { + 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(); + +const slackToolSlugOverrides: Partial> = { + 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) => + 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 +): Promise { + 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 = { loadSkill: { description: "Load a Rowboat skill definition into context by fetching its guidance string", @@ -673,4 +902,162 @@ export const BuiltinTools: z.infer = { } }, }, + + // ============================================================================ + // 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 }) => { + 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 }); + }, + }, }; diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts new file mode 100644 index 00000000..5a1004ce --- /dev/null +++ b/apps/x/packages/core/src/composio/client.ts @@ -0,0 +1,358 @@ +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; + +/** + * 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( + schema: T, + url: string, + options: RequestInit = {}, +): Promise> { + 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>>> { + 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> { + 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>>> { + 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 +): Promise> { + 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> { + 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 +): Promise> { + 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> { + 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> { + 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> }; + + 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 +): Promise> { + console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`); + + try { + const client = getComposioClient(); + const result = await client.tools.execute(actionSlug, { + userId: connectedAccountId, + arguments: input, + connectedAccountId, + }); + + 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 }; + } +} diff --git a/apps/x/packages/core/src/composio/index.ts b/apps/x/packages/core/src/composio/index.ts new file mode 100644 index 00000000..a4636f99 --- /dev/null +++ b/apps/x/packages/core/src/composio/index.ts @@ -0,0 +1,5 @@ +// Composio integration for Rowboat X + +export * from './types.js'; +export * from './client.js'; +export * from './repo.js'; diff --git a/apps/x/packages/core/src/composio/repo.ts b/apps/x/packages/core/src/composio/repo.ts new file mode 100644 index 00000000..73f8f3eb --- /dev/null +++ b/apps/x/packages/core/src/composio/repo.ts @@ -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; + +/** + * Interface for Composio accounts repository + */ +export interface IComposioAccountsRepo { + getAccount(toolkitSlug: string): LocalConnectedAccount | null; + getAllAccounts(): Record; + 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 { + 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(); diff --git a/apps/x/packages/core/src/composio/types.ts b/apps/x/packages/core/src/composio/types.ts new file mode 100644 index 00000000..e2cbaf56 --- /dev/null +++ b/apps/x/packages/core/src/composio/types.ts @@ -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 = (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; +export type ConnectedAccountStatus = z.infer; +export type Toolkit = z.infer; +export type Tool = z.infer; +export type AuthConfig = z.infer; +export type ConnectedAccount = z.infer; +export type LocalConnectedAccount = z.infer; +export type ExecuteActionRequest = z.infer; +export type ExecuteActionResponse = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index e85247fe..4c83ef56 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -261,19 +261,88 @@ const ipcSchemas = { error: z.string().optional(), }), }, + // 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(), + }), + }, 'auth:logout': { req: z.null(), res: z.object({ success: z.boolean(), }), }, - 'auth:didAuthenticate': { + 'composio:initiate-connection': { req: z.object({ - isAuthenticated: z.boolean(), - user: z.object({ - email: z.string(), - name: z.string().optional(), - }).nullable(), + 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(), }, diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 5ea5f4d3..31db92c0 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -290,6 +290,9 @@ importers: '@ai-sdk/provider': specifier: ^2.0.0 version: 2.0.1 + '@composio/core': + specifier: ^0.1.48 + version: 0.1.55(zod@4.2.1) '@google-cloud/local-auth': specifier: ^3.0.1 version: 3.0.1(encoding@0.1.13) @@ -674,6 +677,19 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@composio/client@0.1.0-alpha.37': + resolution: {integrity: sha512-2YzXiRXlxqOgEz7nEh1aKdJ9vwQRb69+RX6UH+LTDmuTwW+pOjI2qbhIBxTbP2z9e2SyHVG0/AtMxc9tR5jU0w==} + + '@composio/core@0.1.55': + resolution: {integrity: sha512-GLSWTS/gZeycQ7W2wSZQ21DKV+LC6WTAilhSj+JG9Apslx3re9luF1Lyblm4UMSf4DzYWKOrtipwQOaeg7bmTg==} + peerDependencies: + zod: '>=3.25.76 <5' + + '@composio/json-schema-to-zod@0.1.16': + resolution: {integrity: sha512-vu6RUQTWDW/0wLDWsQHtWJ97JsnMjA1olTQwVD88BAtKZTdDLuzjnGRxSllxp5JG/GntnovUphraZWcWfe80eQ==} + peerDependencies: + zod: '>=3.25.76 <5' + '@electron-forge/cli@7.11.1': resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==} engines: {node: '>= 16.4.0'} @@ -5375,6 +5391,18 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} + openai@5.23.2: + resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openid-client@6.8.1: resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==} @@ -5692,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'} @@ -6310,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'} @@ -6469,6 +6503,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -7374,6 +7412,27 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@composio/client@0.1.0-alpha.37': {} + + '@composio/core@0.1.55(zod@4.2.1)': + dependencies: + '@composio/client': 0.1.0-alpha.37 + '@composio/json-schema-to-zod': 0.1.16(zod@4.2.1) + '@types/json-schema': 7.0.15 + chalk: 4.1.2 + openai: 5.23.2(zod@4.2.1) + pusher-js: 8.4.0 + semver: 7.7.3 + uuid: 13.0.0 + zod: 4.2.1 + zod-to-json-schema: 3.25.1(zod@4.2.1) + transitivePeerDependencies: + - ws + + '@composio/json-schema-to-zod@0.1.16(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) @@ -13026,6 +13085,10 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@5.23.2(zod@4.2.1): + optionalDependencies: + zod: 4.2.1 + openid-client@6.8.1: dependencies: jose: 6.1.3 @@ -13386,6 +13449,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 @@ -14110,6 +14177,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@1.0.3: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -14269,6 +14338,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + uuid@9.0.1: {} validate-npm-package-license@3.0.4: From d12150f1bf8d922b432b483664668d0f899330f7 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 2 Feb 2026 21:34:54 +0530 Subject: [PATCH 13/21] feat: move gmail sync to composio OAuth and remove calendar sync Migrate gmail_sync to use Composio for OAuth authentication instead of direct Google OAuth. Add gmail connection UI to onboarding and connectors. Remove calendar sync functionality. Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/main/src/composio-handler.ts | 4 + apps/x/apps/main/src/main.ts | 5 +- apps/x/apps/main/src/oauth-handler.ts | 2 - .../src/components/connectors-popover.tsx | 159 +++++- .../src/components/onboarding-modal.tsx | 138 ++++- .../packages/core/src/knowledge/sync_gmail.ts | 515 ++++++++++-------- 6 files changed, 542 insertions(+), 281 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index e5b25d1a..f72613ad 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -3,6 +3,7 @@ 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'; +import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -151,6 +152,9 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ if (accountStatus.status === 'ACTIVE') { emitComposioEvent({ toolkitSlug, success: true }); + if (toolkitSlug === 'gmail') { + triggerGmailSync(); + } } else { emitComposioEvent({ toolkitSlug, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 6ddab7bc..c276301b 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; -import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; + import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; @@ -134,9 +134,6 @@ app.whenReady().then(async () => { // start gmail sync initGmailSync(); - // start calendar sync - initCalendarSync(); - // start fireflies sync initFirefliesSync(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 11caf424..da4b7e62 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -7,7 +7,6 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov 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'; -import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent, emitAuthEvent } from './ipc.js'; @@ -260,7 +259,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Trigger immediate sync for relevant providers if (provider === 'google') { - triggerGmailSync(); triggerCalendarSync(); } else if (provider === 'fireflies-ai') { triggerFirefliesSync(); diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 1799ab75..882a8d48 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -41,8 +41,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio/Slack state + // Composio state (Gmail + Slack) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail') + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) const [slackConnected, setSlackConnected] = useState(false) const [slackLoading, setSlackLoading] = useState(true) const [slackConnecting, setSlackConnecting] = useState(false) @@ -93,6 +97,20 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Load Gmail connection status + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + // Load Slack connection status const refreshSlackStatus = useCallback(async () => { try { @@ -107,6 +125,53 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + // Success will be handled by composio:didConnect event + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + + // Disconnect from Gmail + const handleDisconnectGmail = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) + if (result.success) { + setGmailConnected(false) + toast.success('Disconnected from Gmail') + } else { + toast.error('Failed to disconnect from Gmail') + } + } catch (error) { + console.error('Failed to disconnect from Gmail:', error) + toast.error('Failed to disconnect from Gmail') + } finally { + setGmailLoading(false) + } + }, []) + // Connect to Slack via Composio const startSlackConnect = useCallback(async () => { try { @@ -126,9 +191,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) // 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) { + setComposioApiKeyTarget('slack') setComposioApiKeyOpen(true) return } @@ -141,13 +206,17 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) await window.ipc.invoke('composio:set-api-key', { apiKey }) setComposioApiKeyOpen(false) toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() + // Start the connection for whichever toolkit triggered the API key prompt + if (composioApiKeyTarget === 'gmail') { + await startGmailConnect() + } else { + await startSlackConnect() + } } catch (error) { console.error('Failed to save Composio API key:', error) toast.error('Failed to save API key') } - }, [startSlackConnect]) + }, [composioApiKeyTarget, startGmailConnect, startSlackConnect]) // Disconnect from Slack const handleDisconnectSlack = useCallback(async () => { @@ -173,7 +242,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) // Refresh Granola refreshGranolaConfig() - // Refresh Slack status + // Refresh Composio connections + refreshGmailStatus() refreshSlackStatus() // Refresh OAuth providers @@ -202,7 +272,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -227,7 +297,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) if (success) { const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - // Show detailed message for Google and Fireflies (includes sync info) + // Show detailed message for providers that sync in background if (provider === 'google' || provider === 'fireflies-ai') { toast.success(`Connected to ${displayName}`, { description: 'Syncing your data in the background. This may take a few minutes before changes appear.', @@ -251,7 +321,19 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event - if (toolkitSlug === 'slack') { + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail', { + description: 'Syncing your emails in the background. This may take a few minutes.', + duration: 8000, + }) + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } else if (toolkitSlug === 'slack') { setSlackConnected(success) setSlackConnecting(false) @@ -431,16 +513,55 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) : ( <> - {/* Email & Calendar Section - Google */} - {providers.includes('google') && ( - <> -
- Email & Calendar + {/* Email Section - Gmail via Composio */} +
+ Email +
+
+
+
+
- {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar')} - - - )} +
+ Gmail + {gmailLoading ? ( + Checking... + ) : ( + Sync emails + )} +
+
+
+ {gmailLoading ? ( + + ) : gmailConnected ? ( + + ) : ( + + )} +
+
+ + {/* Meeting Notes Section - Granola & Fireflies */}
@@ -537,7 +658,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) open={composioApiKeyOpen} onOpenChange={setComposioApiKeyOpen} onSubmit={handleComposioApiKeySubmit} - isSubmitting={slackConnecting} + isSubmitting={gmailConnecting || slackConnecting} /> ) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 074ad645..0675697c 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -42,8 +42,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio/Slack state + // Composio state (Gmail + Slack) const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) + const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail') + const [gmailConnected, setGmailConnected] = useState(false) + const [gmailLoading, setGmailLoading] = useState(true) + const [gmailConnecting, setGmailConnecting] = useState(false) const [slackConnected, setSlackConnected] = useState(false) const [slackLoading, setSlackLoading] = useState(true) const [slackConnecting, setSlackConnecting] = useState(false) @@ -101,6 +105,47 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Load Gmail connection status + const refreshGmailStatus = useCallback(async () => { + try { + setGmailLoading(true) + const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) + setGmailConnected(result.isConnected) + } catch (error) { + console.error('Failed to load Gmail status:', error) + setGmailConnected(false) + } finally { + setGmailLoading(false) + } + }, []) + + // Connect to Gmail via Composio + const startGmailConnect = useCallback(async () => { + try { + setGmailConnecting(true) + const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) + if (!result.success) { + toast.error(result.error || 'Failed to connect to Gmail') + setGmailConnecting(false) + } + } catch (error) { + console.error('Failed to connect to Gmail:', error) + toast.error('Failed to connect to Gmail') + setGmailConnecting(false) + } + }, []) + + // Handle Gmail connect button click + const handleConnectGmail = useCallback(async () => { + const configResult = await window.ipc.invoke('composio:is-configured', null) + if (!configResult.configured) { + setComposioApiKeyTarget('gmail') + setComposioApiKeyOpen(true) + return + } + await startGmailConnect() + }, [startGmailConnect]) + // Load Slack connection status const refreshSlackStatus = useCallback(async () => { try { @@ -134,9 +179,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // 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) { + setComposioApiKeyTarget('slack') setComposioApiKeyOpen(true) return } @@ -149,20 +194,24 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { await window.ipc.invoke('composio:set-api-key', { apiKey }) setComposioApiKeyOpen(false) toast.success('Composio API key saved') - // Now start the Slack connection - await startSlackConnect() + if (composioApiKeyTarget === 'gmail') { + await startGmailConnect() + } else { + await startSlackConnect() + } } catch (error) { console.error('Failed to save Composio API key:', error) toast.error('Failed to save API key') } - }, [startSlackConnect]) + }, [composioApiKeyTarget, startGmailConnect, startSlackConnect]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() - // Refresh Slack status + // Refresh Composio connections + refreshGmailStatus() refreshSlackStatus() // Refresh OAuth providers @@ -191,7 +240,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -230,7 +279,16 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event - if (toolkitSlug === 'slack') { + if (toolkitSlug === 'gmail') { + setGmailConnected(success) + setGmailConnecting(false) + + if (success) { + toast.success('Connected to Gmail') + } else { + toast.error(error || 'Failed to connect to Gmail') + } + } else if (toolkitSlug === 'slack') { setSlackConnected(success) setSlackConnecting(false) @@ -377,6 +435,48 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) + // Render Gmail row (Composio) + const renderGmailRow = () => ( +
+
+
+ +
+
+ Gmail + {gmailLoading ? ( + Checking... + ) : ( + Sync emails + )} +
+
+
+ {gmailLoading ? ( + + ) : gmailConnected ? ( +
+ + Connected +
+ ) : ( + + )} +
+
+ ) + // Render Slack row const renderSlackRow = () => (
@@ -470,15 +570,13 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) : ( <> - {/* Email & Calendar Section */} - {providers.includes('google') && ( -
-
- Email & Calendar -
- {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events')} + {/* Email Section - Gmail via Composio */} +
+
+ Email
- )} + {renderGmailRow()} +
{/* Meeting Notes Section */}
@@ -513,7 +611,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected + const hasConnections = connectedProviders.length > 0 || gmailConnected || granolaEnabled || slackConnected return (
@@ -536,10 +634,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {

Connected accounts:

- {connectedProviders.includes('google') && ( + {gmailConnected && (
- Google (Email & Calendar) + Gmail (Email)
)} {connectedProviders.includes('fireflies-ai') && ( @@ -578,7 +676,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { open={composioApiKeyOpen} onOpenChange={setComposioApiKeyOpen} onSubmit={handleComposioApiKeySubmit} - isSubmitting={slackConnecting} + isSubmitting={gmailConnecting || slackConnecting} /> {}}> |]/g, "").substring(0, 100).trim(); } -function decodeBase64(data: string): string { - return Buffer.from(data, 'base64').toString('utf-8'); +// --- State Management --- + +interface SyncState { + last_sync: string; // ISO string — human-readable, source of truth } -function getBody(payload: gmail.Schema$MessagePart): string { - let body = ""; - if (payload.parts) { - for (const part of payload.parts) { - if (part.mimeType === 'text/plain' && part.body && part.body.data) { - const text = decodeBase64(part.body.data); - // Strip quoted lines - const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>')); - body += cleanLines.join('\n'); - } else if (part.mimeType === 'text/html' && part.body && part.body.data) { - const html = decodeBase64(part.body.data); - const md = nhm.translate(html); - // Simple quote stripping for MD - const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>')); - body += cleanLines.join('\n'); - } else if (part.parts) { - body += getBody(part); +function loadState(stateFile: string): SyncState | null { + if (fs.existsSync(stateFile)) { + try { + const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + if (data.last_sync) { + return { last_sync: data.last_sync }; } + } catch (e) { + console.error('[Gmail] Failed to load state:', e); } - } else if (payload.body && payload.body.data) { - const data = decodeBase64(payload.body.data); - if (payload.mimeType === 'text/html') { - const md = nhm.translate(data); - body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); - } else { - body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); - } - } - return body; -} - -async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise { - const filename = part.filename; - const attId = part.body?.attachmentId; - if (!filename || !attId) return null; - - const safeName = `${msgId}_${cleanFilename(filename)}`; - const filePath = path.join(attachmentsDir, safeName); - - if (fs.existsSync(filePath)) return safeName; - - try { - const res = await gmail.users.messages.attachments.get({ - userId, - messageId: msgId, - id: attId - }); - - const data = res.data.data; - if (data) { - fs.writeFileSync(filePath, Buffer.from(data, 'base64')); - console.log(`Saved attachment: ${safeName}`); - return safeName; - } - } catch (e) { - console.error(`Error saving attachment ${filename}:`, e); } return null; } +function saveState(stateFile: string, lastSync: string): void { + const state: SyncState = { + last_sync: lastSync, + }; + fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); +} + +/** + * Try to parse a date string into a Date. Returns null if unparseable. + */ +function tryParseDate(dateStr: string): Date | null { + const d = new Date(dateStr); + return isNaN(d.getTime()) ? null : d; +} + +function toEpochSeconds(isoString: string): number { + return Math.floor(new Date(isoString).getTime() / 1000); +} + +// --- Message Parsing --- + +interface ParsedMessage { + from: string; + date: string; + subject: string; + body: string; +} + +function parseMessageData(messageData: Record): ParsedMessage { + const headers = messageData.payload && typeof messageData.payload === 'object' + ? (messageData.payload as Record).headers as Array<{ name: string; value: string }> | undefined + : undefined; + + const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown'); + const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown'); + const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)'); + + let body = ''; + + // Try to extract body from payload structure (Gmail API format) + if (messageData.payload && typeof messageData.payload === 'object') { + body = extractBodyFromPayload(messageData.payload as Record); + } + + // Fallback: try snippet or body fields + if (!body) { + if (typeof messageData.body === 'string') { + body = messageData.body; + } else if (typeof messageData.snippet === 'string') { + body = messageData.snippet; + } else if (typeof messageData.text === 'string') { + body = messageData.text; + } + } + + // Convert HTML to markdown if body looks like HTML + if (body && (body.includes(' !line.trim().startsWith('>')).join('\n'); + } + + return { from, date, subject, body }; +} + +function extractBodyFromPayload(payload: Record): string { + const parts = payload.parts as Array> | undefined; + + if (parts) { + for (const part of parts) { + const mimeType = part.mimeType as string | undefined; + const bodyData = part.body && typeof part.body === 'object' + ? (part.body as Record).data as string | undefined + : undefined; + + if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) { + const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); + if (mimeType === 'text/html') { + return nhm.translate(decoded); + } + return decoded; + } + + // Recurse into nested parts + if (part.parts) { + const result = extractBodyFromPayload(part as Record); + if (result) return result; + } + } + } + + // Single-part message + const bodyData = payload.body && typeof payload.body === 'object' + ? (payload.body as Record).data as string | undefined + : undefined; + + if (bodyData) { + const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); + const mimeType = payload.mimeType as string | undefined; + if (mimeType === 'text/html') { + return nhm.translate(decoded); + } + return decoded; + } + + return ''; +} + // --- Sync Logic --- -async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) { - const gmail = google.gmail({ version: 'v1', auth }); +/** + * Process a thread and write its .md file. + * Returns the newest message date (as ISO string) found in the thread, or null. + */ +async function processThread(connectedAccountId: string, threadId: string, syncDir: string): Promise { + let threadResult; try { - const res = await gmail.users.threads.get({ userId: 'me', id: threadId }); - const thread = res.data; - const messages = thread.messages; + threadResult = await executeAction( + 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', + connectedAccountId, + { thread_id: threadId, user_id: 'me' } + ); + } catch (error) { + console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); + return null; + } - if (!messages || messages.length === 0) return; + if (!threadResult.success || !threadResult.data) { + console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); + return null; + } - // Subject from first message - const firstHeader = messages[0].payload?.headers; - const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)'; + const data = threadResult.data as Record; + const messages = data.messages as Array> | undefined; - let mdContent = `# ${subject}\n\n`; + let newestDate: Date | null = null; + + if (!messages || messages.length === 0) { + // Single message response + const parsed = parseMessageData(data); + const mdContent = `# ${parsed.subject}\n\n` + + `**Thread ID:** ${threadId}\n` + + `**Message Count:** 1\n\n---\n\n` + + `### From: ${parsed.from}\n` + + `**Date:** ${parsed.date}\n\n` + + `${parsed.body}\n\n---\n\n`; + + fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); + console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`); + newestDate = tryParseDate(parsed.date); + } else { + // Multi-message thread + const firstParsed = parseMessageData(messages[0]); + let mdContent = `# ${firstParsed.subject}\n\n`; mdContent += `**Thread ID:** ${threadId}\n`; mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; for (const msg of messages) { - const msgId = msg.id!; - const headers = msg.payload?.headers || []; - const from = headers.find(h => h.name === 'From')?.value || 'Unknown'; - const date = headers.find(h => h.name === 'Date')?.value || 'Unknown'; + const parsed = parseMessageData(msg); + mdContent += `### From: ${parsed.from}\n`; + mdContent += `**Date:** ${parsed.date}\n\n`; + mdContent += `${parsed.body}\n\n`; + mdContent += `---\n\n`; - mdContent += `### From: ${from}\n`; - mdContent += `**Date:** ${date}\n\n`; - - if (msg.payload) { - const body = getBody(msg.payload); - mdContent += `${body}\n\n`; + const msgDate = tryParseDate(parsed.date); + if (msgDate && (!newestDate || msgDate > newestDate)) { + newestDate = msgDate; } - - // Attachments - const parts: gmail.Schema$MessagePart[] = []; - const traverseParts = (pList: gmail.Schema$MessagePart[]) => { - for (const p of pList) { - parts.push(p); - if (p.parts) traverseParts(p.parts); - } - }; - if (msg.payload?.parts) traverseParts(msg.payload.parts); - - let attachmentsFound = false; - for (const part of parts) { - if (part.filename && part.body?.attachmentId) { - const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir); - if (savedName) { - if (!attachmentsFound) { - mdContent += "**Attachments:**\n"; - attachmentsFound = true; - } - mdContent += `- [${part.filename}](attachments/${savedName})\n`; - } - } - } - mdContent += "\n---\n\n"; } - fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); - console.log(`Synced Thread: ${subject} (${threadId})`); - - } catch (error) { - console.error(`Error processing thread ${threadId}:`, error); + fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); + console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`); } -} -function loadState(stateFile: string): { historyId?: string } { - if (fs.existsSync(stateFile)) { - return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - } - return {}; -} - -function saveState(historyId: string, stateFile: string) { - fs.writeFileSync(stateFile, JSON.stringify({ - historyId, - last_sync: new Date().toISOString() - }, null, 2)); -} - -async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { - console.log(`Performing full sync of last ${lookbackDays} days...`); - const gmail = google.gmail({ version: 'v1', auth }); - - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - lookbackDays); - const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); - - // Get History ID - const profile = await gmail.users.getProfile({ userId: 'me' }); - const currentHistoryId = profile.data.historyId!; - - let pageToken: string | undefined; - do { - const res = await gmail.users.threads.list({ - userId: 'me', - q: `after:${dateQuery}`, - pageToken - }); - - const threads = res.data.threads; - if (threads) { - for (const thread of threads) { - await processThread(auth, thread.id!, syncDir, attachmentsDir); - } - } - pageToken = res.data.nextPageToken ?? undefined; - } while (pageToken); - - saveState(currentHistoryId, stateFile); - console.log("Full sync complete."); -} - -async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { - console.log(`Checking updates since historyId ${startHistoryId}...`); - const gmail = google.gmail({ version: 'v1', auth }); - - try { - const res = await gmail.users.history.list({ - userId: 'me', - startHistoryId, - historyTypes: ['messageAdded'] - }); - - const changes = res.data.history; - if (!changes || changes.length === 0) { - console.log("No new changes."); - const profile = await gmail.users.getProfile({ userId: 'me' }); - saveState(profile.data.historyId!, stateFile); - return; - } - - console.log(`Found ${changes.length} history records.`); - const threadIds = new Set(); - - for (const record of changes) { - if (record.messagesAdded) { - for (const item of record.messagesAdded) { - if (item.message?.threadId) { - threadIds.add(item.message.threadId); - } - } - } - } - - for (const tid of threadIds) { - await processThread(auth, tid, syncDir, attachmentsDir); - } - - const profile = await gmail.users.getProfile({ userId: 'me' }); - saveState(profile.data.historyId!, stateFile); - - } catch (error: unknown) { - const e = error as { response?: { status?: number } }; - if (e.response?.status === 404) { - console.log("History ID expired. Falling back to full sync."); - await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays); - } else { - console.error("Error during partial sync:", error); - // If 401, clear tokens to force re-auth next run - if (e.response?.status === 401) { - console.log("401 Unauthorized, clearing cache"); - GoogleClientFactory.clearCache(); - } - } - } + if (!newestDate) return null; + // Add 1 second so the `after:` query (epoch-second granularity) excludes this email next sync + return new Date(newestDate.getTime() + 1000).toISOString(); } async function performSync() { - const LOOKBACK_DAYS = 30; // Default to 1 month const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); @@ -285,51 +252,127 @@ async function performSync() { if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); + const account = composioAccountsRepo.getAccount('gmail'); + if (!account || account.status !== 'ACTIVE') { + console.log('[Gmail] Gmail not connected via Composio. Skipping sync.'); + return; + } + + const connectedAccountId = account.id; + + // Determine query timestamp + const state = loadState(STATE_FILE); + let afterEpochSeconds: number; + + if (state) { + afterEpochSeconds = toEpochSeconds(state.last_sync); + console.log(`[Gmail] Syncing messages since ${state.last_sync}...`); + } else { + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - LOOKBACK_DAYS); + afterEpochSeconds = Math.floor(pastDate.getTime() / 1000); + console.log(`[Gmail] First sync - fetching last ${LOOKBACK_DAYS} days...`); + } + try { - const auth = await GoogleClientFactory.getClient(); - if (!auth) { - console.log("No valid OAuth credentials available."); + // List threads since last sync (lightweight - returns IDs only) + const allThreadIds: string[] = []; + let pageToken: string | undefined; + + do { + const params: Record = { + query: `after:${afterEpochSeconds}`, + max_results: 20, + user_id: 'me', + }; + if (pageToken) { + params.page_token = pageToken; + } + + const result = await executeAction( + 'GMAIL_LIST_THREADS', + connectedAccountId, + params + ); + + if (!result.success || !result.data) { + console.error('[Gmail] Failed to list threads:', result.error); + return; + } + + const data = result.data as Record; + const threads = data.threads as Array> | undefined; + + if (threads && threads.length > 0) { + for (const thread of threads) { + const threadId = thread.id as string | undefined; + if (threadId) { + allThreadIds.push(threadId); + } + } + } + + pageToken = data.nextPageToken as string | undefined; + } while (pageToken); + + if (allThreadIds.length === 0) { + console.log('[Gmail] No new threads.'); return; } - console.log("Authorization successful. Starting sync..."); + console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`); - const state = loadState(STATE_FILE); - if (!state.historyId) { - console.log("No history ID found, starting full sync..."); - await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); - } else { - console.log("History ID found, starting partial sync..."); - await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); + // Reverse so we process oldest first. Gmail returns newest first, + // so processing in reverse lets the high-water mark advance + // chronologically — safe to save state after each thread. + allThreadIds.reverse(); + + // Process each thread, saving state after each one with the + // newest email date seen so far (high-water mark). + let highWaterMark: string | null = state?.last_sync ?? null; + let processedCount = 0; + for (const threadId of allThreadIds) { + try { + const newestInThread = await processThread(connectedAccountId, threadId, SYNC_DIR); + processedCount++; + + // Advance high-water mark if this thread has a newer email + if (newestInThread) { + if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) { + highWaterMark = newestInThread; + } + saveState(STATE_FILE, highWaterMark); + } + } catch (error) { + console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error); + } } - console.log("Sync completed."); + console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`); + } catch (error) { - console.error("Error during sync:", error); + console.error('[Gmail] Error during sync:', error); } } export async function init() { - console.log("Starting Gmail Sync (TS)..."); - console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); + console.log('[Gmail] Starting Gmail Sync (Composio)...'); + console.log(`[Gmail] Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - // Check if credentials are available with required scopes - const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); - - if (!hasCredentials) { - console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); + const isConnected = composioAccountsRepo.isConnected('gmail'); + + if (!isConnected) { + console.log('[Gmail] Gmail not connected via Composio. Sleeping...'); } else { - // Perform one sync await performSync(); } } catch (error) { - console.error("Error in main loop:", error); + console.error('[Gmail] Error in main loop:', error); } - // Sleep for N minutes before next check (can be interrupted by triggerSync) - console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); + console.log(`[Gmail] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await interruptibleSleep(SYNC_INTERVAL_MS); } } From 3e2ed4cbc4178bbbeb0ee0aaf058427e8be7e53b Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Mon, 2 Feb 2026 22:01:37 +0530 Subject: [PATCH 14/21] upgrade composio and dependencies --- apps/x/packages/core/package.json | 2 +- apps/x/packages/core/src/composio/client.ts | 1 + apps/x/pnpm-lock.yaml | 45 +++++++++------------ 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/apps/x/packages/core/package.json b/apps/x/packages/core/package.json index d30c199c..7fb0bc68 100644 --- a/apps/x/packages/core/package.json +++ b/apps/x/packages/core/package.json @@ -12,7 +12,7 @@ "@ai-sdk/anthropic": "^2.0.44", "@ai-sdk/google": "^2.0.25", "@ai-sdk/openai": "^2.0.53", - "@composio/core": "^0.1.48", + "@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", diff --git a/apps/x/packages/core/src/composio/client.ts b/apps/x/packages/core/src/composio/client.ts index 5a1004ce..97d1e8b2 100644 --- a/apps/x/packages/core/src/composio/client.ts +++ b/apps/x/packages/core/src/composio/client.ts @@ -346,6 +346,7 @@ export async function executeAction( userId: connectedAccountId, arguments: input, connectedAccountId, + dangerouslySkipVersionCheck: true, }); console.log(`[Composio] Action completed successfully`); diff --git a/apps/x/pnpm-lock.yaml b/apps/x/pnpm-lock.yaml index 31db92c0..5995b0ea 100644 --- a/apps/x/pnpm-lock.yaml +++ b/apps/x/pnpm-lock.yaml @@ -291,8 +291,8 @@ importers: specifier: ^2.0.0 version: 2.0.1 '@composio/core': - specifier: ^0.1.48 - version: 0.1.55(zod@4.2.1) + 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) @@ -677,16 +677,16 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} - '@composio/client@0.1.0-alpha.37': - resolution: {integrity: sha512-2YzXiRXlxqOgEz7nEh1aKdJ9vwQRb69+RX6UH+LTDmuTwW+pOjI2qbhIBxTbP2z9e2SyHVG0/AtMxc9tR5jU0w==} + '@composio/client@0.1.0-alpha.56': + resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==} - '@composio/core@0.1.55': - resolution: {integrity: sha512-GLSWTS/gZeycQ7W2wSZQ21DKV+LC6WTAilhSj+JG9Apslx3re9luF1Lyblm4UMSf4DzYWKOrtipwQOaeg7bmTg==} + '@composio/core@0.6.2': + resolution: {integrity: sha512-q4ICwbKdSfFcdWHkgaKf8FBUu7/dohYOCn04MhPU2JcbaVOZ2haGEGv7TdACxMx6zUQpx6HVGKCA0ewH+9MBQA==} peerDependencies: - zod: '>=3.25.76 <5' + zod: ^3.25 || ^4 - '@composio/json-schema-to-zod@0.1.16': - resolution: {integrity: sha512-vu6RUQTWDW/0wLDWsQHtWJ97JsnMjA1olTQwVD88BAtKZTdDLuzjnGRxSllxp5JG/GntnovUphraZWcWfe80eQ==} + '@composio/json-schema-to-zod@0.1.20': + resolution: {integrity: sha512-d4V34itLrUWG/VBh7ciznKcxF/T22MBLHmuEzHoX0zsBOHsUmjYz5qtDh20S2p3FE+HHvLZxpXiv8yfdd4yI+Q==} peerDependencies: zod: '>=3.25.76 <5' @@ -5391,12 +5391,12 @@ packages: resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} engines: {node: '>=8'} - openai@5.23.2: - resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + openai@6.17.0: + resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==} hasBin: true peerDependencies: ws: ^8.18.0 - zod: ^3.23.8 + zod: ^3.25 || ^4.0 peerDependenciesMeta: ws: optional: true @@ -6503,10 +6503,6 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true - uuid@13.0.0: - resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} - hasBin: true - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -7412,24 +7408,23 @@ snapshots: '@chevrotain/utils@11.0.3': {} - '@composio/client@0.1.0-alpha.37': {} + '@composio/client@0.1.0-alpha.56': {} - '@composio/core@0.1.55(zod@4.2.1)': + '@composio/core@0.6.2(zod@4.2.1)': dependencies: - '@composio/client': 0.1.0-alpha.37 - '@composio/json-schema-to-zod': 0.1.16(zod@4.2.1) + '@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: 5.23.2(zod@4.2.1) + openai: 6.17.0(zod@4.2.1) pusher-js: 8.4.0 semver: 7.7.3 - uuid: 13.0.0 zod: 4.2.1 zod-to-json-schema: 3.25.1(zod@4.2.1) transitivePeerDependencies: - ws - '@composio/json-schema-to-zod@0.1.16(zod@4.2.1)': + '@composio/json-schema-to-zod@0.1.20(zod@4.2.1)': dependencies: zod: 4.2.1 @@ -13085,7 +13080,7 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 - openai@5.23.2(zod@4.2.1): + openai@6.17.0(zod@4.2.1): optionalDependencies: zod: 4.2.1 @@ -14338,8 +14333,6 @@ snapshots: uuid@11.1.0: {} - uuid@13.0.0: {} - uuid@9.0.1: {} validate-npm-package-license@3.0.4: From d7b84f87d0c8581166fee53d7b2694439677f14b Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:49:53 +0530 Subject: [PATCH 15/21] feat: voice notes with instant transcription and knowledge graph integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Voice memos now create notes immediately in knowledge/Voice Memos// - Transcription shows directly in the note (Recording... → Transcribing... → transcript) - Graph builder processes voice memos from knowledge directory - Note creation agents now use workspace tools (writeFile, edit, grep, glob) instead of executeCommand - Removes executeCommand dependency - no more permission prompts blocking knowledge updates Co-Authored-By: Claude Opus 4.5 --- apps/x/apps/renderer/src/App.tsx | 24 ++ .../src/components/sidebar-content.tsx | 231 +++++++++++++++++- .../core/src/knowledge/build_graph.ts | 159 +++++++++++- .../core/src/knowledge/note_creation_high.ts | 182 +++++++++----- .../core/src/knowledge/note_creation_low.ts | 124 +++++++--- .../src/knowledge/note_creation_medium.ts | 142 +++++++---- 6 files changed, 720 insertions(+), 142 deletions(-) diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index e31c6240..f45bd486 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -1461,6 +1461,29 @@ function AppContent({ auth }: { auth: ReturnType }) { }, }), [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 @@ -1707,6 +1730,7 @@ function AppContent({ auth }: { auth: ReturnType }) { expandedPaths={expandedPaths} onSelectFile={toggleExpand} knowledgeActions={knowledgeActions} + onVoiceNoteCreated={handleVoiceNoteCreated} runs={runs} currentRunId={runId} tasksActions={{ diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index b82cb95a..af0d1896 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -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 onSelectFile: (path: string, kind: "file" | "dir") => void knowledgeActions: KnowledgeActions + onVoiceNoteCreated?: (path: string) => void runs?: RunListItem[] currentRunId?: string | null tasksActions?: TasksActions @@ -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 { + 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(null) + const chunksRef = React.useRef([]) + const notePathRef = React.useRef(null) + const timestampRef = React.useRef(null) + const relativePathRef = React.useRef(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 ( + + + + + + {isRecording ? 'Stop Recording' : 'New Voice Note'} + + + ) +} + // Knowledge Section function KnowledgeSection({ tree, @@ -148,15 +374,17 @@ function KnowledgeSection({ expandedPaths, onSelectFile, actions, + onVoiceNoteCreated, }: { tree: TreeNode[] selectedPath: string | null expandedPaths: Set 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({ {action.label} ))} + - - {isLoggingIn && ( -

- Complete sign-in in your browser, then return here. -

- )} -
-
- ); -} diff --git a/apps/x/apps/renderer/src/components/sidebar-icon.tsx b/apps/x/apps/renderer/src/components/sidebar-icon.tsx index e45e1093..54aa0622 100644 --- a/apps/x/apps/renderer/src/components/sidebar-icon.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-icon.tsx @@ -4,7 +4,6 @@ import * as React from "react" import { Brain, HelpCircle, - LogOut, MessageSquare, Plug, Settings, @@ -32,12 +31,7 @@ const navItems: NavItem[] = [ { id: "knowledge", title: "Knowledge", icon: Brain }, ] -interface SidebarIconProps { - user?: { email: string; name?: string } | null; - onLogout?: () => void; -} - -export function SidebarIcon({ user, onLogout }: SidebarIconProps = {}) { +export function SidebarIcon() { const { activeSection, setActiveSection } = useSidebarSection() return ( @@ -94,23 +88,6 @@ export function SidebarIcon({ user, onLogout }: SidebarIconProps = {}) { - - {/* Sign out */} - {onLogout && ( - - - - - - {user?.email ? `Sign out (${user.email})` : 'Sign out'} - - - )}
) diff --git a/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts b/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts deleted file mode 100644 index caa9c969..00000000 --- a/apps/x/apps/renderer/src/hooks/useRowboatAuth.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useState, useEffect, useCallback } from 'react'; - -interface RowboatAuthState { - isAuthenticated: boolean; - isLoading: boolean; - isLoggingIn: boolean; - user: { email: string; name?: string } | null; - error: string | null; - login: () => Promise; - logout: () => Promise; -} - -export function useRowboatAuth(): RowboatAuthState { - const [isAuthenticated, setIsAuthenticated] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [isLoggingIn, setIsLoggingIn] = useState(false); - const [user, setUser] = useState<{ email: string; name?: string } | null>(null); - const [error, setError] = useState(null); - - // Check auth status on mount - useEffect(() => { - async function checkStatus() { - try { - const result = await window.ipc.invoke('auth:getStatus', null); - setIsAuthenticated(result.isAuthenticated); - setUser(result.user); - } catch (err) { - console.error('Failed to check auth status:', err); - setIsAuthenticated(false); - setUser(null); - } finally { - setIsLoading(false); - } - } - checkStatus(); - }, []); - - // Listen for auth events - useEffect(() => { - const cleanup = window.ipc.on('auth:didAuthenticate', (event) => { - setIsAuthenticated(event.isAuthenticated); - setUser(event.user); - setIsLoggingIn(false); - setError(null); - }); - return cleanup; - }, []); - - // Also listen for oauth:didConnect for the rowboat provider (handles errors) - useEffect(() => { - const cleanup = window.ipc.on('oauth:didConnect', (event) => { - if (event.provider !== 'rowboat') return; - if (!event.success) { - setIsLoggingIn(false); - setError(event.error || 'Login failed'); - } - }); - return cleanup; - }, []); - - const login = useCallback(async () => { - try { - setIsLoggingIn(true); - setError(null); - const result = await window.ipc.invoke('auth:login', null); - if (!result.success) { - setIsLoggingIn(false); - setError(result.error || 'Failed to start login'); - } - // If success, the OAuth flow has started - wait for auth:didAuthenticate event - } catch (err) { - console.error('Login failed:', err); - setIsLoggingIn(false); - setError('Failed to start login'); - } - }, []); - - const logout = useCallback(async () => { - try { - await window.ipc.invoke('auth:logout', null); - setIsAuthenticated(false); - setUser(null); - } catch (err) { - console.error('Logout failed:', err); - } - }, []); - - return { - isAuthenticated, - isLoading, - isLoggingIn, - user, - error, - login, - logout, - }; -} diff --git a/apps/x/packages/core/src/auth/oauth-client.ts b/apps/x/packages/core/src/auth/oauth-client.ts index 719c8af1..613cee2e 100644 --- a/apps/x/packages/core/src/auth/oauth-client.ts +++ b/apps/x/packages/core/src/auth/oauth-client.ts @@ -159,16 +159,13 @@ export function buildAuthorizationUrl( state: string; } ): URL { - const url = client.buildAuthorizationUrl(config, { + return client.buildAuthorizationUrl(config, { redirect_uri: params.redirectUri, scope: params.scope, code_challenge: params.codeChallenge, code_challenge_method: 'S256', state: params.state, }); - - console.log(`[OAuth] Authorization URL: ${url}`); - return url; } /** @@ -179,7 +176,7 @@ export async function exchangeCodeForTokens( callbackUrl: URL, codeVerifier: string, expectedState: string -): Promise<{ tokens: OAuthTokens; sub?: string }> { +): Promise { console.log(`[OAuth] Exchanging authorization code for tokens...`); const response = await client.authorizationCodeGrant(config, callbackUrl, { @@ -187,27 +184,8 @@ export async function exchangeCodeForTokens( expectedState, }); - const claims = response.claims(); console.log(`[OAuth] Token exchange successful`); - return { - tokens: toOAuthTokens(response), - sub: claims?.sub, - }; -} - -/** - * Fetch user info from the OIDC userinfo endpoint (discovered via issuer metadata) - */ -export async function fetchUserInfo( - config: client.Configuration, - accessToken: string, - expectedSubject: string -): Promise<{ email: string; name?: string }> { - const userInfo = await client.fetchUserInfo(config, accessToken, expectedSubject); - return { - email: userInfo.email ?? '', - name: userInfo.name, - }; + return toOAuthTokens(response); } /** diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index 9be05314..edda5447 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -77,22 +77,7 @@ const providerConfigs: ProviderConfig = { 'profile', 'email', ] - }, - rowboat: { - discovery: { - mode: 'issuer', - issuer: 'https://yhafoahozylbdyyyqjep.supabase.co/auth/v1', - }, - client: { - mode: 'static', - clientId: '0b8a99ec-b5b2-4ddf-8e14-69a3a1675114', - }, - scopes: [ - 'openid', - 'email', - 'profile', - ], - }, + } }; /** diff --git a/apps/x/packages/core/src/auth/types.ts b/apps/x/packages/core/src/auth/types.ts index bd4bb319..249d63b4 100644 --- a/apps/x/packages/core/src/auth/types.ts +++ b/apps/x/packages/core/src/auth/types.ts @@ -9,7 +9,6 @@ export const OAuthTokens = z.object({ expires_at: z.number(), // Unix timestamp token_type: z.literal('Bearer').optional(), scopes: z.array(z.string()).optional(), // Granted scopes from OAuth response - id_token_sub: z.string().optional(), // Subject claim from ID token }); export type OAuthTokens = z.infer; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 4c83ef56..79746516 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -244,23 +244,6 @@ const ipcSchemas = { success: z.literal(true), }), }, - 'auth:getStatus': { - req: z.null(), - res: z.object({ - isAuthenticated: z.boolean(), - user: z.object({ - email: z.string(), - name: z.string().optional(), - }).nullable(), - }), - }, - 'auth:login': { - req: z.null(), - res: z.object({ - success: z.boolean(), - error: z.string().optional(), - }), - }, // Composio integration channels 'composio:is-configured': { req: z.null(), @@ -277,12 +260,6 @@ const ipcSchemas = { error: z.string().optional(), }), }, - 'auth:logout': { - req: z.null(), - res: z.object({ - success: z.boolean(), - }), - }, 'composio:initiate-connection': { req: z.object({ toolkitSlug: z.string(), From fbdf9cd834016a888cce784be1a99ff1e4d4ad5f Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 3 Feb 2026 22:54:33 +0530 Subject: [PATCH 17/21] Revert "feat: move gmail sync to composio OAuth and remove calendar sync" This reverts commit d12150f1bf8d922b432b483664668d0f899330f7. --- apps/x/apps/main/src/composio-handler.ts | 4 - apps/x/apps/main/src/main.ts | 5 +- apps/x/apps/main/src/oauth-handler.ts | 2 + .../src/components/connectors-popover.tsx | 159 +----- .../src/components/onboarding-modal.tsx | 138 +---- .../packages/core/src/knowledge/sync_gmail.ts | 517 ++++++++---------- 6 files changed, 282 insertions(+), 543 deletions(-) diff --git a/apps/x/apps/main/src/composio-handler.ts b/apps/x/apps/main/src/composio-handler.ts index f72613ad..e5b25d1a 100644 --- a/apps/x/apps/main/src/composio-handler.ts +++ b/apps/x/apps/main/src/composio-handler.ts @@ -3,7 +3,6 @@ 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'; -import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; const REDIRECT_URI = 'http://localhost:8081/oauth/callback'; @@ -152,9 +151,6 @@ export async function initiateConnection(toolkitSlug: string): Promise<{ if (accountStatus.status === 'ACTIVE') { emitComposioEvent({ toolkitSlug, success: true }); - if (toolkitSlug === 'gmail') { - triggerGmailSync(); - } } else { emitComposioEvent({ toolkitSlug, diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index c276301b..6ddab7bc 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { dirname } from "node:path"; import { updateElectronApp, UpdateSourceType } from "update-electron-app"; import { init as initGmailSync } from "@x/core/dist/knowledge/sync_gmail.js"; - +import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.js"; import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js"; import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js"; import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js"; @@ -134,6 +134,9 @@ app.whenReady().then(async () => { // start gmail sync initGmailSync(); + // start calendar sync + initCalendarSync(); + // start fireflies sync initFirefliesSync(); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 58ab0809..5b55e8b7 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -7,6 +7,7 @@ import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/prov 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'; +import { triggerSync as triggerGmailSync } from '@x/core/dist/knowledge/sync_gmail.js'; import { triggerSync as triggerCalendarSync } from '@x/core/dist/knowledge/sync_calendar.js'; import { triggerSync as triggerFirefliesSync } from '@x/core/dist/knowledge/sync_fireflies.js'; import { emitOAuthEvent } from './ipc.js'; @@ -193,6 +194,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool // Trigger immediate sync for relevant providers if (provider === 'google') { + triggerGmailSync(); triggerCalendarSync(); } else if (provider === 'fireflies-ai') { triggerFirefliesSync(); diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 882a8d48..1799ab75 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -41,12 +41,8 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio state (Gmail + Slack) + // Composio/Slack state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail') - const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) - const [gmailConnecting, setGmailConnecting] = useState(false) const [slackConnected, setSlackConnected] = useState(false) const [slackLoading, setSlackLoading] = useState(true) const [slackConnecting, setSlackConnecting] = useState(false) @@ -97,20 +93,6 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) - // Load Gmail connection status - const refreshGmailStatus = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - setGmailConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Gmail status:', error) - setGmailConnected(false) - } finally { - setGmailLoading(false) - } - }, []) - // Load Slack connection status const refreshSlackStatus = useCallback(async () => { try { @@ -125,53 +107,6 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) } }, []) - // Connect to Gmail via Composio - const startGmailConnect = useCallback(async () => { - try { - setGmailConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Gmail') - setGmailConnecting(false) - } - // Success will be handled by composio:didConnect event - } catch (error) { - console.error('Failed to connect to Gmail:', error) - toast.error('Failed to connect to Gmail') - setGmailConnecting(false) - } - }, []) - - // Handle Gmail connect button click - const handleConnectGmail = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGmailConnect() - }, [startGmailConnect]) - - // Disconnect from Gmail - const handleDisconnectGmail = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'gmail' }) - if (result.success) { - setGmailConnected(false) - toast.success('Disconnected from Gmail') - } else { - toast.error('Failed to disconnect from Gmail') - } - } catch (error) { - console.error('Failed to disconnect from Gmail:', error) - toast.error('Failed to disconnect from Gmail') - } finally { - setGmailLoading(false) - } - }, []) - // Connect to Slack via Composio const startSlackConnect = useCallback(async () => { try { @@ -191,9 +126,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) // 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) { - setComposioApiKeyTarget('slack') setComposioApiKeyOpen(true) return } @@ -206,17 +141,13 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) await window.ipc.invoke('composio:set-api-key', { apiKey }) setComposioApiKeyOpen(false) toast.success('Composio API key saved') - // Start the connection for whichever toolkit triggered the API key prompt - if (composioApiKeyTarget === 'gmail') { - await startGmailConnect() - } else { - await startSlackConnect() - } + // 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') } - }, [composioApiKeyTarget, startGmailConnect, startSlackConnect]) + }, [startSlackConnect]) // Disconnect from Slack const handleDisconnectSlack = useCallback(async () => { @@ -242,8 +173,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) // Refresh Granola refreshGranolaConfig() - // Refresh Composio connections - refreshGmailStatus() + // Refresh Slack status refreshSlackStatus() // Refresh OAuth providers @@ -272,7 +202,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when popover opens or providers list changes useEffect(() => { @@ -297,7 +227,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) if (success) { const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1) - // Show detailed message for providers that sync in background + // Show detailed message for Google and Fireflies (includes sync info) if (provider === 'google' || provider === 'fireflies-ai') { toast.success(`Connected to ${displayName}`, { description: 'Syncing your data in the background. This may take a few minutes before changes appear.', @@ -321,19 +251,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event - if (toolkitSlug === 'gmail') { - setGmailConnected(success) - setGmailConnecting(false) - - if (success) { - toast.success('Connected to Gmail', { - description: 'Syncing your emails in the background. This may take a few minutes.', - duration: 8000, - }) - } else { - toast.error(error || 'Failed to connect to Gmail') - } - } else if (toolkitSlug === 'slack') { + if (toolkitSlug === 'slack') { setSlackConnected(success) setSlackConnecting(false) @@ -513,55 +431,16 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
) : ( <> - {/* Email Section - Gmail via Composio */} -
- Email -
-
-
-
- + {/* Email & Calendar Section - Google */} + {providers.includes('google') && ( + <> +
+ Email & Calendar
-
- Gmail - {gmailLoading ? ( - Checking... - ) : ( - Sync emails - )} -
-
-
- {gmailLoading ? ( - - ) : gmailConnected ? ( - - ) : ( - - )} -
-
- - + {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar')} + + + )} {/* Meeting Notes Section - Granola & Fireflies */}
@@ -658,7 +537,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) open={composioApiKeyOpen} onOpenChange={setComposioApiKeyOpen} onSubmit={handleComposioApiKeySubmit} - isSubmitting={gmailConnecting || slackConnecting} + isSubmitting={slackConnecting} /> ) diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 0675697c..074ad645 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -42,12 +42,8 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [granolaEnabled, setGranolaEnabled] = useState(false) const [granolaLoading, setGranolaLoading] = useState(true) - // Composio state (Gmail + Slack) + // Composio/Slack state const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false) - const [composioApiKeyTarget, setComposioApiKeyTarget] = useState<'gmail' | 'slack'>('gmail') - const [gmailConnected, setGmailConnected] = useState(false) - const [gmailLoading, setGmailLoading] = useState(true) - const [gmailConnecting, setGmailConnecting] = useState(false) const [slackConnected, setSlackConnected] = useState(false) const [slackLoading, setSlackLoading] = useState(true) const [slackConnecting, setSlackConnecting] = useState(false) @@ -105,47 +101,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) - // Load Gmail connection status - const refreshGmailStatus = useCallback(async () => { - try { - setGmailLoading(true) - const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'gmail' }) - setGmailConnected(result.isConnected) - } catch (error) { - console.error('Failed to load Gmail status:', error) - setGmailConnected(false) - } finally { - setGmailLoading(false) - } - }, []) - - // Connect to Gmail via Composio - const startGmailConnect = useCallback(async () => { - try { - setGmailConnecting(true) - const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'gmail' }) - if (!result.success) { - toast.error(result.error || 'Failed to connect to Gmail') - setGmailConnecting(false) - } - } catch (error) { - console.error('Failed to connect to Gmail:', error) - toast.error('Failed to connect to Gmail') - setGmailConnecting(false) - } - }, []) - - // Handle Gmail connect button click - const handleConnectGmail = useCallback(async () => { - const configResult = await window.ipc.invoke('composio:is-configured', null) - if (!configResult.configured) { - setComposioApiKeyTarget('gmail') - setComposioApiKeyOpen(true) - return - } - await startGmailConnect() - }, [startGmailConnect]) - // Load Slack connection status const refreshSlackStatus = useCallback(async () => { try { @@ -179,9 +134,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // 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) { - setComposioApiKeyTarget('slack') setComposioApiKeyOpen(true) return } @@ -194,24 +149,20 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { await window.ipc.invoke('composio:set-api-key', { apiKey }) setComposioApiKeyOpen(false) toast.success('Composio API key saved') - if (composioApiKeyTarget === 'gmail') { - await startGmailConnect() - } else { - await startSlackConnect() - } + // 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') } - }, [composioApiKeyTarget, startGmailConnect, startSlackConnect]) + }, [startSlackConnect]) // Check connection status for all providers const refreshAllStatuses = useCallback(async () => { // Refresh Granola refreshGranolaConfig() - // Refresh Composio connections - refreshGmailStatus() + // Refresh Slack status refreshSlackStatus() // Refresh OAuth providers @@ -240,7 +191,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { ) setProviderStates(newStates) - }, [providers, refreshGranolaConfig, refreshGmailStatus, refreshSlackStatus]) + }, [providers, refreshGranolaConfig, refreshSlackStatus]) // Refresh statuses when modal opens or providers list changes useEffect(() => { @@ -279,16 +230,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const cleanup = window.ipc.on('composio:didConnect', (event) => { const { toolkitSlug, success, error } = event - if (toolkitSlug === 'gmail') { - setGmailConnected(success) - setGmailConnecting(false) - - if (success) { - toast.success('Connected to Gmail') - } else { - toast.error(error || 'Failed to connect to Gmail') - } - } else if (toolkitSlug === 'slack') { + if (toolkitSlug === 'slack') { setSlackConnected(success) setSlackConnecting(false) @@ -435,48 +377,6 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Render Gmail row (Composio) - const renderGmailRow = () => ( -
-
-
- -
-
- Gmail - {gmailLoading ? ( - Checking... - ) : ( - Sync emails - )} -
-
-
- {gmailLoading ? ( - - ) : gmailConnected ? ( -
- - Connected -
- ) : ( - - )} -
-
- ) - // Render Slack row const renderSlackRow = () => (
@@ -570,13 +470,15 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) : ( <> - {/* Email Section - Gmail via Composio */} -
-
- Email + {/* Email & Calendar Section */} + {providers.includes('google') && ( +
+
+ Email & Calendar +
+ {renderOAuthProvider('google', 'Google', , 'Sync emails and calendar events')}
- {renderGmailRow()} -
+ )} {/* Meeting Notes Section */}
@@ -611,7 +513,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { // Step 2: Completion const CompletionStep = () => { - const hasConnections = connectedProviders.length > 0 || gmailConnected || granolaEnabled || slackConnected + const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected return (
@@ -634,10 +536,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {

Connected accounts:

- {gmailConnected && ( + {connectedProviders.includes('google') && (
- Gmail (Email) + Google (Email & Calendar)
)} {connectedProviders.includes('fireflies-ai') && ( @@ -676,7 +578,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { open={composioApiKeyOpen} onOpenChange={setComposioApiKeyOpen} onSubmit={handleComposioApiKeySubmit} - isSubmitting={gmailConnecting || slackConnecting} + isSubmitting={slackConnecting} /> {}}> |]/g, "").substring(0, 100).trim(); } -// --- State Management --- - -interface SyncState { - last_sync: string; // ISO string — human-readable, source of truth +function decodeBase64(data: string): string { + return Buffer.from(data, 'base64').toString('utf-8'); } -function loadState(stateFile: string): SyncState | null { - if (fs.existsSync(stateFile)) { - try { - const data = JSON.parse(fs.readFileSync(stateFile, 'utf-8')); - if (data.last_sync) { - return { last_sync: data.last_sync }; +function getBody(payload: gmail.Schema$MessagePart): string { + let body = ""; + if (payload.parts) { + for (const part of payload.parts) { + if (part.mimeType === 'text/plain' && part.body && part.body.data) { + const text = decodeBase64(part.body.data); + // Strip quoted lines + const cleanLines = text.split('\n').filter((line: string) => !line.trim().startsWith('>')); + body += cleanLines.join('\n'); + } else if (part.mimeType === 'text/html' && part.body && part.body.data) { + const html = decodeBase64(part.body.data); + const md = nhm.translate(html); + // Simple quote stripping for MD + const cleanLines = md.split('\n').filter((line: string) => !line.trim().startsWith('>')); + body += cleanLines.join('\n'); + } else if (part.parts) { + body += getBody(part); } - } catch (e) { - console.error('[Gmail] Failed to load state:', e); } + } else if (payload.body && payload.body.data) { + const data = decodeBase64(payload.body.data); + if (payload.mimeType === 'text/html') { + const md = nhm.translate(data); + body += md.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); + } else { + body += data.split('\n').filter((line: string) => !line.trim().startsWith('>')).join('\n'); + } + } + return body; +} + +async function saveAttachment(gmail: gmail.Gmail, userId: string, msgId: string, part: gmail.Schema$MessagePart, attachmentsDir: string): Promise { + const filename = part.filename; + const attId = part.body?.attachmentId; + if (!filename || !attId) return null; + + const safeName = `${msgId}_${cleanFilename(filename)}`; + const filePath = path.join(attachmentsDir, safeName); + + if (fs.existsSync(filePath)) return safeName; + + try { + const res = await gmail.users.messages.attachments.get({ + userId, + messageId: msgId, + id: attId + }); + + const data = res.data.data; + if (data) { + fs.writeFileSync(filePath, Buffer.from(data, 'base64')); + console.log(`Saved attachment: ${safeName}`); + return safeName; + } + } catch (e) { + console.error(`Error saving attachment ${filename}:`, e); } return null; } -function saveState(stateFile: string, lastSync: string): void { - const state: SyncState = { - last_sync: lastSync, - }; - fs.writeFileSync(stateFile, JSON.stringify(state, null, 2)); -} - -/** - * Try to parse a date string into a Date. Returns null if unparseable. - */ -function tryParseDate(dateStr: string): Date | null { - const d = new Date(dateStr); - return isNaN(d.getTime()) ? null : d; -} - -function toEpochSeconds(isoString: string): number { - return Math.floor(new Date(isoString).getTime() / 1000); -} - -// --- Message Parsing --- - -interface ParsedMessage { - from: string; - date: string; - subject: string; - body: string; -} - -function parseMessageData(messageData: Record): ParsedMessage { - const headers = messageData.payload && typeof messageData.payload === 'object' - ? (messageData.payload as Record).headers as Array<{ name: string; value: string }> | undefined - : undefined; - - const from = headers?.find(h => h.name === 'From')?.value || String(messageData.from || messageData.sender || 'Unknown'); - const date = headers?.find(h => h.name === 'Date')?.value || String(messageData.date || messageData.internalDate || 'Unknown'); - const subject = headers?.find(h => h.name === 'Subject')?.value || String(messageData.subject || '(No Subject)'); - - let body = ''; - - // Try to extract body from payload structure (Gmail API format) - if (messageData.payload && typeof messageData.payload === 'object') { - body = extractBodyFromPayload(messageData.payload as Record); - } - - // Fallback: try snippet or body fields - if (!body) { - if (typeof messageData.body === 'string') { - body = messageData.body; - } else if (typeof messageData.snippet === 'string') { - body = messageData.snippet; - } else if (typeof messageData.text === 'string') { - body = messageData.text; - } - } - - // Convert HTML to markdown if body looks like HTML - if (body && (body.includes(' !line.trim().startsWith('>')).join('\n'); - } - - return { from, date, subject, body }; -} - -function extractBodyFromPayload(payload: Record): string { - const parts = payload.parts as Array> | undefined; - - if (parts) { - for (const part of parts) { - const mimeType = part.mimeType as string | undefined; - const bodyData = part.body && typeof part.body === 'object' - ? (part.body as Record).data as string | undefined - : undefined; - - if ((mimeType === 'text/plain' || mimeType === 'text/html') && bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - // Recurse into nested parts - if (part.parts) { - const result = extractBodyFromPayload(part as Record); - if (result) return result; - } - } - } - - // Single-part message - const bodyData = payload.body && typeof payload.body === 'object' - ? (payload.body as Record).data as string | undefined - : undefined; - - if (bodyData) { - const decoded = Buffer.from(bodyData, 'base64').toString('utf-8'); - const mimeType = payload.mimeType as string | undefined; - if (mimeType === 'text/html') { - return nhm.translate(decoded); - } - return decoded; - } - - return ''; -} - // --- Sync Logic --- -/** - * Process a thread and write its .md file. - * Returns the newest message date (as ISO string) found in the thread, or null. - */ -async function processThread(connectedAccountId: string, threadId: string, syncDir: string): Promise { - let threadResult; +async function processThread(auth: OAuth2Client, threadId: string, syncDir: string, attachmentsDir: string) { + const gmail = google.gmail({ version: 'v1', auth }); try { - threadResult = await executeAction( - 'GMAIL_FETCH_MESSAGE_BY_THREAD_ID', - connectedAccountId, - { thread_id: threadId, user_id: 'me' } - ); - } catch (error) { - console.warn(`[Gmail] Skipping thread ${threadId} (fetch failed):`, error instanceof Error ? error.message : error); - return null; - } + const res = await gmail.users.threads.get({ userId: 'me', id: threadId }); + const thread = res.data; + const messages = thread.messages; - if (!threadResult.success || !threadResult.data) { - console.error(`[Gmail] Failed to fetch thread ${threadId}:`, threadResult.error); - return null; - } + if (!messages || messages.length === 0) return; - const data = threadResult.data as Record; - const messages = data.messages as Array> | undefined; + // Subject from first message + const firstHeader = messages[0].payload?.headers; + const subject = firstHeader?.find(h => h.name === 'Subject')?.value || '(No Subject)'; - let newestDate: Date | null = null; - - if (!messages || messages.length === 0) { - // Single message response - const parsed = parseMessageData(data); - const mdContent = `# ${parsed.subject}\n\n` + - `**Thread ID:** ${threadId}\n` + - `**Message Count:** 1\n\n---\n\n` + - `### From: ${parsed.from}\n` + - `**Date:** ${parsed.date}\n\n` + - `${parsed.body}\n\n---\n\n`; - - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${parsed.subject} (${threadId})`); - newestDate = tryParseDate(parsed.date); - } else { - // Multi-message thread - const firstParsed = parseMessageData(messages[0]); - let mdContent = `# ${firstParsed.subject}\n\n`; + let mdContent = `# ${subject}\n\n`; mdContent += `**Thread ID:** ${threadId}\n`; mdContent += `**Message Count:** ${messages.length}\n\n---\n\n`; for (const msg of messages) { - const parsed = parseMessageData(msg); - mdContent += `### From: ${parsed.from}\n`; - mdContent += `**Date:** ${parsed.date}\n\n`; - mdContent += `${parsed.body}\n\n`; - mdContent += `---\n\n`; + const msgId = msg.id!; + const headers = msg.payload?.headers || []; + const from = headers.find(h => h.name === 'From')?.value || 'Unknown'; + const date = headers.find(h => h.name === 'Date')?.value || 'Unknown'; - const msgDate = tryParseDate(parsed.date); - if (msgDate && (!newestDate || msgDate > newestDate)) { - newestDate = msgDate; + mdContent += `### From: ${from}\n`; + mdContent += `**Date:** ${date}\n\n`; + + if (msg.payload) { + const body = getBody(msg.payload); + mdContent += `${body}\n\n`; + } + + // Attachments + const parts: gmail.Schema$MessagePart[] = []; + const traverseParts = (pList: gmail.Schema$MessagePart[]) => { + for (const p of pList) { + parts.push(p); + if (p.parts) traverseParts(p.parts); + } + }; + if (msg.payload?.parts) traverseParts(msg.payload.parts); + + let attachmentsFound = false; + for (const part of parts) { + if (part.filename && part.body?.attachmentId) { + const savedName = await saveAttachment(gmail, 'me', msgId, part, attachmentsDir); + if (savedName) { + if (!attachmentsFound) { + mdContent += "**Attachments:**\n"; + attachmentsFound = true; + } + mdContent += `- [${part.filename}](attachments/${savedName})\n`; + } + } + } + mdContent += "\n---\n\n"; + } + + fs.writeFileSync(path.join(syncDir, `${threadId}.md`), mdContent); + console.log(`Synced Thread: ${subject} (${threadId})`); + + } catch (error) { + console.error(`Error processing thread ${threadId}:`, error); + } +} + +function loadState(stateFile: string): { historyId?: string } { + if (fs.existsSync(stateFile)) { + return JSON.parse(fs.readFileSync(stateFile, 'utf-8')); + } + return {}; +} + +function saveState(historyId: string, stateFile: string) { + fs.writeFileSync(stateFile, JSON.stringify({ + historyId, + last_sync: new Date().toISOString() + }, null, 2)); +} + +async function fullSync(auth: OAuth2Client, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { + console.log(`Performing full sync of last ${lookbackDays} days...`); + const gmail = google.gmail({ version: 'v1', auth }); + + const pastDate = new Date(); + pastDate.setDate(pastDate.getDate() - lookbackDays); + const dateQuery = pastDate.toISOString().split('T')[0].replace(/-/g, '/'); + + // Get History ID + const profile = await gmail.users.getProfile({ userId: 'me' }); + const currentHistoryId = profile.data.historyId!; + + let pageToken: string | undefined; + do { + const res = await gmail.users.threads.list({ + userId: 'me', + q: `after:${dateQuery}`, + pageToken + }); + + const threads = res.data.threads; + if (threads) { + for (const thread of threads) { + await processThread(auth, thread.id!, syncDir, attachmentsDir); + } + } + pageToken = res.data.nextPageToken ?? undefined; + } while (pageToken); + + saveState(currentHistoryId, stateFile); + console.log("Full sync complete."); +} + +async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir: string, attachmentsDir: string, stateFile: string, lookbackDays: number) { + console.log(`Checking updates since historyId ${startHistoryId}...`); + const gmail = google.gmail({ version: 'v1', auth }); + + try { + const res = await gmail.users.history.list({ + userId: 'me', + startHistoryId, + historyTypes: ['messageAdded'] + }); + + const changes = res.data.history; + if (!changes || changes.length === 0) { + console.log("No new changes."); + const profile = await gmail.users.getProfile({ userId: 'me' }); + saveState(profile.data.historyId!, stateFile); + return; + } + + console.log(`Found ${changes.length} history records.`); + const threadIds = new Set(); + + for (const record of changes) { + if (record.messagesAdded) { + for (const item of record.messagesAdded) { + if (item.message?.threadId) { + threadIds.add(item.message.threadId); + } + } } } - fs.writeFileSync(path.join(syncDir, `${cleanFilename(threadId)}.md`), mdContent); - console.log(`[Gmail] Synced Thread: ${firstParsed.subject} (${threadId})`); - } + for (const tid of threadIds) { + await processThread(auth, tid, syncDir, attachmentsDir); + } - if (!newestDate) return null; - // Add 1 second so the `after:` query (epoch-second granularity) excludes this email next sync - return new Date(newestDate.getTime() + 1000).toISOString(); + const profile = await gmail.users.getProfile({ userId: 'me' }); + saveState(profile.data.historyId!, stateFile); + + } catch (error: unknown) { + const e = error as { response?: { status?: number } }; + if (e.response?.status === 404) { + console.log("History ID expired. Falling back to full sync."); + await fullSync(auth, syncDir, attachmentsDir, stateFile, lookbackDays); + } else { + console.error("Error during partial sync:", error); + // If 401, clear tokens to force re-auth next run + if (e.response?.status === 401) { + console.log("401 Unauthorized, clearing cache"); + GoogleClientFactory.clearCache(); + } + } + } } async function performSync() { + const LOOKBACK_DAYS = 30; // Default to 1 month const ATTACHMENTS_DIR = path.join(SYNC_DIR, 'attachments'); const STATE_FILE = path.join(SYNC_DIR, 'sync_state.json'); @@ -252,127 +285,51 @@ async function performSync() { if (!fs.existsSync(SYNC_DIR)) fs.mkdirSync(SYNC_DIR, { recursive: true }); if (!fs.existsSync(ATTACHMENTS_DIR)) fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true }); - const account = composioAccountsRepo.getAccount('gmail'); - if (!account || account.status !== 'ACTIVE') { - console.log('[Gmail] Gmail not connected via Composio. Skipping sync.'); - return; - } - - const connectedAccountId = account.id; - - // Determine query timestamp - const state = loadState(STATE_FILE); - let afterEpochSeconds: number; - - if (state) { - afterEpochSeconds = toEpochSeconds(state.last_sync); - console.log(`[Gmail] Syncing messages since ${state.last_sync}...`); - } else { - const pastDate = new Date(); - pastDate.setDate(pastDate.getDate() - LOOKBACK_DAYS); - afterEpochSeconds = Math.floor(pastDate.getTime() / 1000); - console.log(`[Gmail] First sync - fetching last ${LOOKBACK_DAYS} days...`); - } - try { - // List threads since last sync (lightweight - returns IDs only) - const allThreadIds: string[] = []; - let pageToken: string | undefined; - - do { - const params: Record = { - query: `after:${afterEpochSeconds}`, - max_results: 20, - user_id: 'me', - }; - if (pageToken) { - params.page_token = pageToken; - } - - const result = await executeAction( - 'GMAIL_LIST_THREADS', - connectedAccountId, - params - ); - - if (!result.success || !result.data) { - console.error('[Gmail] Failed to list threads:', result.error); - return; - } - - const data = result.data as Record; - const threads = data.threads as Array> | undefined; - - if (threads && threads.length > 0) { - for (const thread of threads) { - const threadId = thread.id as string | undefined; - if (threadId) { - allThreadIds.push(threadId); - } - } - } - - pageToken = data.nextPageToken as string | undefined; - } while (pageToken); - - if (allThreadIds.length === 0) { - console.log('[Gmail] No new threads.'); + const auth = await GoogleClientFactory.getClient(); + if (!auth) { + console.log("No valid OAuth credentials available."); return; } - console.log(`[Gmail] Found ${allThreadIds.length} threads to sync.`); + console.log("Authorization successful. Starting sync..."); - // Reverse so we process oldest first. Gmail returns newest first, - // so processing in reverse lets the high-water mark advance - // chronologically — safe to save state after each thread. - allThreadIds.reverse(); - - // Process each thread, saving state after each one with the - // newest email date seen so far (high-water mark). - let highWaterMark: string | null = state?.last_sync ?? null; - let processedCount = 0; - for (const threadId of allThreadIds) { - try { - const newestInThread = await processThread(connectedAccountId, threadId, SYNC_DIR); - processedCount++; - - // Advance high-water mark if this thread has a newer email - if (newestInThread) { - if (!highWaterMark || new Date(newestInThread) > new Date(highWaterMark)) { - highWaterMark = newestInThread; - } - saveState(STATE_FILE, highWaterMark); - } - } catch (error) { - console.error(`[Gmail] Error processing thread ${threadId}, skipping:`, error); - } + const state = loadState(STATE_FILE); + if (!state.historyId) { + console.log("No history ID found, starting full sync..."); + await fullSync(auth, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); + } else { + console.log("History ID found, starting partial sync..."); + await partialSync(auth, state.historyId, SYNC_DIR, ATTACHMENTS_DIR, STATE_FILE, LOOKBACK_DAYS); } - console.log(`[Gmail] Sync completed. Processed ${processedCount}/${allThreadIds.length} threads.`); - + console.log("Sync completed."); } catch (error) { - console.error('[Gmail] Error during sync:', error); + console.error("Error during sync:", error); } } export async function init() { - console.log('[Gmail] Starting Gmail Sync (Composio)...'); - console.log(`[Gmail] Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); + console.log("Starting Gmail Sync (TS)..."); + console.log(`Will sync every ${SYNC_INTERVAL_MS / 1000} seconds.`); while (true) { try { - const isConnected = composioAccountsRepo.isConnected('gmail'); - - if (!isConnected) { - console.log('[Gmail] Gmail not connected via Composio. Sleeping...'); + // Check if credentials are available with required scopes + const hasCredentials = await GoogleClientFactory.hasValidCredentials(REQUIRED_SCOPE); + + if (!hasCredentials) { + console.log("Google OAuth credentials not available or missing required Gmail scope. Sleeping..."); } else { + // Perform one sync await performSync(); } } catch (error) { - console.error('[Gmail] Error in main loop:', error); + console.error("Error in main loop:", error); } - console.log(`[Gmail] Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); + // Sleep for N minutes before next check (can be interrupted by triggerSync) + console.log(`Sleeping for ${SYNC_INTERVAL_MS / 1000} seconds...`); await interruptibleSleep(SYNC_INTERVAL_MS); } } From 3cfbfa7c34ec1c2ae4b80198a77f605c1a6eda50 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:01:23 +0530 Subject: [PATCH 18/21] Reapply "ask for google client id" This reverts commit efa91f86276bfd8b9361ce8247bf5c2ee76c92e5. --- apps/x/apps/main/src/ipc.ts | 2 +- apps/x/apps/main/src/oauth-handler.ts | 43 +++++++++- .../src/components/connectors-popover.tsx | 38 ++++++++- .../src/components/google-client-id-modal.tsx | 85 +++++++++++++++++++ .../src/components/onboarding-modal.tsx | 35 +++++++- apps/x/apps/renderer/src/hooks/useOAuth.ts | 4 +- .../src/lib/google-client-id-store.ts | 17 ++++ .../core/src/auth/provider-client-id.ts | 23 +++++ apps/x/packages/core/src/auth/providers.ts | 3 +- .../src/knowledge/fireflies-client-factory.ts | 12 ++- .../src/knowledge/google-client-factory.ts | 51 +++++++---- apps/x/packages/shared/src/ipc.ts | 1 + 12 files changed, 281 insertions(+), 33 deletions(-) create mode 100644 apps/x/apps/renderer/src/components/google-client-id-modal.tsx create mode 100644 apps/x/apps/renderer/src/lib/google-client-id-store.ts create mode 100644 apps/x/packages/core/src/auth/provider-client-id.ts diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 53b2f913..87997713 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -306,7 +306,7 @@ export function setupIpcHandlers() { return runsCore.listRuns(args.cursor); }, '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); diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 5b55e8b7..3586aaac 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -4,6 +4,12 @@ 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'; @@ -76,14 +82,25 @@ function getClientRegistrationRepo(): IClientRegistrationRepo { */ async function getProviderConfiguration(provider: string): Promise { 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 @@ -120,10 +137,11 @@ async function getProviderConfiguration(provider: string): Promise { +export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> { try { console.log(`[OAuth] Starting connection flow for ${provider}...`); @@ -142,6 +160,14 @@ export async function connectProvider(provider: string): Promise<{ success: bool 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); @@ -256,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); @@ -269,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) { @@ -325,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: [] }; diff --git a/apps/x/apps/renderer/src/components/connectors-popover.tsx b/apps/x/apps/renderer/src/components/connectors-popover.tsx index 1799ab75..7033a2ac 100644 --- a/apps/x/apps/renderer/src/components/connectors-popover.tsx +++ b/apps/x/apps/renderer/src/components/connectors-popover.tsx @@ -18,6 +18,8 @@ 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 { @@ -36,6 +38,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -266,15 +269,14 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return cleanup }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + 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 @@ -297,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 => ({ @@ -308,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 => ({ @@ -395,6 +421,12 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps) return ( <> + {tooltip ? ( diff --git a/apps/x/apps/renderer/src/components/google-client-id-modal.tsx b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx new file mode 100644 index 00000000..5727d506 --- /dev/null +++ b/apps/x/apps/renderer/src/components/google-client-id-modal.tsx @@ -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 ( + + + + Enter Google Client ID + + This app does not store the client ID. You will be prompted each session. + + +
+ + setClientId(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault() + handleSubmit() + } + }} + autoFocus + /> +
+
+ + +
+
+
+ ) +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index 074ad645..a1621d84 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" 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 { @@ -37,6 +39,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const [providers, setProviders] = useState([]) const [providersLoading, setProvidersLoading] = useState(true) const [providerStates, setProviderStates] = useState>({}) + const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false) // Granola state const [granolaEnabled, setGranolaEnabled] = useState(false) @@ -245,15 +248,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return cleanup }, []) - // Connect to a provider - const handleConnect = useCallback(async (provider: string) => { + 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}`) @@ -272,6 +274,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, []) + // Connect to a provider + const handleConnect = useCallback(async (provider: string) => { + if (provider === 'google') { + const existingClientId = getGoogleClientId() + if (!existingClientId) { + setGoogleClientIdOpen(true) + return + } + await startConnect(provider, existingClientId) + return + } + + await startConnect(provider) + }, [startConnect]) + + const handleGoogleClientIdSubmit = useCallback((clientId: string) => { + setGoogleClientId(clientId) + setGoogleClientIdOpen(false) + startConnect('google', clientId) + }, [startConnect]) + const handleNext = () => { if (currentStep < 2) { setCurrentStep((prev) => (prev + 1) as Step) @@ -574,6 +597,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { return ( <> + { + 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 diff --git a/apps/x/apps/renderer/src/lib/google-client-id-store.ts b/apps/x/apps/renderer/src/lib/google-client-id-store.ts new file mode 100644 index 00000000..78898325 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/google-client-id-store.ts @@ -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; +} diff --git a/apps/x/packages/core/src/auth/provider-client-id.ts b/apps/x/packages/core/src/auth/provider-client-id.ts new file mode 100644 index 00000000..6f8f6a90 --- /dev/null +++ b/apps/x/packages/core/src/auth/provider-client-id.ts @@ -0,0 +1,23 @@ +type ProviderClientIdOverrides = Map; + +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); +} diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index edda5447..b9acce11 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -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', diff --git a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts index a56d39b5..d8c975c7 100644 --- a/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/fireflies-client-factory.ts @@ -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 ); } diff --git a/apps/x/packages/core/src/knowledge/google-client-factory.ts b/apps/x/packages/core/src/knowledge/google-client-factory.ts index f32f492c..9a1240fb 100644 --- a/apps/x/packages/core/src/knowledge/google-client-factory.ts +++ b/apps/x/packages/core/src/knowledge/google-client-factory.ts @@ -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 { - 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( diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 79746516..aca2ce17 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -176,6 +176,7 @@ const ipcSchemas = { 'oauth:connect': { req: z.object({ provider: z.string(), + clientId: z.string().optional(), }), res: z.object({ success: z.boolean(), From 26c3e495d1b1480161104600542c6ee9aa387a0b Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:14:29 +0530 Subject: [PATCH 19/21] Revert "dont download calendar attachments" This reverts commit 4a47006aa916bdf54dc443b08e314d20b2b816da. --- apps/x/packages/core/src/auth/providers.ts | 1 + apps/x/packages/core/src/knowledge/sync_calendar.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/x/packages/core/src/auth/providers.ts b/apps/x/packages/core/src/auth/providers.ts index b9acce11..02f78fc5 100644 --- a/apps/x/packages/core/src/auth/providers.ts +++ b/apps/x/packages/core/src/auth/providers.ts @@ -62,6 +62,7 @@ const providerConfigs: ProviderConfig = { scopes: [ 'https://www.googleapis.com/auth/gmail.readonly', 'https://www.googleapis.com/auth/calendar.events.readonly', + 'https://www.googleapis.com/auth/drive.readonly', ], }, 'fireflies-ai': { diff --git a/apps/x/packages/core/src/knowledge/sync_calendar.ts b/apps/x/packages/core/src/knowledge/sync_calendar.ts index 426de091..46ec2e1e 100644 --- a/apps/x/packages/core/src/knowledge/sync_calendar.ts +++ b/apps/x/packages/core/src/knowledge/sync_calendar.ts @@ -12,7 +12,7 @@ const SYNC_INTERVAL_MS = 5 * 60 * 1000; // Check every 5 minutes const LOOKBACK_DAYS = 14; const REQUIRED_SCOPES = [ 'https://www.googleapis.com/auth/calendar.events.readonly', - // 'https://www.googleapis.com/auth/drive.readonly' + 'https://www.googleapis.com/auth/drive.readonly' ]; const nhm = new NodeHtmlMarkdown(); @@ -186,7 +186,7 @@ async function syncCalendarWindow(auth: OAuth2Client, syncDir: string, lookbackD for (const event of events) { if (event.id) { await saveEvent(event, syncDir); - // await processAttachments(drive, event, syncDir); + await processAttachments(drive, event, syncDir); currentEventIds.add(event.id); } } From 948c6e717619913b8b0f50aa2c49f1f87c23dbd9 Mon Sep 17 00:00:00 2001 From: Arjun <6592213+arkml@users.noreply.github.com> Date: Tue, 3 Feb 2026 23:29:21 +0530 Subject: [PATCH 20/21] make exa mcp available by default --- apps/x/packages/core/src/mcp/repo.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/x/packages/core/src/mcp/repo.ts b/apps/x/packages/core/src/mcp/repo.ts index 841f162b..66162895 100644 --- a/apps/x/packages/core/src/mcp/repo.ts +++ b/apps/x/packages/core/src/mcp/repo.ts @@ -4,6 +4,13 @@ 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; getConfig(): Promise>; @@ -18,7 +25,7 @@ export class FSMcpConfigRepo implements IMcpConfigRepo { 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)); } } From 10f94ce67e2a1b01771cb5577ee741d2875065b2 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Wed, 4 Feb 2026 01:12:06 +0530 Subject: [PATCH 21/21] feat: simplify LLM config and onboarding --- CLAUDE.md | 7 +- apps/x/apps/main/src/ipc.ts | 16 +- .../src/components/onboarding-modal.tsx | 333 +++++++++++++++++- apps/x/packages/core/src/agents/runtime.ts | 6 +- apps/x/packages/core/src/models/models-dev.ts | 174 +++++++++ apps/x/packages/core/src/models/models.ts | 158 ++++----- apps/x/packages/core/src/models/repo.ts | 42 +-- apps/x/packages/shared/src/index.ts | 3 +- apps/x/packages/shared/src/ipc.ts | 31 +- apps/x/packages/shared/src/models.ts | 13 + 10 files changed, 630 insertions(+), 153 deletions(-) create mode 100644 apps/x/packages/core/src/models/models-dev.ts create mode 100644 apps/x/packages/shared/src/models.ts diff --git a/CLAUDE.md b/CLAUDE.md index c59ed5b2..db51cb63 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,11 @@ pnpm uses symlinks for workspace packages. Electron Forge's dependency walker ca ## 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 @@ -133,7 +138,7 @@ cd apps/x && npm run deps && npm run lint | UI | React 19, Vite 7 | | Styling | TailwindCSS, Radix UI | | State | React hooks | -| AI | Vercel AI SDK, Anthropic/OpenAI/Google providers | +| 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 | diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index 87997713..5a7a7bd9 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -17,6 +17,9 @@ import fs from 'node:fs/promises'; import z from 'zod'; 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'; @@ -305,6 +308,17 @@ 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('modelConfigRepo'); + await repo.setConfig(args); + return { success: true }; + }, 'oauth:connect': async (_event, args) => { return await connectProvider(args.provider, args.clientId); }, @@ -371,4 +385,4 @@ export function setupIpcHandlers() { return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input); }, }); -} \ No newline at end of file +} diff --git a/apps/x/apps/renderer/src/components/onboarding-modal.tsx b/apps/x/apps/renderer/src/components/onboarding-modal.tsx index a1621d84..1f664f10 100644 --- a/apps/x/apps/renderer/src/components/onboarding-modal.tsx +++ b/apps/x/apps/renderer/src/components/onboarding-modal.tsx @@ -13,6 +13,14 @@ 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" @@ -30,11 +38,38 @@ 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(0) + // LLM setup state + const [llmProvider, setLlmProvider] = useState("openai") + const [modelsCatalog, setModelsCatalog] = useState>({}) + const [modelsLoading, setModelsLoading] = useState(false) + const [modelsError, setModelsError] = useState(null) + const [providerConfigs, setProviderConfigs] = useState>({ + 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([]) const [providersLoading, setProvidersLoading] = useState(true) @@ -51,6 +86,27 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { 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) @@ -75,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 = {} + 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 { @@ -160,6 +258,69 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { } }, [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 @@ -295,20 +456,10 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { startConnect('google', clientId) }, [startConnect]) - const handleNext = () => { - if (currentStep < 2) { - setCurrentStep((prev) => (prev + 1) as Step) - } - } - - const handleComplete = () => { - onComplete() - } - // Step indicator component const StepIndicator = () => (
- {[0, 1, 2].map((step) => ( + {[0, 1, 2, 3].map((step) => (
) - // 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 ( +
+ + Choose your model + + Select your provider and model to power Rowboat’s AI. + + + +
+
+ Provider +
+ {providerOptions.map((provider) => ( + + ))} +
+
+ +
+ Model + {modelsLoading ? ( +
+ + Loading models... +
+ ) : showModelInput ? ( + updateProviderConfig(llmProvider, { model: e.target.value })} + placeholder="Enter model ID" + /> + ) : ( + + )} + {modelsError && ( +
{modelsError}
+ )} +
+ + {requiresApiKey && ( +
+ API Key + updateProviderConfig(llmProvider, { apiKey: e.target.value })} + placeholder="Paste your API key" + /> +
+ )} + + {showBaseURL && ( +
+ Base URL + 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" + } + /> +
+ )} +
+ +
+ + {testState.status === "success" && ( + Connected + )} + {testState.status === "error" && ( + + {testState.error || "Test failed"} + + )} +
+ +
+ +
+
+ ) + } + + // Step 2: Connect Accounts const AccountConnectionStep = () => (
@@ -534,7 +834,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
) - // Step 2: Completion + // Step 3: Completion const CompletionStep = () => { const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected @@ -618,8 +918,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { > {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } + {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && }
diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index 9a6b7dcb..0246cc2f 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -15,7 +15,7 @@ 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"; @@ -623,8 +623,8 @@ 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) { diff --git a/apps/x/packages/core/src/models/models-dev.ts b/apps/x/packages/core/src/models/models-dev.ts new file mode 100644 index 00000000..6fecb694 --- /dev/null +++ b/apps/x/packages/core/src/models/models-dev.ts @@ -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 { + try { + const raw = await fs.readFile(CACHE_PATH, "utf8"); + return JSON.parse(raw) as CacheFile; + } catch { + return null; + } +} + +async function writeCache(data: unknown): Promise { + const payload: CacheFile = { + fetchedAt: new Date().toISOString(), + data, + }; + await fs.writeFile(CACHE_PATH, JSON.stringify(payload, null, 2)); +} + +async function fetchModelsDev(): Promise { + 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; 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, + flavor: "openai" | "anthropic" | "google", +): z.infer | null { + if (data[flavor]) return data[flavor]; + let best: { score: number; provider: z.infer } | 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): boolean { + if (model.experimental) return false; + if (model.status && ["alpha", "beta", "deprecated"].includes(model.status)) return false; + return true; +} + +function supportsToolCall(model: z.infer): boolean { + return model.tool_call === true; +} + +function normalizeModels(models: Record>): 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 }; +} diff --git a/apps/x/packages/core/src/models/models.ts b/apps/x/packages/core/src/models/models.ts index d2d846e5..482931df 100644 --- a/apps/x/packages/core/src/models/models.ts +++ b/apps/x/packages/core/src/models/models.ts @@ -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 = {}; - -export async function getProvider(name: string = ""): Promise { - // get model conf - const repo = container.resolve("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): 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]; -} \ No newline at end of file +} + +export async function testModelConnection( + providerConfig: z.infer, + 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); + } +} diff --git a/apps/x/packages/core/src/models/repo.ts b/apps/x/packages/core/src/models/repo.ts index 33ad2502..f39aadf1 100644 --- a/apps/x/packages/core/src/models/repo.ts +++ b/apps/x/packages/core/src/models/repo.ts @@ -1,4 +1,4 @@ -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"; @@ -7,21 +7,14 @@ import z from "zod"; export interface IModelConfigRepo { ensureConfig(): Promise; getConfig(): Promise>; - upsert(providerName: string, config: z.infer): Promise; - delete(providerName: string): Promise; - setDefault(providerName: string, model: string): Promise; + setConfig(config: z.infer): Promise; } const defaultConfig: z.infer = { - providers: { - "rowboat": { - flavor: "rowboat [free]", - } + provider: { + flavor: "openai", }, - defaults: { - provider: "rowboat", - model: "gpt-5.1", - } + model: "gpt-4.1", }; export class FSModelConfigRepo implements IModelConfigRepo { @@ -40,28 +33,7 @@ export class FSModelConfigRepo implements IModelConfigRepo { return ModelConfig.parse(JSON.parse(config)); } - private async setConfig(config: z.infer): Promise { + async setConfig(config: z.infer): Promise { await fs.writeFile(this.configPath, JSON.stringify(config, null, 2)); } - - async upsert(providerName: string, config: z.infer): Promise { - const conf = await this.getConfig(); - conf.providers[providerName] = config; - await this.setConfig(conf); - } - - async delete(providerName: string): Promise { - const conf = await this.getConfig(); - delete conf.providers[providerName]; - await this.setConfig(conf); - } - - async setDefault(providerName: string, model: string): Promise { - const conf = await this.getConfig(); - conf.defaults = { - provider: providerName, - model, - }; - await this.setConfig(conf); - } -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/index.ts b/apps/x/packages/shared/src/index.ts index 878c1043..3bca8969 100644 --- a/apps/x/packages/shared/src/index.ts +++ b/apps/x/packages/shared/src/index.ts @@ -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 }; \ No newline at end of file +export { PrefixLogger }; diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index aca2ce17..2835a90b 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -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) @@ -173,6 +174,34 @@ 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(), @@ -373,4 +402,4 @@ export function validateResponse( ): IPCChannels[K]['res'] { const schema = ipcSchemas[channel].res; return schema.parse(data) as IPCChannels[K]['res']; -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/models.ts b/apps/x/packages/shared/src/models.ts new file mode 100644 index 00000000..14e91689 --- /dev/null +++ b/apps/x/packages/shared/src/models.ts @@ -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(), +});