mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
commit
fd730f6eb4
59 changed files with 5401 additions and 503 deletions
152
CLAUDE.md
Normal file
152
CLAUDE.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# CLAUDE.md - AI Coding Agent Context
|
||||
|
||||
This file provides context for AI coding agents working on the Rowboat monorepo.
|
||||
|
||||
## Quick Reference Commands
|
||||
|
||||
```bash
|
||||
# Electron App (apps/x)
|
||||
cd apps/x && pnpm install # Install dependencies
|
||||
cd apps/x && npm run deps # Build workspace packages (shared → core → preload)
|
||||
cd apps/x && npm run dev # Development mode (builds deps, runs app)
|
||||
cd apps/x && npm run lint # Lint check
|
||||
cd apps/x/apps/main && npm run package # Production build (.app)
|
||||
cd apps/x/apps/main && npm run make # Create DMG distributable
|
||||
```
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
```
|
||||
rowboat/
|
||||
├── apps/
|
||||
│ ├── x/ # Electron desktop app (focus of this doc)
|
||||
│ ├── rowboat/ # Next.js web dashboard
|
||||
│ ├── rowboatx/ # Next.js frontend
|
||||
│ ├── cli/ # CLI tool
|
||||
│ ├── python-sdk/ # Python SDK
|
||||
│ └── docs/ # Documentation site
|
||||
├── CLAUDE.md # This file
|
||||
└── README.md # User-facing readme
|
||||
```
|
||||
|
||||
## Electron App Architecture (`apps/x`)
|
||||
|
||||
The Electron app is a **nested pnpm workspace** with its own package management.
|
||||
|
||||
```
|
||||
apps/x/
|
||||
├── package.json # Workspace root, dev scripts
|
||||
├── pnpm-workspace.yaml # Defines workspace packages
|
||||
├── pnpm-lock.yaml # Lockfile
|
||||
├── apps/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── src/ # Main process source
|
||||
│ │ ├── forge.config.cjs # Electron Forge config
|
||||
│ │ └── bundle.mjs # esbuild bundler
|
||||
│ ├── renderer/ # React UI (Vite)
|
||||
│ │ ├── src/ # React components
|
||||
│ │ └── vite.config.ts
|
||||
│ └── preload/ # Electron preload scripts
|
||||
│ └── src/
|
||||
└── packages/
|
||||
├── shared/ # @x/shared - Types, utilities, validators
|
||||
└── core/ # @x/core - Business logic, AI, OAuth, MCP
|
||||
```
|
||||
|
||||
### Build Order (Dependencies)
|
||||
|
||||
```
|
||||
shared (no deps)
|
||||
↓
|
||||
core (depends on shared)
|
||||
↓
|
||||
preload (depends on shared)
|
||||
↓
|
||||
renderer (depends on shared)
|
||||
main (depends on shared, core)
|
||||
```
|
||||
|
||||
**The `npm run deps` command builds:** shared → core → preload
|
||||
|
||||
### Key Entry Points
|
||||
|
||||
| Component | Entry | Output |
|
||||
|-----------|-------|--------|
|
||||
| main | `apps/main/src/main.ts` | `.package/dist/main.cjs` |
|
||||
| renderer | `apps/renderer/src/main.tsx` | `apps/renderer/dist/` |
|
||||
| preload | `apps/preload/src/preload.ts` | `apps/preload/dist/preload.js` |
|
||||
|
||||
## Build System
|
||||
|
||||
- **Package manager:** pnpm (required for `workspace:*` protocol)
|
||||
- **Main bundler:** esbuild (bundles to single CommonJS file)
|
||||
- **Renderer bundler:** Vite
|
||||
- **Packaging:** Electron Forge
|
||||
- **TypeScript:** ES2022 target
|
||||
|
||||
### Why esbuild bundling?
|
||||
|
||||
pnpm uses symlinks for workspace packages. Electron Forge's dependency walker can't follow these symlinks. esbuild bundles everything into a single file, eliminating the need for node_modules in the packaged app.
|
||||
|
||||
## Key Files Reference
|
||||
|
||||
| Purpose | File |
|
||||
|---------|------|
|
||||
| Electron main entry | `apps/x/apps/main/src/main.ts` |
|
||||
| React app entry | `apps/x/apps/renderer/src/main.tsx` |
|
||||
| Forge config (packaging) | `apps/x/apps/main/forge.config.cjs` |
|
||||
| Main process bundler | `apps/x/apps/main/bundle.mjs` |
|
||||
| Vite config | `apps/x/apps/renderer/vite.config.ts` |
|
||||
| Shared types | `apps/x/packages/shared/src/` |
|
||||
| Core business logic | `apps/x/packages/core/src/` |
|
||||
| Workspace config | `apps/x/pnpm-workspace.yaml` |
|
||||
| Root scripts | `apps/x/package.json` |
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### LLM configuration (single provider)
|
||||
- Config file: `~/.rowboat/config/models.json`
|
||||
- Schema: `{ provider: { flavor, apiKey?, baseURL?, headers? }, model: string }`
|
||||
- Models catalog cache: `~/.rowboat/config/models.dev.json` (OpenAI/Anthropic/Google only)
|
||||
|
||||
### Add a new shared type
|
||||
1. Edit `apps/x/packages/shared/src/`
|
||||
2. Run `cd apps/x && npm run deps` to rebuild
|
||||
|
||||
### Modify main process
|
||||
1. Edit `apps/x/apps/main/src/`
|
||||
2. Restart dev server (main doesn't hot-reload)
|
||||
|
||||
### Modify renderer (React UI)
|
||||
1. Edit `apps/x/apps/renderer/src/`
|
||||
2. Changes hot-reload automatically in dev mode
|
||||
|
||||
### Add a new dependency to main
|
||||
1. `cd apps/x/apps/main && pnpm add <package>`
|
||||
2. Import in source - esbuild will bundle it
|
||||
|
||||
### Verify compilation
|
||||
```bash
|
||||
cd apps/x && npm run deps && npm run lint
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Desktop | Electron 39.x |
|
||||
| UI | React 19, Vite 7 |
|
||||
| Styling | TailwindCSS, Radix UI |
|
||||
| State | React hooks |
|
||||
| AI | Vercel AI SDK, OpenAI/Anthropic/Google/OpenRouter providers, Vercel AI Gateway, Ollama, models.dev catalog |
|
||||
| IPC | Electron contextBridge |
|
||||
| Build | TypeScript 5.9, esbuild, Electron Forge |
|
||||
|
||||
## Environment Variables (for packaging)
|
||||
|
||||
For production builds with code signing:
|
||||
- `APPLE_ID` - Apple Developer ID
|
||||
- `APPLE_PASSWORD` - App-specific password
|
||||
- `APPLE_TEAM_ID` - Team ID
|
||||
|
||||
Not required for local development.
|
||||
|
|
@ -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, '"')
|
||||
.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<void>
|
||||
): Promise<AuthServerResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = createServer((req, res) => {
|
||||
|
|
@ -46,7 +56,7 @@ export function createAuthServer(
|
|||
</head>
|
||||
<body>
|
||||
<h1 class="error">Authorization Failed</h1>
|
||||
<p>Error: ${error}</p>
|
||||
<p>Error: ${escapeHtml(error)}</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
|
|
@ -55,48 +65,28 @@ export function createAuthServer(
|
|||
return;
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
onCallback(code, state);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.success { color: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">Authorization Successful</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(400, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OAuth Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.error { color: #d32f2f; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="error">Invalid Request</h1>
|
||||
<p>Missing code or state parameter.</p>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
// Handle callback - either traditional OAuth with code/state or Composio-style notification
|
||||
// Composio callbacks may not have code/state, just a notification that the flow completed
|
||||
onCallback(code || '', state || '');
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
.success { color: #2e7d32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="success">Authorization Successful</h1>
|
||||
<p>You can close this window.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not Found');
|
||||
|
|
|
|||
296
apps/x/apps/main/src/composio-handler.ts
Normal file
296
apps/x/apps/main/src/composio-handler.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import { shell, BrowserWindow } from 'electron';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import * as composioClient from '@x/core/dist/composio/client.js';
|
||||
import { composioAccountsRepo } from '@x/core/dist/composio/repo.js';
|
||||
import type { LocalConnectedAccount } from '@x/core/dist/composio/types.js';
|
||||
|
||||
const REDIRECT_URI = 'http://localhost:8081/oauth/callback';
|
||||
|
||||
// Store active OAuth flows
|
||||
const activeFlows = new Map<string, {
|
||||
toolkitSlug: string;
|
||||
connectedAccountId: string;
|
||||
authConfigId: string;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Emit Composio connection event to all renderer windows
|
||||
*/
|
||||
export function emitComposioEvent(event: { toolkitSlug: string; success: boolean; error?: string }): void {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
for (const win of windows) {
|
||||
if (!win.isDestroyed() && win.webContents) {
|
||||
win.webContents.send('composio:didConnect', event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio is configured with an API key
|
||||
*/
|
||||
export function isConfigured(): { configured: boolean } {
|
||||
return { configured: composioClient.isConfigured() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composio API key
|
||||
*/
|
||||
export function setApiKey(apiKey: string): { success: boolean; error?: string } {
|
||||
try {
|
||||
composioClient.setApiKey(apiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to set API key',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth connection for a toolkit
|
||||
*/
|
||||
export async function initiateConnection(toolkitSlug: string): Promise<{
|
||||
success: boolean;
|
||||
redirectUrl?: string;
|
||||
connectedAccountId?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log(`[Composio] Initiating connection for ${toolkitSlug}...`);
|
||||
|
||||
// Check if already connected
|
||||
if (composioAccountsRepo.isConnected(toolkitSlug)) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get toolkit to check auth schemes
|
||||
const toolkit = await composioClient.getToolkit(toolkitSlug);
|
||||
|
||||
// Check for managed OAuth2
|
||||
if (!toolkit.composio_managed_auth_schemes.includes('OAUTH2')) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Toolkit ${toolkitSlug} does not support managed OAuth2`,
|
||||
};
|
||||
}
|
||||
|
||||
// Find or create managed OAuth2 auth config
|
||||
const authConfigs = await composioClient.listAuthConfigs(toolkitSlug, null, true);
|
||||
let authConfigId: string;
|
||||
|
||||
const managedOauth2 = authConfigs.items.find(
|
||||
cfg => cfg.auth_scheme === 'OAUTH2' && cfg.is_composio_managed
|
||||
);
|
||||
|
||||
if (managedOauth2) {
|
||||
authConfigId = managedOauth2.id;
|
||||
} else {
|
||||
// Create new managed auth config
|
||||
const created = await composioClient.createAuthConfig({
|
||||
toolkit: { slug: toolkitSlug },
|
||||
auth_config: {
|
||||
type: 'use_composio_managed_auth',
|
||||
name: `rowboat-${toolkitSlug}`,
|
||||
},
|
||||
});
|
||||
authConfigId = created.auth_config.id;
|
||||
}
|
||||
|
||||
// Create connected account with callback URL
|
||||
const callbackUrl = REDIRECT_URI;
|
||||
const response = await composioClient.createConnectedAccount({
|
||||
auth_config: { id: authConfigId },
|
||||
connection: {
|
||||
user_id: 'rowboat-user',
|
||||
callback_url: callbackUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const connectedAccountId = response.id;
|
||||
|
||||
// Safely extract redirectUrl with type checking
|
||||
const connectionVal = response.connectionData?.val;
|
||||
const redirectUrl = typeof connectionVal === 'object' && connectionVal !== null && 'redirectUrl' in connectionVal
|
||||
? String((connectionVal as Record<string, unknown>).redirectUrl)
|
||||
: undefined;
|
||||
|
||||
if (!redirectUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No redirect URL received from Composio',
|
||||
};
|
||||
}
|
||||
|
||||
// Store flow state
|
||||
const flowKey = `${toolkitSlug}-${Date.now()}`;
|
||||
activeFlows.set(flowKey, {
|
||||
toolkitSlug,
|
||||
connectedAccountId,
|
||||
authConfigId,
|
||||
});
|
||||
|
||||
// Save initial account state
|
||||
const account: LocalConnectedAccount = {
|
||||
id: connectedAccountId,
|
||||
authConfigId,
|
||||
status: 'INITIATED',
|
||||
toolkitSlug,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUpdatedAt: new Date().toISOString(),
|
||||
};
|
||||
composioAccountsRepo.saveAccount(account);
|
||||
|
||||
// Set up callback server
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
const { server } = await createAuthServer(8081, async (_code, _state) => {
|
||||
// OAuth callback received - sync the account status
|
||||
try {
|
||||
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
|
||||
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
|
||||
|
||||
if (accountStatus.status === 'ACTIVE') {
|
||||
emitComposioEvent({ toolkitSlug, success: true });
|
||||
} else {
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
success: false,
|
||||
error: `Connection status: ${accountStatus.status}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Composio] Failed to sync account status:', error);
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
activeFlows.delete(flowKey);
|
||||
server.close();
|
||||
clearTimeout(cleanupTimeout);
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout for abandoned flows (5 minutes)
|
||||
cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(flowKey)) {
|
||||
console.log(`[Composio] Cleaning up abandoned flow for ${toolkitSlug}`);
|
||||
activeFlows.delete(flowKey);
|
||||
server.close();
|
||||
emitComposioEvent({
|
||||
toolkitSlug,
|
||||
success: false,
|
||||
error: 'OAuth flow timed out',
|
||||
});
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// Open browser for OAuth
|
||||
shell.openExternal(redirectUrl);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
redirectUrl,
|
||||
connectedAccountId,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Composio] Connection initiation failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status for a toolkit
|
||||
*/
|
||||
export async function getConnectionStatus(toolkitSlug: string): Promise<{
|
||||
isConnected: boolean;
|
||||
status?: string;
|
||||
}> {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account) {
|
||||
return { isConnected: false };
|
||||
}
|
||||
return {
|
||||
isConnected: account.status === 'ACTIVE',
|
||||
status: account.status,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync connection status with Composio API
|
||||
*/
|
||||
export async function syncConnection(
|
||||
toolkitSlug: string,
|
||||
connectedAccountId: string
|
||||
): Promise<{ status: string }> {
|
||||
try {
|
||||
const accountStatus = await composioClient.getConnectedAccount(connectedAccountId);
|
||||
composioAccountsRepo.updateAccountStatus(toolkitSlug, accountStatus.status);
|
||||
return { status: accountStatus.status };
|
||||
} catch (error) {
|
||||
console.error('[Composio] Failed to sync connection:', error);
|
||||
return { status: 'FAILED' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a toolkit
|
||||
*/
|
||||
export async function disconnect(toolkitSlug: string): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (account) {
|
||||
// Delete from Composio
|
||||
await composioClient.deleteConnectedAccount(account.id);
|
||||
// Delete local record
|
||||
composioAccountsRepo.deleteAccount(toolkitSlug);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Composio] Disconnect failed:', error);
|
||||
// Still delete local record even if API call fails
|
||||
composioAccountsRepo.deleteAccount(toolkitSlug);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List connected toolkits
|
||||
*/
|
||||
export function listConnected(): { toolkits: string[] } {
|
||||
return { toolkits: composioAccountsRepo.getConnectedToolkits() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Composio action
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionSlug: string,
|
||||
toolkitSlug: string,
|
||||
input: Record<string, unknown>
|
||||
): Promise<{ success: boolean; data: unknown; error?: string }> {
|
||||
try {
|
||||
const account = composioAccountsRepo.getAccount(toolkitSlug);
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: `Toolkit ${toolkitSlug} is not connected`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await composioClient.executeAction(actionSlug, account.id, input);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Composio] Action execution failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -15,11 +15,15 @@ import { bus } from '@x/core/dist/runs/bus.js';
|
|||
import type { FSWatcher } from 'chokidar';
|
||||
import fs from 'node:fs/promises';
|
||||
import z from 'zod';
|
||||
import { RunEvent } from 'packages/shared/dist/runs.js';
|
||||
import { RunEvent } from '@x/shared/dist/runs.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
||||
import { testModelConnection } from '@x/core/dist/models/models.js';
|
||||
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
||||
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
||||
import * as composioHandler from './composio-handler.js';
|
||||
|
||||
type InvokeChannels = ipc.InvokeChannels;
|
||||
type IPCChannels = ipc.IPCChannels;
|
||||
|
|
@ -295,7 +299,7 @@ export function setupIpcHandlers() {
|
|||
return { success: true };
|
||||
},
|
||||
'runs:stop': async (_event, args) => {
|
||||
await runsCore.stop(args.runId);
|
||||
await runsCore.stop(args.runId, args.force);
|
||||
return { success: true };
|
||||
},
|
||||
'runs:fetch': async (_event, args) => {
|
||||
|
|
@ -304,8 +308,19 @@ export function setupIpcHandlers() {
|
|||
'runs:list': async (_event, args) => {
|
||||
return runsCore.listRuns(args.cursor);
|
||||
},
|
||||
'models:list': async () => {
|
||||
return await listOnboardingModels();
|
||||
},
|
||||
'models:test': async (_event, args) => {
|
||||
return await testModelConnection(args.provider, args.model);
|
||||
},
|
||||
'models:saveConfig': async (_event, args) => {
|
||||
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
||||
await repo.setConfig(args);
|
||||
return { success: true };
|
||||
},
|
||||
'oauth:connect': async (_event, args) => {
|
||||
return await connectProvider(args.provider);
|
||||
return await connectProvider(args.provider, args.clientId);
|
||||
},
|
||||
'oauth:disconnect': async (_event, args) => {
|
||||
return await disconnectProvider(args.provider);
|
||||
|
|
@ -344,5 +359,30 @@ export function setupIpcHandlers() {
|
|||
markOnboardingComplete();
|
||||
return { success: true };
|
||||
},
|
||||
// Composio integration handlers
|
||||
'composio:is-configured': async () => {
|
||||
return composioHandler.isConfigured();
|
||||
},
|
||||
'composio:set-api-key': async (_event, args) => {
|
||||
return composioHandler.setApiKey(args.apiKey);
|
||||
},
|
||||
'composio:initiate-connection': async (_event, args) => {
|
||||
return composioHandler.initiateConnection(args.toolkitSlug);
|
||||
},
|
||||
'composio:get-connection-status': async (_event, args) => {
|
||||
return composioHandler.getConnectionStatus(args.toolkitSlug);
|
||||
},
|
||||
'composio:sync-connection': async (_event, args) => {
|
||||
return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId);
|
||||
},
|
||||
'composio:disconnect': async (_event, args) => {
|
||||
return composioHandler.disconnect(args.toolkitSlug);
|
||||
},
|
||||
'composio:list-connected': async () => {
|
||||
return composioHandler.listConnected();
|
||||
},
|
||||
'composio:execute-action': async (_event, args) => {
|
||||
return composioHandler.executeAction(args.actionSlug, args.toolkitSlug, args.input);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import { shell } from 'electron';
|
||||
import type { Server } from 'http';
|
||||
import { createAuthServer } from './auth-server.js';
|
||||
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
|
||||
import type { Configuration } from '@x/core/dist/auth/oauth-client.js';
|
||||
import { getProviderConfig, getAvailableProviders } from '@x/core/dist/auth/providers.js';
|
||||
import {
|
||||
clearProviderClientIdOverride,
|
||||
getProviderClientIdOverride,
|
||||
hasProviderClientIdOverride,
|
||||
setProviderClientIdOverride,
|
||||
} from '@x/core/dist/auth/provider-client-id.js';
|
||||
import container from '@x/core/dist/di/container.js';
|
||||
import { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||
import { IClientRegistrationRepo } from '@x/core/dist/auth/client-repo.js';
|
||||
|
|
@ -14,12 +21,48 @@ import { emitOAuthEvent } from './ipc.js';
|
|||
const REDIRECT_URI = 'http://localhost:8080/oauth/callback';
|
||||
|
||||
// Store active OAuth flows (state -> { codeVerifier, provider, config })
|
||||
const activeFlows = new Map<string, {
|
||||
codeVerifier: string;
|
||||
const activeFlows = new Map<string, {
|
||||
codeVerifier: string;
|
||||
provider: string;
|
||||
config: Configuration;
|
||||
}>();
|
||||
|
||||
// Module-level state for tracking the active OAuth flow
|
||||
interface ActiveOAuthFlow {
|
||||
provider: string;
|
||||
state: string;
|
||||
server: Server;
|
||||
cleanupTimeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
let activeFlow: ActiveOAuthFlow | null = null;
|
||||
|
||||
/**
|
||||
* Cancel any active OAuth flow, cleaning up resources
|
||||
*/
|
||||
function cancelActiveFlow(reason: string = 'cancelled'): void {
|
||||
if (!activeFlow) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[OAuth] Cancelling active flow for ${activeFlow.provider}: ${reason}`);
|
||||
|
||||
clearTimeout(activeFlow.cleanupTimeout);
|
||||
activeFlow.server.close();
|
||||
activeFlows.delete(activeFlow.state);
|
||||
|
||||
// Only emit event for user-visible cancellations
|
||||
if (reason !== 'new_flow_started') {
|
||||
emitOAuthEvent({
|
||||
provider: activeFlow.provider,
|
||||
success: false,
|
||||
error: `OAuth flow ${reason}`
|
||||
});
|
||||
}
|
||||
|
||||
activeFlow = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get OAuth repository from DI container
|
||||
*/
|
||||
|
|
@ -39,14 +82,25 @@ function getClientRegistrationRepo(): IClientRegistrationRepo {
|
|||
*/
|
||||
async function getProviderConfiguration(provider: string): Promise<Configuration> {
|
||||
const config = getProviderConfig(provider);
|
||||
const resolveClientId = (): string => {
|
||||
const override = getProviderClientIdOverride(provider);
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
if (config.client.mode === 'static' && config.client.clientId) {
|
||||
return config.client.clientId;
|
||||
}
|
||||
throw new Error(`${provider} client ID not configured. Please provide a client ID.`);
|
||||
};
|
||||
|
||||
if (config.discovery.mode === 'issuer') {
|
||||
if (config.client.mode === 'static') {
|
||||
// Discover endpoints, use static client ID
|
||||
console.log(`[OAuth] ${provider}: Discovery from issuer with static client ID`);
|
||||
const clientId = resolveClientId();
|
||||
return await oauthClient.discoverConfiguration(
|
||||
config.discovery.issuer,
|
||||
config.client.clientId
|
||||
clientId
|
||||
);
|
||||
} else {
|
||||
// DCR mode - check for existing registration or register new
|
||||
|
|
@ -83,10 +137,11 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
|
|||
}
|
||||
|
||||
console.log(`[OAuth] ${provider}: Using static endpoints (no discovery)`);
|
||||
const clientId = resolveClientId();
|
||||
return oauthClient.createStaticConfiguration(
|
||||
config.discovery.authorizationEndpoint,
|
||||
config.discovery.tokenEndpoint,
|
||||
config.client.clientId,
|
||||
clientId,
|
||||
config.discovery.revocationEndpoint
|
||||
);
|
||||
}
|
||||
|
|
@ -95,12 +150,24 @@ async function getProviderConfiguration(provider: string): Promise<Configuration
|
|||
/**
|
||||
* Initiate OAuth flow for a provider
|
||||
*/
|
||||
export async function connectProvider(provider: string): Promise<{ success: boolean; error?: string }> {
|
||||
export async function connectProvider(provider: string, clientId?: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log(`[OAuth] Starting connection flow for ${provider}...`);
|
||||
|
||||
// Cancel any existing flow before starting a new one
|
||||
cancelActiveFlow('new_flow_started');
|
||||
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const providerConfig = getProviderConfig(provider);
|
||||
|
||||
if (provider === 'google') {
|
||||
const trimmedClientId = clientId?.trim();
|
||||
if (!trimmedClientId) {
|
||||
return { success: false, error: 'Google client ID is required to connect.' };
|
||||
}
|
||||
setProviderClientIdOverride(provider, trimmedClientId);
|
||||
}
|
||||
|
||||
// Get or create OAuth configuration
|
||||
const config = await getProviderConfiguration(provider);
|
||||
|
||||
|
|
@ -122,9 +189,6 @@ export async function connectProvider(provider: string): Promise<{ success: bool
|
|||
state,
|
||||
});
|
||||
|
||||
// Declare timeout variable (will be set after server is created)
|
||||
let cleanupTimeout: NodeJS.Timeout;
|
||||
|
||||
// Create callback server
|
||||
const { server } = await createAuthServer(8080, async (code, receivedState) => {
|
||||
// Validate state
|
||||
|
|
@ -140,7 +204,7 @@ export async function connectProvider(provider: string): Promise<{ success: bool
|
|||
try {
|
||||
// Build callback URL for token exchange
|
||||
const callbackUrl = new URL(`${REDIRECT_URI}?code=${code}&state=${receivedState}`);
|
||||
|
||||
|
||||
// Exchange code for tokens
|
||||
console.log(`[OAuth] Exchanging authorization code for tokens (${provider})...`);
|
||||
const tokens = await oauthClient.exchangeCodeForTokens(
|
||||
|
|
@ -172,21 +236,30 @@ export async function connectProvider(provider: string): Promise<{ success: bool
|
|||
} finally {
|
||||
// Clean up
|
||||
activeFlows.delete(state);
|
||||
server.close();
|
||||
clearTimeout(cleanupTimeout);
|
||||
if (activeFlow && activeFlow.state === state) {
|
||||
clearTimeout(activeFlow.cleanupTimeout);
|
||||
activeFlow.server.close();
|
||||
activeFlow = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set timeout to clean up abandoned flows (5 minutes)
|
||||
// Set timeout to clean up abandoned flows (2 minutes)
|
||||
// This prevents memory leaks if user never completes the OAuth flow
|
||||
cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlows.has(state)) {
|
||||
const cleanupTimeout = setTimeout(() => {
|
||||
if (activeFlow?.state === state) {
|
||||
console.log(`[OAuth] Cleaning up abandoned OAuth flow for ${provider} (timeout)`);
|
||||
activeFlows.delete(state);
|
||||
server.close();
|
||||
emitOAuthEvent({ provider, success: false, error: 'OAuth flow timed out' });
|
||||
cancelActiveFlow('timed_out');
|
||||
}
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
}, 2 * 60 * 1000); // 2 minutes
|
||||
|
||||
// Store complete flow state for cleanup
|
||||
activeFlow = {
|
||||
provider,
|
||||
state,
|
||||
server,
|
||||
cleanupTimeout,
|
||||
};
|
||||
|
||||
// Open in system browser (shares cookies/sessions with user's regular browser)
|
||||
shell.openExternal(authUrl.toString());
|
||||
|
|
@ -209,6 +282,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
await oauthRepo.clearTokens(provider);
|
||||
if (provider === 'google') {
|
||||
clearProviderClientIdOverride(provider);
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('OAuth disconnect failed:', error);
|
||||
|
|
@ -222,6 +298,9 @@ export async function disconnectProvider(provider: string): Promise<{ success: b
|
|||
export async function isConnected(provider: string): Promise<{ isConnected: boolean }> {
|
||||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
if (provider === 'google' && !hasProviderClientIdOverride(provider)) {
|
||||
return { isConnected: false };
|
||||
}
|
||||
const connected = await oauthRepo.isConnected(provider);
|
||||
return { isConnected: connected };
|
||||
} catch (error) {
|
||||
|
|
@ -278,7 +357,10 @@ export async function getConnectedProviders(): Promise<{ providers: string[] }>
|
|||
try {
|
||||
const oauthRepo = getOAuthRepo();
|
||||
const providers = await oauthRepo.getConnectedProviders();
|
||||
return { providers };
|
||||
const filteredProviders = providers.filter((provider) =>
|
||||
provider === 'google' ? hasProviderClientIdOverride(provider) : true
|
||||
);
|
||||
return { providers: filteredProviders };
|
||||
} catch (error) {
|
||||
console.error('Get connected providers failed:', error);
|
||||
return { providers: [] };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { contextBridge, ipcRenderer, webUtils } from 'electron';
|
||||
import { ipc as ipcShared } from '@x/shared';
|
||||
|
||||
type InvokeChannels = ipcShared.InvokeChannels;
|
||||
|
|
@ -51,4 +51,8 @@ const ipc = {
|
|||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('ipc', ipc);
|
||||
contextBridge.exposeInMainWorld('ipc', ipc);
|
||||
|
||||
contextBridge.exposeInMainWorld('electronUtils', {
|
||||
getPathForFile: (file: File) => webUtils.getPathForFile(file),
|
||||
});
|
||||
|
|
@ -6,7 +6,7 @@ import type { LanguageModelUsage, ToolUIPart } from 'ai';
|
|||
import './App.css'
|
||||
import z from 'zod';
|
||||
import { Button } from './components/ui/button';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen } from 'lucide-react';
|
||||
import { CheckIcon, LoaderIcon, ArrowUp, PanelRightIcon, SquarePen, Square } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MarkdownEditor } from './components/markdown-editor';
|
||||
import { ChatInputBar } from './components/chat-button';
|
||||
|
|
@ -279,7 +279,9 @@ const collectFilePaths = (nodes: TreeNode[]): string[] =>
|
|||
// Inner component that uses the controller to access mentions
|
||||
interface ChatInputInnerProps {
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
presetMessage?: string
|
||||
onPresetMessageConsumed?: () => void
|
||||
runId?: string | null
|
||||
|
|
@ -287,7 +289,9 @@ interface ChatInputInnerProps {
|
|||
|
||||
function ChatInputInner({
|
||||
onSubmit,
|
||||
onStop,
|
||||
isProcessing,
|
||||
isStopping,
|
||||
presetMessage,
|
||||
onPresetMessageConsumed,
|
||||
runId,
|
||||
|
|
@ -318,6 +322,37 @@ function ChatInputInner({
|
|||
}
|
||||
}, [handleSubmit])
|
||||
|
||||
useEffect(() => {
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types?.includes("Files")) {
|
||||
e.preventDefault()
|
||||
}
|
||||
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
|
||||
const paths = Array.from(e.dataTransfer.files)
|
||||
.map((f) => window.electronUtils?.getPathForFile(f))
|
||||
.filter(Boolean)
|
||||
if (paths.length > 0) {
|
||||
const currentText = controller.textInput.value
|
||||
const pathText = paths.join(' ')
|
||||
controller.textInput.setInput(
|
||||
currentText ? `${currentText} ${pathText}` : pathText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("dragover", onDragOver)
|
||||
document.addEventListener("drop", onDrop)
|
||||
return () => {
|
||||
document.removeEventListener("dragover", onDragOver)
|
||||
document.removeEventListener("drop", onDrop)
|
||||
}
|
||||
}, [controller])
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 bg-background border border-border rounded-3xl shadow-xl px-4 py-2.5">
|
||||
<PromptInputTextarea
|
||||
|
|
@ -327,19 +362,39 @@ function ChatInputInner({
|
|||
focusTrigger={runId}
|
||||
className="min-h-6 py-0 border-0 shadow-none focus-visible:ring-0 rounded-none"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
canSubmit
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
title={isStopping ? "Click again to force stop" : "Stop generation"}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
isStopping
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
>
|
||||
{isStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
canSubmit
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -350,7 +405,9 @@ interface ChatInputWithMentionsProps {
|
|||
recentFiles: string[]
|
||||
visibleFiles: string[]
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
onStop?: () => void
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
presetMessage?: string
|
||||
onPresetMessageConsumed?: () => void
|
||||
runId?: string | null
|
||||
|
|
@ -361,7 +418,9 @@ function ChatInputWithMentions({
|
|||
recentFiles,
|
||||
visibleFiles,
|
||||
onSubmit,
|
||||
onStop,
|
||||
isProcessing,
|
||||
isStopping,
|
||||
presetMessage,
|
||||
onPresetMessageConsumed,
|
||||
runId,
|
||||
|
|
@ -370,7 +429,9 @@ function ChatInputWithMentions({
|
|||
<PromptInputProvider knowledgeFiles={knowledgeFiles} recentFiles={recentFiles} visibleFiles={visibleFiles}>
|
||||
<ChatInputInner
|
||||
onSubmit={onSubmit}
|
||||
onStop={onStop}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
presetMessage={presetMessage}
|
||||
onPresetMessageConsumed={onPresetMessageConsumed}
|
||||
runId={runId}
|
||||
|
|
@ -414,6 +475,8 @@ function App() {
|
|||
const [runId, setRunId] = useState<string | null>(null)
|
||||
const runIdRef = useRef<string | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const [stopClickedAt, setStopClickedAt] = useState<number | null>(null)
|
||||
const [agentId] = useState<string>('copilot')
|
||||
const [presetMessage, setPresetMessage] = useState<string | undefined>(undefined)
|
||||
|
||||
|
|
@ -758,6 +821,8 @@ function App() {
|
|||
|
||||
case 'run-processing-end':
|
||||
setIsProcessing(false)
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
break
|
||||
|
||||
case 'start':
|
||||
|
|
@ -936,8 +1001,32 @@ function App() {
|
|||
break
|
||||
}
|
||||
|
||||
case 'run-stopped':
|
||||
setIsProcessing(false)
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
// Clear pending requests since they've been aborted
|
||||
setPendingPermissionRequests(new Map())
|
||||
setPendingAskHumanRequests(new Map())
|
||||
// Flush any streaming content as a message
|
||||
setCurrentAssistantMessage(currentMsg => {
|
||||
if (currentMsg) {
|
||||
setConversation(prev => [...prev, {
|
||||
id: `assistant-stopped-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: currentMsg,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
return ''
|
||||
})
|
||||
setCurrentReasoning('')
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setIsProcessing(false)
|
||||
setIsStopping(false)
|
||||
setStopClickedAt(null)
|
||||
console.error('Run error:', event.error)
|
||||
break
|
||||
}
|
||||
|
|
@ -1009,6 +1098,21 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!runId) return
|
||||
const now = Date.now()
|
||||
const isForce = isStopping && stopClickedAt !== null && (now - stopClickedAt) < 2000
|
||||
|
||||
setStopClickedAt(now)
|
||||
setIsStopping(true)
|
||||
|
||||
try {
|
||||
await window.ipc.invoke('runs:stop', { runId, force: isForce })
|
||||
} catch (error) {
|
||||
console.error('Failed to stop run:', error)
|
||||
}
|
||||
}, [runId, isStopping, stopClickedAt])
|
||||
|
||||
const handlePermissionResponse = useCallback(async (toolCallId: string, subflow: string[], response: 'approve' | 'deny') => {
|
||||
if (!runId) return
|
||||
|
||||
|
|
@ -1337,6 +1441,29 @@ function App() {
|
|||
},
|
||||
}), [tree, selectedPath, workspaceRoot, collectDirPaths])
|
||||
|
||||
// Handler for when a voice note is created/updated
|
||||
const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
|
||||
// Refresh the tree to show the new file/folder
|
||||
const newTree = await loadDirectory()
|
||||
setTree(newTree)
|
||||
|
||||
// Expand parent directories to show the file
|
||||
const parts = notePath.split('/')
|
||||
const parentPaths: string[] = []
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
parentPaths.push(parts.slice(0, i).join('/'))
|
||||
}
|
||||
setExpandedPaths(prev => {
|
||||
const newSet = new Set(prev)
|
||||
parentPaths.forEach(p => newSet.add(p))
|
||||
return newSet
|
||||
})
|
||||
|
||||
// Select the file to show it in the editor
|
||||
setIsGraphOpen(false)
|
||||
setSelectedPath(notePath)
|
||||
}, [loadDirectory])
|
||||
|
||||
const ensureWikiFile = useCallback(async (wikiPath: string) => {
|
||||
const resolvedPath = toKnowledgePath(wikiPath)
|
||||
if (!resolvedPath) return null
|
||||
|
|
@ -1562,7 +1689,7 @@ function App() {
|
|||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<SidebarSectionProvider defaultSection="knowledge" onSectionChange={handleSectionChange}>
|
||||
<SidebarSectionProvider defaultSection="tasks" onSectionChange={handleSectionChange}>
|
||||
<div className="flex h-svh w-full">
|
||||
{/* Icon sidebar - always visible, fixed position */}
|
||||
<SidebarIcon />
|
||||
|
|
@ -1583,6 +1710,7 @@ function App() {
|
|||
expandedPaths={expandedPaths}
|
||||
onSelectFile={toggleExpand}
|
||||
knowledgeActions={knowledgeActions}
|
||||
onVoiceNoteCreated={handleVoiceNoteCreated}
|
||||
runs={runs}
|
||||
currentRunId={runId}
|
||||
tasksActions={{
|
||||
|
|
@ -1779,7 +1907,9 @@ function App() {
|
|||
recentFiles={recentWikiFiles}
|
||||
visibleFiles={visibleKnowledgeFiles}
|
||||
onSubmit={handlePromptSubmit}
|
||||
onStop={handleStop}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
presetMessage={presetMessage}
|
||||
onPresetMessageConsumed={() => setPresetMessage(undefined)}
|
||||
runId={runId}
|
||||
|
|
@ -1801,6 +1931,8 @@ function App() {
|
|||
currentAssistantMessage={currentAssistantMessage}
|
||||
currentReasoning={currentReasoning}
|
||||
isProcessing={isProcessing}
|
||||
isStopping={isStopping}
|
||||
onStop={handleStop}
|
||||
message={message}
|
||||
onMessageChange={setMessage}
|
||||
onSubmit={handlePromptSubmit}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ export const Conversation = ({ className, ...props }: ConversationProps) => (
|
|||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ArrowUp, Expand, Plus } from 'lucide-react'
|
||||
import { ArrowUp, Expand, LoaderIcon, Plus, Square } from 'lucide-react'
|
||||
import type { ToolUIPart } from 'ai'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -115,6 +115,8 @@ interface ChatSidebarProps {
|
|||
currentAssistantMessage: string
|
||||
currentReasoning: string
|
||||
isProcessing: boolean
|
||||
isStopping?: boolean
|
||||
onStop?: () => void
|
||||
message: string
|
||||
onMessageChange: (message: string) => void
|
||||
onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void
|
||||
|
|
@ -139,6 +141,8 @@ export function ChatSidebar({
|
|||
currentAssistantMessage,
|
||||
currentReasoning,
|
||||
isProcessing,
|
||||
isStopping,
|
||||
onStop,
|
||||
message,
|
||||
onMessageChange,
|
||||
onSubmit,
|
||||
|
|
@ -595,19 +599,39 @@ export function ChatSidebar({
|
|||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
canSubmit
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
title={isStopping ? "Click again to force stop" : "Stop generation"}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
isStopping
|
||||
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
)}
|
||||
>
|
||||
{isStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-full shrink-0 transition-all",
|
||||
canSubmit
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{knowledgeFiles.length > 0 && (
|
||||
<MentionPopover
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface ComposioApiKeyModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (apiKey: string) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export function ComposioApiKeyModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: ComposioApiKeyModalProps) {
|
||||
const [apiKey, setApiKey] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setApiKey("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const trimmedApiKey = apiKey.trim()
|
||||
const isValid = trimmedApiKey.length > 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid || isSubmitting) return
|
||||
onSubmit(trimmedApiKey)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Composio API Key</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://app.composio.dev/settings"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline underline-offset-2"
|
||||
>
|
||||
app.composio.dev/settings
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="composio-api-key">
|
||||
API Key
|
||||
</label>
|
||||
<Input
|
||||
id="composio-api-key"
|
||||
type="password"
|
||||
placeholder="Enter your Composio API key"
|
||||
value={apiKey}
|
||||
onChange={(event) => setApiKey(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
|
|
@ -17,6 +17,9 @@ import {
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId, clearGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface ProviderState {
|
||||
|
|
@ -35,11 +38,18 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
|
||||
// Load available providers on mount
|
||||
useEffect(() => {
|
||||
async function loadProviders() {
|
||||
|
|
@ -86,11 +96,89 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack status:', error)
|
||||
setSlackConnected(false)
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Handle Slack connect button click
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Handle Composio API key submission
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Disconnect from Slack
|
||||
const handleDisconnectSlack = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:disconnect', { toolkitSlug: 'slack' })
|
||||
if (result.success) {
|
||||
setSlackConnected(false)
|
||||
toast.success('Disconnected from Slack')
|
||||
} else {
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect from Slack:', error)
|
||||
toast.error('Failed to disconnect from Slack')
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
||||
|
|
@ -117,7 +205,7 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
)
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
|
||||
// Refresh statuses when popover opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -161,15 +249,34 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
return cleanup
|
||||
}, [refreshAllStatuses])
|
||||
|
||||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
// Listen for Composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackConnected(success)
|
||||
setSlackConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider })
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
|
||||
|
||||
if (result.success) {
|
||||
// OAuth flow started - keep isConnecting state, wait for event
|
||||
|
|
@ -192,6 +299,27 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
const existingClientId = getGoogleClientId()
|
||||
if (!existingClientId) {
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
await startConnect(provider, existingClientId)
|
||||
return
|
||||
}
|
||||
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
setGoogleClientIdOpen(false)
|
||||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
// Disconnect from a provider
|
||||
const handleDisconnect = useCallback(async (provider: string) => {
|
||||
setProviderStates(prev => ({
|
||||
|
|
@ -203,6 +331,9 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
const result = await window.ipc.invoke('oauth:disconnect', { provider })
|
||||
|
||||
if (result.success) {
|
||||
if (provider === 'google') {
|
||||
clearGoogleClientId()
|
||||
}
|
||||
const displayName = provider === 'fireflies-ai' ? 'Fireflies' : provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||
toast.success(`Disconnected from ${displayName}`)
|
||||
setProviderStates(prev => ({
|
||||
|
|
@ -289,6 +420,13 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={googleClientIdOpen}
|
||||
onOpenChange={setGoogleClientIdOpen}
|
||||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{tooltip ? (
|
||||
<Tooltip open={open ? false : undefined}>
|
||||
|
|
@ -368,10 +506,71 @@ export function ConnectorsPopover({ children, tooltip }: ConnectorsPopoverProps)
|
|||
|
||||
{/* Fireflies */}
|
||||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-4" />, 'AI meeting transcripts')}
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Team Communication Section - Slack */}
|
||||
<div className="px-2 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Team Communication</span>
|
||||
</div>
|
||||
|
||||
{/* Slack */}
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-2 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDisconnectSlack}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface GoogleClientIdModalProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSubmit: (clientId: string) => void
|
||||
isSubmitting?: boolean
|
||||
}
|
||||
|
||||
export function GoogleClientIdModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
}: GoogleClientIdModalProps) {
|
||||
const [clientId, setClientId] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setClientId("")
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const trimmedClientId = clientId.trim()
|
||||
const isValid = trimmedClientId.length > 0
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!isValid || isSubmitting) return
|
||||
onSubmit(trimmedClientId)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Enter Google Client ID</DialogTitle>
|
||||
<DialogDescription>
|
||||
This app does not store the client ID. You will be prompted each session.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-muted-foreground" htmlFor="google-client-id">
|
||||
Client ID
|
||||
</label>
|
||||
<Input
|
||||
id="google-client-id"
|
||||
placeholder="xxxxxxxxxxxx-xxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!isValid || isSubmitting}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2, Sailboat } from "lucide-react"
|
||||
import { Loader2, Mic, Mail, CheckCircle2, Sailboat, MessageSquare } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -13,7 +13,18 @@ import {
|
|||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ComposioApiKeyModal } from "@/components/composio-api-key-modal"
|
||||
import { GoogleClientIdModal } from "@/components/google-client-id-modal"
|
||||
import { getGoogleClientId, setGoogleClientId } from "@/lib/google-client-id-store"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface ProviderState {
|
||||
|
|
@ -27,20 +38,75 @@ interface OnboardingModalProps {
|
|||
onComplete: () => void
|
||||
}
|
||||
|
||||
type Step = 0 | 1 | 2
|
||||
type Step = 0 | 1 | 2 | 3
|
||||
|
||||
type LlmProviderFlavor = "openai" | "anthropic" | "google" | "openrouter" | "aigateway" | "ollama" | "openai-compatible"
|
||||
|
||||
interface LlmModelOption {
|
||||
id: string
|
||||
name?: string
|
||||
release_date?: string
|
||||
}
|
||||
|
||||
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState<Step>(0)
|
||||
|
||||
// LLM setup state
|
||||
const [llmProvider, setLlmProvider] = useState<LlmProviderFlavor>("openai")
|
||||
const [modelsCatalog, setModelsCatalog] = useState<Record<string, LlmModelOption[]>>({})
|
||||
const [modelsLoading, setModelsLoading] = useState(false)
|
||||
const [modelsError, setModelsError] = useState<string | null>(null)
|
||||
const [providerConfigs, setProviderConfigs] = useState<Record<LlmProviderFlavor, { apiKey: string; baseURL: string; model: string }>>({
|
||||
openai: { apiKey: "", baseURL: "", model: "" },
|
||||
anthropic: { apiKey: "", baseURL: "", model: "" },
|
||||
google: { apiKey: "", baseURL: "", model: "" },
|
||||
openrouter: { apiKey: "", baseURL: "", model: "" },
|
||||
aigateway: { apiKey: "", baseURL: "", model: "" },
|
||||
ollama: { apiKey: "", baseURL: "http://localhost:11434", model: "" },
|
||||
"openai-compatible": { apiKey: "", baseURL: "http://localhost:1234/v1", model: "" },
|
||||
})
|
||||
const [testState, setTestState] = useState<{ status: "idle" | "testing" | "success" | "error"; error?: string }>({
|
||||
status: "idle",
|
||||
})
|
||||
const [savingLlmConfig, setSavingLlmConfig] = useState(false)
|
||||
|
||||
// OAuth provider states
|
||||
const [providers, setProviders] = useState<string[]>([])
|
||||
const [providersLoading, setProvidersLoading] = useState(true)
|
||||
const [providerStates, setProviderStates] = useState<Record<string, ProviderState>>({})
|
||||
const [googleClientIdOpen, setGoogleClientIdOpen] = useState(false)
|
||||
|
||||
// Granola state
|
||||
const [granolaEnabled, setGranolaEnabled] = useState(false)
|
||||
const [granolaLoading, setGranolaLoading] = useState(true)
|
||||
|
||||
// Composio/Slack state
|
||||
const [composioApiKeyOpen, setComposioApiKeyOpen] = useState(false)
|
||||
const [slackConnected, setSlackConnected] = useState(false)
|
||||
const [slackLoading, setSlackLoading] = useState(true)
|
||||
const [slackConnecting, setSlackConnecting] = useState(false)
|
||||
|
||||
const updateProviderConfig = useCallback(
|
||||
(provider: LlmProviderFlavor, updates: Partial<{ apiKey: string; baseURL: string; model: string }>) => {
|
||||
setProviderConfigs(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], ...updates },
|
||||
}))
|
||||
setTestState({ status: "idle" })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const activeConfig = providerConfigs[llmProvider]
|
||||
const requiresApiKey = llmProvider === "openai" || llmProvider === "anthropic" || llmProvider === "google" || llmProvider === "openrouter" || llmProvider === "aigateway"
|
||||
const requiresBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const showBaseURL = llmProvider === "ollama" || llmProvider === "openai-compatible" || llmProvider === "aigateway"
|
||||
const isLocalProvider = llmProvider === "ollama" || llmProvider === "openai-compatible"
|
||||
const canTest =
|
||||
activeConfig.model.trim().length > 0 &&
|
||||
(!requiresApiKey || activeConfig.apiKey.trim().length > 0) &&
|
||||
(!requiresBaseURL || activeConfig.baseURL.trim().length > 0)
|
||||
|
||||
// Track connected providers for the completion step
|
||||
const connectedProviders = Object.entries(providerStates)
|
||||
.filter(([, state]) => state.isConnected)
|
||||
|
|
@ -65,6 +131,48 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
loadProviders()
|
||||
}, [open])
|
||||
|
||||
// Load LLM models catalog on open
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
async function loadModels() {
|
||||
try {
|
||||
setModelsLoading(true)
|
||||
setModelsError(null)
|
||||
const result = await window.ipc.invoke("models:list", null)
|
||||
const catalog: Record<string, LlmModelOption[]> = {}
|
||||
for (const provider of result.providers || []) {
|
||||
catalog[provider.id] = provider.models || []
|
||||
}
|
||||
setModelsCatalog(catalog)
|
||||
} catch (error) {
|
||||
console.error("Failed to load models catalog:", error)
|
||||
setModelsError("Failed to load models list")
|
||||
setModelsCatalog({})
|
||||
} finally {
|
||||
setModelsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadModels()
|
||||
}, [open])
|
||||
|
||||
// Initialize default models from catalog
|
||||
useEffect(() => {
|
||||
if (Object.keys(modelsCatalog).length === 0) return
|
||||
setProviderConfigs(prev => {
|
||||
const next = { ...prev }
|
||||
const cloudProviders: LlmProviderFlavor[] = ["openai", "anthropic", "google"]
|
||||
for (const provider of cloudProviders) {
|
||||
const models = modelsCatalog[provider]
|
||||
if (models?.length && !next[provider].model) {
|
||||
next[provider] = { ...next[provider], model: models[0]?.id || "" }
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [modelsCatalog])
|
||||
|
||||
// Load Granola config
|
||||
const refreshGranolaConfig = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -94,11 +202,133 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Load Slack connection status
|
||||
const refreshSlackStatus = useCallback(async () => {
|
||||
try {
|
||||
setSlackLoading(true)
|
||||
const result = await window.ipc.invoke('composio:get-connection-status', { toolkitSlug: 'slack' })
|
||||
setSlackConnected(result.isConnected)
|
||||
} catch (error) {
|
||||
console.error('Failed to load Slack status:', error)
|
||||
setSlackConnected(false)
|
||||
} finally {
|
||||
setSlackLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Start Slack connection
|
||||
const startSlackConnect = useCallback(async () => {
|
||||
try {
|
||||
setSlackConnecting(true)
|
||||
const result = await window.ipc.invoke('composio:initiate-connection', { toolkitSlug: 'slack' })
|
||||
if (!result.success) {
|
||||
toast.error(result.error || 'Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
// Success will be handled by composio:didConnect event
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Slack:', error)
|
||||
toast.error('Failed to connect to Slack')
|
||||
setSlackConnecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Connect to Slack via Composio (checks if configured first)
|
||||
const handleConnectSlack = useCallback(async () => {
|
||||
// Check if Composio is configured
|
||||
const configResult = await window.ipc.invoke('composio:is-configured', null)
|
||||
if (!configResult.configured) {
|
||||
setComposioApiKeyOpen(true)
|
||||
return
|
||||
}
|
||||
await startSlackConnect()
|
||||
}, [startSlackConnect])
|
||||
|
||||
// Handle Composio API key submission
|
||||
const handleComposioApiKeySubmit = useCallback(async (apiKey: string) => {
|
||||
try {
|
||||
await window.ipc.invoke('composio:set-api-key', { apiKey })
|
||||
setComposioApiKeyOpen(false)
|
||||
toast.success('Composio API key saved')
|
||||
// Now start the Slack connection
|
||||
await startSlackConnect()
|
||||
} catch (error) {
|
||||
console.error('Failed to save Composio API key:', error)
|
||||
toast.error('Failed to save API key')
|
||||
}
|
||||
}, [startSlackConnect])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep((prev) => (prev + 1) as Step)
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete()
|
||||
}
|
||||
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
if (!canTest) return
|
||||
setTestState({ status: "testing" })
|
||||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
const result = await window.ipc.invoke("models:test", {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
},
|
||||
model,
|
||||
})
|
||||
if (result.success) {
|
||||
setTestState({ status: "success" })
|
||||
toast.success("Connection successful")
|
||||
} else {
|
||||
setTestState({ status: "error", error: result.error })
|
||||
toast.error(result.error || "Connection test failed")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Connection test failed:", error)
|
||||
setTestState({ status: "error", error: "Connection test failed" })
|
||||
toast.error("Connection test failed")
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, canTest, llmProvider])
|
||||
|
||||
const handleSaveLlmConfig = useCallback(async () => {
|
||||
if (testState.status !== "success") return
|
||||
setSavingLlmConfig(true)
|
||||
try {
|
||||
const apiKey = activeConfig.apiKey.trim() || undefined
|
||||
const baseURL = activeConfig.baseURL.trim() || undefined
|
||||
const model = activeConfig.model.trim()
|
||||
await window.ipc.invoke("models:saveConfig", {
|
||||
provider: {
|
||||
flavor: llmProvider,
|
||||
apiKey,
|
||||
baseURL,
|
||||
},
|
||||
model,
|
||||
})
|
||||
setSavingLlmConfig(false)
|
||||
handleNext()
|
||||
} catch (error) {
|
||||
console.error("Failed to save LLM config:", error)
|
||||
toast.error("Failed to save LLM settings")
|
||||
setSavingLlmConfig(false)
|
||||
}
|
||||
}, [activeConfig.apiKey, activeConfig.baseURL, activeConfig.model, handleNext, llmProvider, testState.status])
|
||||
|
||||
// Check connection status for all providers
|
||||
const refreshAllStatuses = useCallback(async () => {
|
||||
// Refresh Granola
|
||||
refreshGranolaConfig()
|
||||
|
||||
// Refresh Slack status
|
||||
refreshSlackStatus()
|
||||
|
||||
// Refresh OAuth providers
|
||||
if (providers.length === 0) return
|
||||
|
||||
|
|
@ -125,7 +355,7 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
)
|
||||
|
||||
setProviderStates(newStates)
|
||||
}, [providers, refreshGranolaConfig])
|
||||
}, [providers, refreshGranolaConfig, refreshSlackStatus])
|
||||
|
||||
// Refresh statuses when modal opens or providers list changes
|
||||
useEffect(() => {
|
||||
|
|
@ -159,15 +389,34 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
return cleanup
|
||||
}, [])
|
||||
|
||||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
// Listen for Composio connection events
|
||||
useEffect(() => {
|
||||
const cleanup = window.ipc.on('composio:didConnect', (event) => {
|
||||
const { toolkitSlug, success, error } = event
|
||||
|
||||
if (toolkitSlug === 'slack') {
|
||||
setSlackConnected(success)
|
||||
setSlackConnecting(false)
|
||||
|
||||
if (success) {
|
||||
toast.success('Connected to Slack')
|
||||
} else {
|
||||
toast.error(error || 'Failed to connect to Slack')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return cleanup
|
||||
}, [])
|
||||
|
||||
const startConnect = useCallback(async (provider: string, clientId?: string) => {
|
||||
setProviderStates(prev => ({
|
||||
...prev,
|
||||
[provider]: { ...prev[provider], isConnecting: true }
|
||||
}))
|
||||
|
||||
try {
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider })
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId })
|
||||
|
||||
if (!result.success) {
|
||||
toast.error(result.error || `Failed to connect to ${provider}`)
|
||||
|
|
@ -186,20 +435,31 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 2) {
|
||||
setCurrentStep((prev) => (prev + 1) as Step)
|
||||
// Connect to a provider
|
||||
const handleConnect = useCallback(async (provider: string) => {
|
||||
if (provider === 'google') {
|
||||
const existingClientId = getGoogleClientId()
|
||||
if (!existingClientId) {
|
||||
setGoogleClientIdOpen(true)
|
||||
return
|
||||
}
|
||||
await startConnect(provider, existingClientId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
onComplete()
|
||||
}
|
||||
await startConnect(provider)
|
||||
}, [startConnect])
|
||||
|
||||
const handleGoogleClientIdSubmit = useCallback((clientId: string) => {
|
||||
setGoogleClientId(clientId)
|
||||
setGoogleClientIdOpen(false)
|
||||
startConnect('google', clientId)
|
||||
}, [startConnect])
|
||||
|
||||
// Step indicator component
|
||||
const StepIndicator = () => (
|
||||
<div className="flex gap-2 justify-center mb-6">
|
||||
{[0, 1, 2].map((step) => (
|
||||
{[0, 1, 2, 3].map((step) => (
|
||||
<div
|
||||
key={step}
|
||||
className={cn(
|
||||
|
|
@ -291,6 +551,50 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Render Slack row
|
||||
const renderSlackRow = () => (
|
||||
<div className="flex items-center justify-between gap-3 rounded-md px-3 py-3 hover:bg-accent">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-muted">
|
||||
<MessageSquare className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">Slack</span>
|
||||
{slackLoading ? (
|
||||
<span className="text-xs text-muted-foreground">Checking...</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
Send messages and view channels
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{slackLoading ? (
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
) : slackConnected ? (
|
||||
<div className="flex items-center gap-1.5 text-sm text-green-600">
|
||||
<CheckCircle2 className="size-4" />
|
||||
<span>Connected</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleConnectSlack}
|
||||
disabled={slackConnecting}
|
||||
>
|
||||
{slackConnecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Step 0: Welcome
|
||||
const WelcomeStep = () => (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -323,7 +627,156 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Step 1: Connect Accounts
|
||||
// Step 1: LLM Setup
|
||||
const LlmSetupStep = () => {
|
||||
const providerOptions: Array<{ id: LlmProviderFlavor; name: string; description: string }> = [
|
||||
{ id: "openai", name: "OpenAI", description: "Use your OpenAI API key" },
|
||||
{ id: "anthropic", name: "Anthropic", description: "Use your Anthropic API key" },
|
||||
{ id: "google", name: "Google", description: "Use your Google AI Studio key" },
|
||||
{ id: "openrouter", name: "OpenRouter", description: "Access multiple models with one key" },
|
||||
{ id: "aigateway", name: "AI Gateway (Vercel)", description: "Use Vercel's AI Gateway" },
|
||||
{ id: "ollama", name: "Ollama (Local)", description: "Run a local model via Ollama" },
|
||||
{ id: "openai-compatible", name: "OpenAI-Compatible", description: "Local or hosted OpenAI-compatible API" },
|
||||
]
|
||||
|
||||
const modelsForProvider = modelsCatalog[llmProvider] || []
|
||||
const showModelInput = isLocalProvider || modelsForProvider.length === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="text-center mb-6">
|
||||
<DialogTitle className="text-2xl">Choose your model</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
Select your provider and model to power Rowboat’s AI.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Provider</span>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{providerOptions.map((provider) => (
|
||||
<button
|
||||
key={provider.id}
|
||||
onClick={() => {
|
||||
setLlmProvider(provider.id)
|
||||
setTestState({ status: "idle" })
|
||||
}}
|
||||
className={cn(
|
||||
"rounded-md border px-3 py-3 text-left transition-colors",
|
||||
llmProvider === provider.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<div className="text-sm font-medium">{provider.name}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{provider.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Model</span>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading models...
|
||||
</div>
|
||||
) : showModelInput ? (
|
||||
<Input
|
||||
value={activeConfig.model}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { model: e.target.value })}
|
||||
placeholder="Enter model ID"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={activeConfig.model}
|
||||
onValueChange={(value) => updateProviderConfig(llmProvider, { model: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{modelsForProvider.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name || model.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{modelsError && (
|
||||
<div className="text-xs text-destructive">{modelsError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requiresApiKey && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">API Key</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={activeConfig.apiKey}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { apiKey: e.target.value })}
|
||||
placeholder="Paste your API key"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBaseURL && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Base URL</span>
|
||||
<Input
|
||||
value={activeConfig.baseURL}
|
||||
onChange={(e) => updateProviderConfig(llmProvider, { baseURL: e.target.value })}
|
||||
placeholder={
|
||||
llmProvider === "ollama"
|
||||
? "http://localhost:11434"
|
||||
: llmProvider === "openai-compatible"
|
||||
? "http://localhost:1234/v1"
|
||||
: "https://ai-gateway.vercel.sh/v1"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleTestConnection}
|
||||
disabled={!canTest || testState.status === "testing"}
|
||||
>
|
||||
{testState.status === "testing" ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
"Test connection"
|
||||
)}
|
||||
</Button>
|
||||
{testState.status === "success" && (
|
||||
<span className="text-sm text-green-600">Connected</span>
|
||||
)}
|
||||
{testState.status === "error" && (
|
||||
<span className="text-sm text-destructive">
|
||||
{testState.error || "Test failed"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 mt-8">
|
||||
<Button
|
||||
onClick={handleSaveLlmConfig}
|
||||
size="lg"
|
||||
disabled={testState.status !== "success" || savingLlmConfig}
|
||||
>
|
||||
{savingLlmConfig ? <Loader2 className="size-4 animate-spin" /> : "Continue"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Connect Accounts
|
||||
const AccountConnectionStep = () => (
|
||||
<div className="flex flex-col">
|
||||
<DialogHeader className="text-center mb-6">
|
||||
|
|
@ -358,6 +811,14 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
{renderGranolaRow()}
|
||||
{providers.includes('fireflies-ai') && renderOAuthProvider('fireflies-ai', 'Fireflies', <Mic className="size-5" />, 'AI meeting transcripts')}
|
||||
</div>
|
||||
|
||||
{/* Team Communication Section */}
|
||||
<div className="space-y-2">
|
||||
<div className="px-3">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Team Communication</span>
|
||||
</div>
|
||||
{renderSlackRow()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -373,9 +834,9 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
</div>
|
||||
)
|
||||
|
||||
// Step 2: Completion
|
||||
// Step 3: Completion
|
||||
const CompletionStep = () => {
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled
|
||||
const hasConnections = connectedProviders.length > 0 || granolaEnabled || slackConnected
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center text-center">
|
||||
|
|
@ -416,6 +877,12 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
<span>Granola (Local meeting notes)</span>
|
||||
</div>
|
||||
)}
|
||||
{slackConnected && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 text-green-600" />
|
||||
<span>Slack (Team communication)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -429,6 +896,19 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleClientIdModal
|
||||
open={googleClientIdOpen}
|
||||
onOpenChange={setGoogleClientIdOpen}
|
||||
onSubmit={handleGoogleClientIdSubmit}
|
||||
isSubmitting={providerStates.google?.isConnecting ?? false}
|
||||
/>
|
||||
<ComposioApiKeyModal
|
||||
open={composioApiKeyOpen}
|
||||
onOpenChange={setComposioApiKeyOpen}
|
||||
onSubmit={handleComposioApiKeySubmit}
|
||||
isSubmitting={slackConnecting}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
className="w-[60vw] max-w-3xl max-h-[80vh] overflow-y-auto"
|
||||
|
|
@ -438,9 +918,11 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
|
|||
>
|
||||
<StepIndicator />
|
||||
{currentStep === 0 && <WelcomeStep />}
|
||||
{currentStep === 1 && <AccountConnectionStep />}
|
||||
{currentStep === 2 && <CompletionStep />}
|
||||
{currentStep === 1 && <LlmSetupStep />}
|
||||
{currentStep === 2 && <AccountConnectionStep />}
|
||||
{currentStep === 3 && <CompletionStep />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ import {
|
|||
Folder,
|
||||
FolderPlus,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Network,
|
||||
Pencil,
|
||||
Square,
|
||||
SquarePen,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
|
|
@ -88,6 +90,7 @@ type SidebarContentPanelProps = {
|
|||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
knowledgeActions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
runs?: RunListItem[]
|
||||
currentRunId?: string | null
|
||||
tasksActions?: TasksActions
|
||||
|
|
@ -95,7 +98,7 @@ type SidebarContentPanelProps = {
|
|||
|
||||
const sectionTitles = {
|
||||
knowledge: "Knowledge",
|
||||
tasks: "Tasks",
|
||||
tasks: "Chats",
|
||||
}
|
||||
|
||||
export function SidebarContentPanel({
|
||||
|
|
@ -104,6 +107,7 @@ export function SidebarContentPanel({
|
|||
expandedPaths,
|
||||
onSelectFile,
|
||||
knowledgeActions,
|
||||
onVoiceNoteCreated,
|
||||
runs = [],
|
||||
currentRunId,
|
||||
tasksActions,
|
||||
|
|
@ -126,6 +130,7 @@ export function SidebarContentPanel({
|
|||
expandedPaths={expandedPaths}
|
||||
onSelectFile={onSelectFile}
|
||||
actions={knowledgeActions}
|
||||
onVoiceNoteCreated={onVoiceNoteCreated}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "tasks" && (
|
||||
|
|
@ -141,6 +146,227 @@ export function SidebarContentPanel({
|
|||
)
|
||||
}
|
||||
|
||||
async function transcribeWithDeepgram(audioBlob: Blob): Promise<string | null> {
|
||||
try {
|
||||
const configResult = await window.ipc.invoke('workspace:readFile', {
|
||||
path: 'config/deepgram.json',
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const { apiKey } = JSON.parse(configResult.data) as { apiKey: string }
|
||||
if (!apiKey) throw new Error('No apiKey in deepgram.json')
|
||||
|
||||
const response = await fetch(
|
||||
'https://api.deepgram.com/v1/listen?model=nova-2&smart_format=true',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Token ${apiKey}`,
|
||||
'Content-Type': audioBlob.type,
|
||||
},
|
||||
body: audioBlob,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) throw new Error(`Deepgram API error: ${response.status}`)
|
||||
const result = await response.json()
|
||||
return result.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? null
|
||||
} catch (err) {
|
||||
console.error('Deepgram transcription failed:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Voice Note Recording Button
|
||||
function VoiceNoteButton({ onNoteCreated }: { onNoteCreated?: (path: string) => void }) {
|
||||
const [isRecording, setIsRecording] = React.useState(false)
|
||||
const mediaRecorderRef = React.useRef<MediaRecorder | null>(null)
|
||||
const chunksRef = React.useRef<Blob[]>([])
|
||||
const notePathRef = React.useRef<string | null>(null)
|
||||
const timestampRef = React.useRef<string | null>(null)
|
||||
const relativePathRef = React.useRef<string | null>(null)
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
// Generate timestamp and paths immediately
|
||||
const now = new Date()
|
||||
const timestamp = now.toISOString().replace(/[:.]/g, '-')
|
||||
const dateStr = now.toISOString().split('T')[0] // YYYY-MM-DD
|
||||
const noteName = `voice-memo-${timestamp}`
|
||||
const notePath = `knowledge/Voice Memos/${dateStr}/${noteName}.md`
|
||||
|
||||
timestampRef.current = timestamp
|
||||
notePathRef.current = notePath
|
||||
// Relative path for linking (from knowledge/ root, without .md extension)
|
||||
const relativePath = `Voice Memos/${dateStr}/${noteName}`
|
||||
relativePathRef.current = relativePath
|
||||
|
||||
// Create the note immediately with a "Recording..." placeholder
|
||||
await window.ipc.invoke('workspace:mkdir', {
|
||||
path: `knowledge/Voice Memos/${dateStr}`,
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
const initialContent = `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${now.toLocaleString()}
|
||||
**Path:** ${relativePath}
|
||||
|
||||
## Transcript
|
||||
|
||||
*Recording in progress...*
|
||||
`
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: notePath,
|
||||
data: initialContent,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
|
||||
// Select the note so the user can see it
|
||||
onNoteCreated?.(notePath)
|
||||
|
||||
// Start actual recording
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const mimeType = MediaRecorder.isTypeSupported('audio/mp4')
|
||||
? 'audio/mp4'
|
||||
: 'audio/webm'
|
||||
const recorder = new MediaRecorder(stream, { mimeType })
|
||||
chunksRef.current = []
|
||||
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data)
|
||||
}
|
||||
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType })
|
||||
const ext = mimeType === 'audio/mp4' ? 'm4a' : 'webm'
|
||||
const audioFilename = `voice-memo-${timestampRef.current}.${ext}`
|
||||
|
||||
// Save audio file to voice_memos folder (for backup/reference)
|
||||
try {
|
||||
await window.ipc.invoke('workspace:mkdir', {
|
||||
path: 'voice_memos',
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
const arrayBuffer = await blob.arrayBuffer()
|
||||
const base64 = btoa(
|
||||
new Uint8Array(arrayBuffer).reduce(
|
||||
(data, byte) => data + String.fromCharCode(byte),
|
||||
'',
|
||||
),
|
||||
)
|
||||
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: `voice_memos/${audioFilename}`,
|
||||
data: base64,
|
||||
opts: { encoding: 'base64' },
|
||||
})
|
||||
} catch {
|
||||
console.error('Failed to save audio file')
|
||||
}
|
||||
|
||||
// Update note to show transcribing status
|
||||
const currentNotePath = notePathRef.current
|
||||
const currentRelativePath = relativePathRef.current
|
||||
if (currentNotePath && currentRelativePath) {
|
||||
const transcribingContent = `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
|
||||
## Transcript
|
||||
|
||||
*Transcribing...*
|
||||
`
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: currentNotePath,
|
||||
data: transcribingContent,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
}
|
||||
|
||||
// Transcribe and update the note with the transcript
|
||||
const transcript = await transcribeWithDeepgram(blob)
|
||||
if (currentNotePath && currentRelativePath) {
|
||||
const finalContent = transcript
|
||||
? `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
|
||||
## Transcript
|
||||
|
||||
${transcript}
|
||||
`
|
||||
: `# Voice Memo
|
||||
|
||||
**Type:** voice memo
|
||||
**Recorded:** ${new Date().toLocaleString()}
|
||||
**Path:** ${currentRelativePath}
|
||||
|
||||
## Transcript
|
||||
|
||||
*Transcription failed. Please try again.*
|
||||
`
|
||||
await window.ipc.invoke('workspace:writeFile', {
|
||||
path: currentNotePath,
|
||||
data: finalContent,
|
||||
opts: { encoding: 'utf8' },
|
||||
})
|
||||
|
||||
// Re-select to trigger refresh
|
||||
onNoteCreated?.(currentNotePath)
|
||||
|
||||
if (transcript) {
|
||||
toast('Voice note transcribed', 'success')
|
||||
} else {
|
||||
toast('Transcription failed', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recorder.start()
|
||||
mediaRecorderRef.current = recorder
|
||||
setIsRecording(true)
|
||||
toast('Recording started', 'success')
|
||||
} catch {
|
||||
toast('Could not access microphone', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
||||
mediaRecorderRef.current.stop()
|
||||
}
|
||||
mediaRecorderRef.current = null
|
||||
setIsRecording(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
className="text-sidebar-foreground/70 hover:text-sidebar-foreground hover:bg-sidebar-accent rounded p-1.5 transition-colors"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Square className="size-4 fill-red-500 text-red-500 animate-pulse" />
|
||||
) : (
|
||||
<Mic className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{isRecording ? 'Stop Recording' : 'New Voice Note'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// Knowledge Section
|
||||
function KnowledgeSection({
|
||||
tree,
|
||||
|
|
@ -148,15 +374,17 @@ function KnowledgeSection({
|
|||
expandedPaths,
|
||||
onSelectFile,
|
||||
actions,
|
||||
onVoiceNoteCreated,
|
||||
}: {
|
||||
tree: TreeNode[]
|
||||
selectedPath: string | null
|
||||
expandedPaths: Set<string>
|
||||
onSelectFile: (path: string, kind: "file" | "dir") => void
|
||||
actions: KnowledgeActions
|
||||
onVoiceNoteCreated?: (path: string) => void
|
||||
}) {
|
||||
const isExpanded = expandedPaths.size > 0
|
||||
|
||||
|
||||
const quickActions = [
|
||||
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() },
|
||||
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
|
||||
|
|
@ -181,6 +409,7 @@ function KnowledgeSection({
|
|||
<TooltipContent side="bottom">{action.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<VoiceNoteButton onNoteCreated={onVoiceNoteCreated} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export function useSidebarSection() {
|
|||
}
|
||||
|
||||
export function SidebarSectionProvider({
|
||||
defaultSection = "knowledge",
|
||||
defaultSection = "tasks",
|
||||
onSectionChange,
|
||||
children,
|
||||
}: {
|
||||
|
|
|
|||
3
apps/x/apps/renderer/src/global.d.ts
vendored
3
apps/x/apps/renderer/src/global.d.ts
vendored
|
|
@ -33,6 +33,9 @@ declare global {
|
|||
handler: (event: IPCChannels[K]['req']) => void
|
||||
): () => void;
|
||||
};
|
||||
electronUtils: {
|
||||
getPathForFile: (file: File) => string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,10 +50,10 @@ export function useOAuth(provider: string) {
|
|||
return cleanup;
|
||||
}, [provider, checkConnection]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
const connect = useCallback(async (clientId?: string) => {
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider });
|
||||
const result = await window.ipc.invoke('oauth:connect', { provider, clientId });
|
||||
if (result.success) {
|
||||
// OAuth flow started - keep isConnecting state, wait for event
|
||||
// Event listener will handle the actual completion
|
||||
|
|
|
|||
17
apps/x/apps/renderer/src/lib/google-client-id-store.ts
Normal file
17
apps/x/apps/renderer/src/lib/google-client-id-store.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
let googleClientId: string | null = null;
|
||||
|
||||
export function getGoogleClientId(): string | null {
|
||||
return googleClientId;
|
||||
}
|
||||
|
||||
export function setGoogleClientId(clientId: string): void {
|
||||
const trimmed = clientId.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
googleClientId = trimmed;
|
||||
}
|
||||
|
||||
export function clearGoogleClientId(): void {
|
||||
googleClientId = null;
|
||||
}
|
||||
|
|
@ -12,11 +12,14 @@
|
|||
"@ai-sdk/anthropic": "^2.0.44",
|
||||
"@ai-sdk/google": "^2.0.25",
|
||||
"@ai-sdk/openai": "^2.0.53",
|
||||
"@composio/core": "^0.6.0",
|
||||
"@ai-sdk/openai-compatible": "^1.0.27",
|
||||
"@ai-sdk/provider": "^2.0.0",
|
||||
"@google-cloud/local-auth": "^3.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openrouter/ai-sdk-provider": "^1.2.6",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@types/react": "^19.2.7",
|
||||
"@x/shared": "workspace:*",
|
||||
"ai": "^5.0.102",
|
||||
"awilix": "^12.0.5",
|
||||
|
|
@ -27,6 +30,7 @@
|
|||
"node-html-markdown": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
"openid-client": "^6.8.1",
|
||||
"react": "^19.2.3",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,13 +15,14 @@ import { CopilotAgent } from "../application/assistant/agent.js";
|
|||
import { isBlocked } from "../application/lib/command-executor.js";
|
||||
import container from "../di/container.js";
|
||||
import { IModelConfigRepo } from "../models/repo.js";
|
||||
import { getProvider } from "../models/models.js";
|
||||
import { createProvider } from "../models/models.js";
|
||||
import { IAgentsRepo } from "./repo.js";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IMessageQueue } from "../application/lib/message-queue.js";
|
||||
import { IRunsRepo } from "../runs/repo.js";
|
||||
import { IRunsLock } from "../runs/lock.js";
|
||||
import { IAbortRegistry } from "../runs/abort-registry.js";
|
||||
import { PrefixLogger } from "@x/shared";
|
||||
import { parse } from "yaml";
|
||||
import { raw as noteCreationMediumRaw } from "../knowledge/note_creation_medium.js";
|
||||
|
|
@ -39,6 +40,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
private messageQueue: IMessageQueue;
|
||||
private modelConfigRepo: IModelConfigRepo;
|
||||
private runsLock: IRunsLock;
|
||||
private abortRegistry: IAbortRegistry;
|
||||
|
||||
constructor({
|
||||
runsRepo,
|
||||
|
|
@ -47,6 +49,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
messageQueue,
|
||||
modelConfigRepo,
|
||||
runsLock,
|
||||
abortRegistry,
|
||||
}: {
|
||||
runsRepo: IRunsRepo;
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
|
|
@ -54,6 +57,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
runsLock: IRunsLock;
|
||||
abortRegistry: IAbortRegistry;
|
||||
}) {
|
||||
this.runsRepo = runsRepo;
|
||||
this.idGenerator = idGenerator;
|
||||
|
|
@ -61,6 +65,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
this.messageQueue = messageQueue;
|
||||
this.modelConfigRepo = modelConfigRepo;
|
||||
this.runsLock = runsLock;
|
||||
this.abortRegistry = abortRegistry;
|
||||
}
|
||||
|
||||
async trigger(runId: string): Promise<void> {
|
||||
|
|
@ -68,6 +73,7 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
console.log(`unable to acquire lock on run ${runId}`);
|
||||
return;
|
||||
}
|
||||
const signal = this.abortRegistry.createForRun(runId);
|
||||
try {
|
||||
await this.bus.publish({
|
||||
runId,
|
||||
|
|
@ -75,6 +81,11 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
subflow: [],
|
||||
});
|
||||
while (true) {
|
||||
// Check for abort before each iteration
|
||||
if (signal.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
let eventCount = 0;
|
||||
const run = await this.runsRepo.fetch(runId);
|
||||
if (!run) {
|
||||
|
|
@ -84,18 +95,28 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
for (const event of run.log) {
|
||||
state.ingest(event);
|
||||
}
|
||||
for await (const event of streamAgent({
|
||||
state,
|
||||
idGenerator: this.idGenerator,
|
||||
runId,
|
||||
messageQueue: this.messageQueue,
|
||||
modelConfigRepo: this.modelConfigRepo,
|
||||
})) {
|
||||
eventCount++;
|
||||
if (event.type !== "llm-stream-event") {
|
||||
await this.runsRepo.appendEvents(runId, [event]);
|
||||
try {
|
||||
for await (const event of streamAgent({
|
||||
state,
|
||||
idGenerator: this.idGenerator,
|
||||
runId,
|
||||
messageQueue: this.messageQueue,
|
||||
modelConfigRepo: this.modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry: this.abortRegistry,
|
||||
})) {
|
||||
eventCount++;
|
||||
if (event.type !== "llm-stream-event") {
|
||||
await this.runsRepo.appendEvents(runId, [event]);
|
||||
}
|
||||
await this.bus.publish(event);
|
||||
}
|
||||
await this.bus.publish(event);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
// Abort detected — exit cleanly
|
||||
break;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// if no events, break
|
||||
|
|
@ -103,7 +124,20 @@ export class AgentRuntime implements IAgentRuntime {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit run-stopped event if aborted
|
||||
if (signal.aborted) {
|
||||
const stoppedEvent: z.infer<typeof RunEvent> = {
|
||||
runId,
|
||||
type: "run-stopped",
|
||||
reason: "user-requested",
|
||||
subflow: [],
|
||||
};
|
||||
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
|
||||
await this.bus.publish(stoppedEvent);
|
||||
}
|
||||
} finally {
|
||||
this.abortRegistry.cleanup(runId);
|
||||
await this.runsLock.release(runId);
|
||||
await this.bus.publish({
|
||||
runId,
|
||||
|
|
@ -428,6 +462,39 @@ export class AgentState {
|
|||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns tool-result messages for all pending tool calls, marking them as aborted.
|
||||
* This is called when a run is stopped so the LLM knows what happened to its tool requests.
|
||||
*/
|
||||
getAbortedToolResults(): z.infer<typeof ToolMessage>[] {
|
||||
const results: z.infer<typeof ToolMessage>[] = [];
|
||||
for (const toolCallId of Object.keys(this.pendingToolCalls)) {
|
||||
const toolCall = this.toolCallIdMap[toolCallId];
|
||||
if (toolCall) {
|
||||
results.push({
|
||||
role: "tool",
|
||||
content: JSON.stringify({ error: "Tool execution aborted" }),
|
||||
toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending state (permissions, ask-human, tool calls).
|
||||
* Used when a run is stopped.
|
||||
*/
|
||||
clearAllPending(): void {
|
||||
this.pendingToolPermissionRequests = {};
|
||||
this.pendingAskHumanRequests = {};
|
||||
// Recursively clear subflows
|
||||
for (const subflow of Object.values(this.subflowStates)) {
|
||||
subflow.clearAllPending();
|
||||
}
|
||||
}
|
||||
|
||||
finalResponse(): string {
|
||||
if (!this.lastAssistantMsg) {
|
||||
return '';
|
||||
|
|
@ -526,12 +593,16 @@ export async function* streamAgent({
|
|||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
}: {
|
||||
state: AgentState,
|
||||
idGenerator: IMonotonicallyIncreasingIdGenerator;
|
||||
runId: string;
|
||||
messageQueue: IMessageQueue;
|
||||
modelConfigRepo: IModelConfigRepo;
|
||||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
}): AsyncGenerator<z.infer<typeof RunEvent>, void, unknown> {
|
||||
const logger = new PrefixLogger(`run-${runId}-${state.agentName}`);
|
||||
|
||||
|
|
@ -552,11 +623,14 @@ export async function* streamAgent({
|
|||
const tools = await buildTools(agent);
|
||||
|
||||
// set up provider + model
|
||||
const provider = await getProvider(agent.provider);
|
||||
const model = provider.languageModel(agent.model || modelConfig.defaults.model);
|
||||
const provider = createProvider(modelConfig.provider);
|
||||
const model = provider.languageModel(modelConfig.model);
|
||||
|
||||
let loopCounter = 0;
|
||||
while (true) {
|
||||
// Check abort at the top of each iteration
|
||||
signal.throwIfAborted();
|
||||
|
||||
loopCounter++;
|
||||
const loopLogger = logger.child(`iter-${loopCounter}`);
|
||||
loopLogger.log('starting loop iteration');
|
||||
|
|
@ -598,6 +672,11 @@ export async function* streamAgent({
|
|||
}
|
||||
|
||||
// execute approved tool
|
||||
// Check abort before starting tool execution
|
||||
if (signal.aborted) {
|
||||
_logger.log('skipping, reason: aborted');
|
||||
break;
|
||||
}
|
||||
_logger.log('executing tool');
|
||||
yield* processEvent({
|
||||
runId,
|
||||
|
|
@ -616,6 +695,8 @@ export async function* streamAgent({
|
|||
runId,
|
||||
messageQueue,
|
||||
modelConfigRepo,
|
||||
signal,
|
||||
abortRegistry,
|
||||
})) {
|
||||
yield* processEvent({
|
||||
...event,
|
||||
|
|
@ -626,7 +707,7 @@ export async function* streamAgent({
|
|||
result = subflowState.finalResponse();
|
||||
}
|
||||
} else {
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments);
|
||||
result = await execTool(agent.tools![toolCall.toolName], toolCall.arguments, { runId, signal, abortRegistry });
|
||||
}
|
||||
const resultPayload = result === undefined ? null : result;
|
||||
const resultMsg: z.infer<typeof ToolMessage> = {
|
||||
|
|
@ -709,6 +790,7 @@ export async function* streamAgent({
|
|||
state.messages,
|
||||
instructionsWithDateTime,
|
||||
tools,
|
||||
signal,
|
||||
)) {
|
||||
// Only log significant events (not text-delta to reduce noise)
|
||||
if (event.type !== 'text-delta') {
|
||||
|
|
@ -791,6 +873,7 @@ async function* streamLlm(
|
|||
messages: z.infer<typeof MessageList>,
|
||||
instructions: string,
|
||||
tools: ToolSet,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<z.infer<typeof LlmStepStreamEvent>, void, unknown> {
|
||||
const { fullStream } = streamText({
|
||||
model,
|
||||
|
|
@ -798,8 +881,11 @@ async function* streamLlm(
|
|||
system: instructions,
|
||||
tools,
|
||||
stopWhen: stepCountIs(1),
|
||||
abortSignal: signal,
|
||||
});
|
||||
for await (const event of fullStream) {
|
||||
// Check abort on every chunk for responsiveness
|
||||
signal?.throwIfAborted();
|
||||
// console.log("\n\n\t>>>>\t\tstream event", JSON.stringify(event));
|
||||
switch (event.type) {
|
||||
case "reasoning-start":
|
||||
|
|
|
|||
|
|
@ -26,8 +26,12 @@ Rowboat is an agentic assistant for everyday work - emails, meetings, projects,
|
|||
|
||||
**Meeting Prep:** When users ask you to prepare for a meeting, prep for a call, or brief them on attendees, load the \`meeting-prep\` skill first. It provides structured guidance for gathering context about attendees from the knowledge base and creating useful meeting briefs.
|
||||
|
||||
**Create Presentations:** When users ask you to create a presentation, slide deck, pitch deck, or PDF slides, load the \`create-presentations\` skill first. It provides structured guidance for generating PDF presentations using context from the knowledge base.
|
||||
|
||||
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. This is required for any document creation or editing task. The skill provides structured guidance for creating, editing, and refining documents in the knowledge base.
|
||||
|
||||
**Slack:** When users ask about Slack messages, want to send messages to teammates, check channel conversations, or find someone on Slack, load the \`slack\` skill. You can send messages, view channel history, search conversations, and find users. Always check if Slack is connected first with \`slack-checkConnection\`, and always show message drafts to the user before sending.
|
||||
|
||||
## Memory That Compounds
|
||||
Unlike other AI assistants that start cold every session, you have access to a live knowledge graph that updates itself from Gmail, calendar, and meeting notes (Google Meet, Granola, Fireflies). This isn't just summaries - it's structured extraction of decisions, commitments, open questions, and context, routed to long-lived notes for each person, project, and topic.
|
||||
|
||||
|
|
@ -148,29 +152,31 @@ When a user asks for ANY task that might require external capabilities (web sear
|
|||
- NEVER ask what OS the user is on - they are on macOS.
|
||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||
|
||||
**Command Approval:**
|
||||
- Approved shell commands are listed in \`~/.rowboat/config/security.json\`. Read this file to see what commands are allowed.
|
||||
- Only use commands from the approved list. Commands not in the list will be blocked.
|
||||
- If you cannot accomplish a task with the approved commands, tell the user which command you need and ask them to add it to \`security.json\`.
|
||||
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
|
||||
## Builtin Tools vs Shell Commands
|
||||
|
||||
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require security allowlist entries:
|
||||
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval:
|
||||
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations
|
||||
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search
|
||||
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management
|
||||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
|
||||
- \`loadSkill\` - Skill loading
|
||||
- \`slack-checkConnection\`, \`slack-listAvailableTools\`, \`slack-executeAction\` - Slack integration (requires Slack to be connected via Composio). Use \`slack-listAvailableTools\` first to discover available tool slugs, then \`slack-executeAction\` to execute them.
|
||||
|
||||
These tools work directly and are NOT filtered by \`.rowboat/config/security.json\`.
|
||||
**Prefer these tools whenever possible** — they work instantly with zero friction. For file operations inside \`~/.rowboat/\`, always use these instead of \`executeCommand\`.
|
||||
|
||||
**Shell commands via \`executeCommand\`:**
|
||||
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`~/.rowboat/config/security.json\` and run immediately.
|
||||
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user — this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
|
||||
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
|
||||
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
|
||||
- Always confirm with the user before executing commands that modify files outside \`~/.rowboat/\` (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?").
|
||||
|
||||
**CRITICAL: MCP Server Configuration**
|
||||
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
|
||||
- NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers
|
||||
- Invalid MCP configs will prevent the agent from starting with validation errors
|
||||
|
||||
**Only \`executeCommand\` (shell/bash commands) is filtered** by the security allowlist. If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
|
||||
|
||||
The security allowlist in \`security.json\` only applies to shell commands executed via \`executeCommand\`, not to Rowboat's internal builtin tools.`;
|
||||
Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.`;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
export const skill = String.raw`
|
||||
# PDF Presentation Generator Skill
|
||||
|
||||
## When to Use
|
||||
|
||||
Activate when the user wants to create presentations, slide decks, or pitch decks.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Check ~/.rowboat/knowledge/ for relevant context about the company, product, team, etc.
|
||||
2. Ensure Playwright is installed: 'npm install playwright && npx playwright install chromium'
|
||||
3. Create an HTML file (e.g., /tmp/presentation.html) with slides (1280x720px each)
|
||||
4. Create a Node.js script to convert HTML to PDF:
|
||||
|
||||
~~~javascript
|
||||
// save as /tmp/convert.js
|
||||
const { chromium } = require('playwright');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('file:///tmp/presentation.html', { waitUntil: 'networkidle' });
|
||||
await page.pdf({
|
||||
path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'),
|
||||
width: '1280px',
|
||||
height: '720px',
|
||||
printBackground: true,
|
||||
});
|
||||
await browser.close();
|
||||
console.log('Done: ~/Desktop/presentation.pdf');
|
||||
})();
|
||||
~~~
|
||||
|
||||
5. Run it: 'node /tmp/convert.js'
|
||||
6. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf"
|
||||
|
||||
Do NOT show HTML code to the user. Do NOT explain how to export. Just create the PDF and deliver it.
|
||||
|
||||
## PDF Export Rules
|
||||
|
||||
**These rules prevent rendering issues in PDF. Violating them causes overlapping rectangles and broken layouts.**
|
||||
|
||||
1. **No layered elements** - Never create separate elements for backgrounds or shadows. Style content elements directly.
|
||||
2. **No box-shadow** - Use borders instead: \`border: 1px solid #e5e7eb\`
|
||||
3. **Bullets via CSS only** - Use \`li::before\` pseudo-elements, not separate DOM elements
|
||||
4. **Content must fit** - Slides are 1280x720px with 60px padding. Safe area is 1160x600px. Use \`overflow: hidden\`.
|
||||
|
||||
## Required CSS
|
||||
|
||||
~~~css
|
||||
@page { size: 1280px 720px; margin: 0; }
|
||||
html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
|
||||
.slide {
|
||||
width: 1280px;
|
||||
height: 720px;
|
||||
padding: 60px;
|
||||
overflow: hidden;
|
||||
page-break-after: always;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
.slide:last-child { page-break-after: auto; }
|
||||
~~~
|
||||
|
||||
## Playwright Export
|
||||
|
||||
~~~typescript
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('file://' + htmlPath, { waitUntil: 'networkidle' });
|
||||
await page.pdf({
|
||||
path: '~/Desktop/presentation.pdf',
|
||||
width: '1280px',
|
||||
height: '720px',
|
||||
printBackground: true,
|
||||
});
|
||||
await browser.close();
|
||||
~~~
|
||||
`;
|
||||
|
||||
export default skill;
|
||||
|
|
@ -7,17 +7,17 @@ import draftEmailsSkill from "./draft-emails/skill.js";
|
|||
import mcpIntegrationSkill from "./mcp-integration/skill.js";
|
||||
import meetingPrepSkill from "./meeting-prep/skill.js";
|
||||
import organizeFilesSkill from "./organize-files/skill.js";
|
||||
import slackSkill from "./slack/skill.js";
|
||||
import workflowAuthoringSkill from "./workflow-authoring/skill.js";
|
||||
import createPresentationsSkill from "./create-presentations/skill.js";
|
||||
import workflowRunOpsSkill from "./workflow-run-ops/skill.js";
|
||||
|
||||
const CURRENT_FILE = fileURLToPath(import.meta.url);
|
||||
const CURRENT_DIR = path.dirname(CURRENT_FILE);
|
||||
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CATALOG_PREFIX = "src/application/assistant/skills";
|
||||
|
||||
type SkillDefinition = {
|
||||
id: string;
|
||||
id: string; // Also used as folder name
|
||||
title: string;
|
||||
folder: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
};
|
||||
|
|
@ -29,66 +29,69 @@ type ResolvedSkill = {
|
|||
};
|
||||
|
||||
const definitions: SkillDefinition[] = [
|
||||
{
|
||||
id: "create-presentations",
|
||||
title: "Create Presentations",
|
||||
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
|
||||
content: createPresentationsSkill,
|
||||
},
|
||||
{
|
||||
id: "doc-collab",
|
||||
title: "Document Collaboration",
|
||||
folder: "doc-collab",
|
||||
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
|
||||
content: docCollabSkill,
|
||||
},
|
||||
{
|
||||
id: "draft-emails",
|
||||
title: "Draft Emails",
|
||||
folder: "draft-emails",
|
||||
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
|
||||
content: draftEmailsSkill,
|
||||
},
|
||||
{
|
||||
id: "meeting-prep",
|
||||
title: "Meeting Prep",
|
||||
folder: "meeting-prep",
|
||||
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
|
||||
content: meetingPrepSkill,
|
||||
},
|
||||
{
|
||||
id: "organize-files",
|
||||
title: "Organize Files",
|
||||
folder: "organize-files",
|
||||
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
|
||||
content: organizeFilesSkill,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
title: "Slack Integration",
|
||||
summary: "Send Slack messages, view channel history, search conversations, find users, and manage team communication.",
|
||||
content: slackSkill,
|
||||
},
|
||||
{
|
||||
id: "workflow-authoring",
|
||||
title: "Workflow Authoring",
|
||||
folder: "workflow-authoring",
|
||||
summary: "Creating or editing workflows/agents, validating schema rules, and keeping filenames aligned with JSON ids.",
|
||||
content: workflowAuthoringSkill,
|
||||
},
|
||||
{
|
||||
id: "builtin-tools",
|
||||
title: "Builtin Tools Reference",
|
||||
folder: "builtin-tools",
|
||||
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
|
||||
content: builtinToolsSkill,
|
||||
},
|
||||
{
|
||||
id: "mcp-integration",
|
||||
title: "MCP Integration Guidance",
|
||||
folder: "mcp-integration",
|
||||
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
|
||||
content: mcpIntegrationSkill,
|
||||
},
|
||||
{
|
||||
id: "deletion-guardrails",
|
||||
title: "Deletion Guardrails",
|
||||
folder: "deletion-guardrails",
|
||||
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
|
||||
content: deletionGuardrailsSkill,
|
||||
},
|
||||
{
|
||||
id: "workflow-run-ops",
|
||||
title: "Workflow Run Operations",
|
||||
folder: "workflow-run-ops",
|
||||
summary: "Commands that list workflow runs, inspect paused executions, or manage cron schedules for workflows.",
|
||||
content: workflowRunOpsSkill,
|
||||
},
|
||||
|
|
@ -96,7 +99,7 @@ const definitions: SkillDefinition[] = [
|
|||
|
||||
const skillEntries = definitions.map((definition) => ({
|
||||
...definition,
|
||||
catalogPath: `${CATALOG_PREFIX}/${definition.folder}/skill.ts`,
|
||||
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
|
||||
}));
|
||||
|
||||
const catalogSections = skillEntries.map((entry) => [
|
||||
|
|
@ -146,8 +149,8 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
|
|||
};
|
||||
|
||||
for (const entry of skillEntries) {
|
||||
const absoluteTs = path.join(CURRENT_DIR, entry.folder, "skill.ts");
|
||||
const absoluteJs = path.join(CURRENT_DIR, entry.folder, "skill.js");
|
||||
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
|
||||
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
|
||||
const resolvedEntry: ResolvedSkill = {
|
||||
id: entry.id,
|
||||
catalogPath: entry.catalogPath,
|
||||
|
|
@ -156,14 +159,13 @@ for (const entry of skillEntries) {
|
|||
|
||||
const baseAliases = [
|
||||
entry.id,
|
||||
entry.folder,
|
||||
`${entry.folder}/skill`,
|
||||
`${entry.folder}/skill.ts`,
|
||||
`${entry.folder}/skill.js`,
|
||||
`skills/${entry.folder}/skill.ts`,
|
||||
`skills/${entry.folder}/skill.js`,
|
||||
`${CATALOG_PREFIX}/${entry.folder}/skill.ts`,
|
||||
`${CATALOG_PREFIX}/${entry.folder}/skill.js`,
|
||||
`${entry.id}/skill`,
|
||||
`${entry.id}/skill.ts`,
|
||||
`${entry.id}/skill.js`,
|
||||
`skills/${entry.id}/skill.ts`,
|
||||
`skills/${entry.id}/skill.js`,
|
||||
`${CATALOG_PREFIX}/${entry.id}/skill.ts`,
|
||||
`${CATALOG_PREFIX}/${entry.id}/skill.js`,
|
||||
absoluteTs,
|
||||
absoluteJs,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -2,7 +2,7 @@ import { z, ZodType } from "zod";
|
|||
import * as path from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { glob } from "glob";
|
||||
import { executeCommand } from "./command-executor.js";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
|
|
@ -11,17 +11,247 @@ import { McpServerDefinition } from "@x/shared/dist/mcp.js";
|
|||
import * as workspace from "../../workspace/workspace.js";
|
||||
import { IAgentsRepo } from "../../agents/repo.js";
|
||||
import { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
import { executeAction as executeComposioAction, isConfigured as isComposioConfigured, listToolkitTools } from "../../composio/client.js";
|
||||
import { slackToolCatalog } from "../assistant/skills/slack/tool-catalog.js";
|
||||
import type { ToolContext } from "./exec-tool.js";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const BuiltinToolsSchema = z.record(z.string(), z.object({
|
||||
description: z.string(),
|
||||
inputSchema: z.custom<ZodType>(),
|
||||
execute: z.function({
|
||||
input: z.any(),
|
||||
input: z.any(), // (input, ctx?) => Promise<any>
|
||||
output: z.promise(z.any()),
|
||||
}),
|
||||
}));
|
||||
|
||||
type SlackToolHint = {
|
||||
search?: string;
|
||||
patterns: string[];
|
||||
fallbackSlugs?: string[];
|
||||
preferSlugIncludes?: string[];
|
||||
excludePatterns?: string[];
|
||||
minScore?: number;
|
||||
};
|
||||
|
||||
const slackToolHints: Record<string, SlackToolHint> = {
|
||||
sendMessage: {
|
||||
search: "message",
|
||||
patterns: ["send", "message", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEND_MESSAGE",
|
||||
"SLACK_SENDS_A_MESSAGE_TO_A_SLACK_CHANNEL",
|
||||
"SLACK_SEND_A_MESSAGE",
|
||||
],
|
||||
},
|
||||
listConversations: {
|
||||
search: "conversation",
|
||||
patterns: ["list", "conversation", "channel"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_CONVERSATIONS",
|
||||
"SLACK_LIST_ALL_CHANNELS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_CHANNELS_WITH_VARIOUS_FILTERS",
|
||||
"SLACK_LIST_CHANNELS",
|
||||
"SLACK_LIST_CHANNEL",
|
||||
],
|
||||
preferSlugIncludes: ["list", "conversation"],
|
||||
minScore: 2,
|
||||
},
|
||||
getConversationHistory: {
|
||||
search: "history",
|
||||
patterns: ["history", "conversation", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
"SLACK_FETCHES_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CONVERSATION_HISTORY",
|
||||
"SLACK_GET_CHANNEL_HISTORY",
|
||||
],
|
||||
preferSlugIncludes: ["history"],
|
||||
minScore: 2,
|
||||
},
|
||||
listUsers: {
|
||||
search: "user",
|
||||
patterns: ["list", "user"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_LIST_ALL_USERS",
|
||||
"SLACK_LIST_ALL_SLACK_TEAM_USERS_WITH_PAGINATION",
|
||||
"SLACK_LIST_USERS",
|
||||
"SLACK_GET_USERS",
|
||||
"SLACK_USERS_LIST",
|
||||
],
|
||||
preferSlugIncludes: ["list", "user"],
|
||||
excludePatterns: ["find", "by name", "by email", "by_email", "by_name", "lookup", "profile", "info"],
|
||||
minScore: 2,
|
||||
},
|
||||
getUserInfo: {
|
||||
search: "user",
|
||||
patterns: ["user", "info", "profile"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_GET_USER_INFO",
|
||||
"SLACK_GET_USER",
|
||||
"SLACK_USER_INFO",
|
||||
],
|
||||
preferSlugIncludes: ["user", "info"],
|
||||
minScore: 1,
|
||||
},
|
||||
searchMessages: {
|
||||
search: "search",
|
||||
patterns: ["search", "message"],
|
||||
fallbackSlugs: [
|
||||
"SLACK_SEARCH_FOR_MESSAGES_WITH_QUERY",
|
||||
"SLACK_SEARCH_MESSAGES",
|
||||
"SLACK_SEARCH_MESSAGE",
|
||||
],
|
||||
preferSlugIncludes: ["search"],
|
||||
minScore: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const slackToolSlugCache = new Map<string, string>();
|
||||
|
||||
const slackToolSlugOverrides: Partial<Record<keyof typeof slackToolHints, string>> = {
|
||||
sendMessage: "SLACK_SEND_MESSAGE",
|
||||
listConversations: "SLACK_LIST_CONVERSATIONS",
|
||||
getConversationHistory: "SLACK_FETCH_CONVERSATION_HISTORY",
|
||||
listUsers: "SLACK_LIST_ALL_USERS",
|
||||
getUserInfo: "SLACK_RETRIEVE_DETAILED_USER_INFORMATION",
|
||||
searchMessages: "SLACK_SEARCH_MESSAGES",
|
||||
};
|
||||
|
||||
const compactObject = (input: Record<string, unknown>) =>
|
||||
Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
||||
|
||||
type SlackToolResult = { success: boolean; data?: unknown; error?: string };
|
||||
|
||||
/** Helper to execute a Slack tool with consistent account validation and error handling */
|
||||
async function executeSlackTool(
|
||||
hintKey: keyof typeof slackToolHints,
|
||||
params: Record<string, unknown>
|
||||
): Promise<SlackToolResult> {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
try {
|
||||
const toolSlug = await resolveSlackToolSlug(hintKey);
|
||||
return await executeComposioAction(toolSlug, account.id, compactObject(params));
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeSlackTool = (tool: { slug: string; name?: string; description?: string }) =>
|
||||
`${tool.slug} ${tool.name || ""} ${tool.description || ""}`.toLowerCase();
|
||||
|
||||
const scoreSlackTool = (tool: { slug: string; name?: string; description?: string }, patterns: string[]) => {
|
||||
const slug = tool.slug.toLowerCase();
|
||||
const name = (tool.name || "").toLowerCase();
|
||||
const description = (tool.description || "").toLowerCase();
|
||||
|
||||
let score = 0;
|
||||
for (const pattern of patterns) {
|
||||
const needle = pattern.toLowerCase();
|
||||
if (slug.includes(needle)) score += 3;
|
||||
if (name.includes(needle)) score += 2;
|
||||
if (description.includes(needle)) score += 1;
|
||||
}
|
||||
return score;
|
||||
};
|
||||
|
||||
const pickSlackTool = (
|
||||
tools: Array<{ slug: string; name?: string; description?: string }>,
|
||||
hint: SlackToolHint,
|
||||
) => {
|
||||
let candidates = tools;
|
||||
|
||||
if (hint.excludePatterns && hint.excludePatterns.length > 0) {
|
||||
candidates = candidates.filter((tool) => {
|
||||
const haystack = normalizeSlackTool(tool);
|
||||
return !hint.excludePatterns!.some((pattern) => haystack.includes(pattern.toLowerCase()));
|
||||
});
|
||||
}
|
||||
|
||||
if (hint.preferSlugIncludes && hint.preferSlugIncludes.length > 0) {
|
||||
const preferred = candidates.filter((tool) =>
|
||||
hint.preferSlugIncludes!.every((pattern) => tool.slug.toLowerCase().includes(pattern.toLowerCase()))
|
||||
);
|
||||
if (preferred.length > 0) {
|
||||
candidates = preferred;
|
||||
}
|
||||
}
|
||||
|
||||
let best: { slug: string; name?: string; description?: string } | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const tool of candidates) {
|
||||
const score = scoreSlackTool(tool, hint.patterns);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (!best || (hint.minScore !== undefined && bestScore < hint.minScore)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return best;
|
||||
};
|
||||
|
||||
const resolveSlackToolSlug = async (hintKey: keyof typeof slackToolHints) => {
|
||||
const cached = slackToolSlugCache.get(hintKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const hint = slackToolHints[hintKey];
|
||||
|
||||
const override = slackToolSlugOverrides[hintKey];
|
||||
if (override && slackToolCatalog.some((tool) => tool.slug === override)) {
|
||||
slackToolSlugCache.set(hintKey, override);
|
||||
return override;
|
||||
}
|
||||
const resolveFromTools = (tools: Array<{ slug: string; name?: string; description?: string }>) => {
|
||||
if (hint.fallbackSlugs && hint.fallbackSlugs.length > 0) {
|
||||
const fallbackSet = new Set(hint.fallbackSlugs.map((slug) => slug.toLowerCase()));
|
||||
const fallback = tools.find((tool) => fallbackSet.has(tool.slug.toLowerCase()));
|
||||
if (fallback) return fallback.slug;
|
||||
}
|
||||
|
||||
const best = pickSlackTool(tools, hint);
|
||||
return best?.slug || null;
|
||||
};
|
||||
|
||||
const initialTools = slackToolCatalog;
|
||||
|
||||
if (!initialTools.length) {
|
||||
throw new Error("No Slack tools returned from Composio");
|
||||
}
|
||||
|
||||
const initialSlug = resolveFromTools(initialTools);
|
||||
if (initialSlug) {
|
||||
slackToolSlugCache.set(hintKey, initialSlug);
|
||||
return initialSlug;
|
||||
}
|
||||
|
||||
const allSlug = resolveFromTools(slackToolCatalog);
|
||||
|
||||
if (!allSlug) {
|
||||
const fallback = await listToolkitTools("slack", hint.search || null);
|
||||
const fallbackSlug = resolveFromTools(fallback.items || []);
|
||||
if (!fallbackSlug) {
|
||||
throw new Error(`Unable to resolve Slack tool for ${hintKey}. Try slack-listAvailableTools.`);
|
||||
}
|
||||
slackToolSlugCache.set(hintKey, fallbackSlug);
|
||||
return fallbackSlug;
|
||||
}
|
||||
|
||||
slackToolSlugCache.set(hintKey, allSlug);
|
||||
return allSlug;
|
||||
};
|
||||
|
||||
export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
||||
loadSkill: {
|
||||
description: "Load a Rowboat skill definition into context by fetching its guidance string",
|
||||
|
|
@ -609,17 +839,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
description: 'Execute a shell command and return the output. Use this to run bash/shell commands.',
|
||||
inputSchema: z.object({
|
||||
command: z.string().describe('The shell command to execute (e.g., "ls -la", "cat file.txt")'),
|
||||
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root)'),
|
||||
cwd: z.string().optional().describe('Working directory to execute the command in (defaults to workspace root). You do not need to set this unless absolutely necessary.'),
|
||||
}),
|
||||
execute: async ({ command, cwd }: { command: string, cwd?: string }) => {
|
||||
execute: async ({ command, cwd }: { command: string, cwd?: string }, ctx?: ToolContext) => {
|
||||
try {
|
||||
const rootDir = path.resolve(WorkDir);
|
||||
const workingDir = cwd ? path.resolve(rootDir, cwd) : rootDir;
|
||||
const rootPrefix = rootDir.endsWith(path.sep)
|
||||
? rootDir
|
||||
: `${rootDir}${path.sep}`;
|
||||
|
||||
// TODO: Re-enable this check
|
||||
// const rootPrefix = rootDir.endsWith(path.sep)
|
||||
// ? rootDir
|
||||
// : `${rootDir}${path.sep}`;
|
||||
// if (workingDir !== rootDir && !workingDir.startsWith(rootPrefix)) {
|
||||
// return {
|
||||
// success: false,
|
||||
|
|
@ -629,8 +859,32 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
// };
|
||||
// }
|
||||
|
||||
// Use abortable version when we have a signal
|
||||
if (ctx?.signal) {
|
||||
const { promise, process: proc } = executeCommandAbortable(command, {
|
||||
cwd: workingDir,
|
||||
signal: ctx.signal,
|
||||
});
|
||||
|
||||
// Register process with abort registry for force-kill
|
||||
ctx.abortRegistry.registerProcess(ctx.runId, proc);
|
||||
|
||||
const result = await promise;
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0 && !result.wasAborted,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
wasAborted: result.wasAborted,
|
||||
command,
|
||||
workingDir,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to original for backward compatibility
|
||||
const result = await executeCommand(command, { cwd: workingDir });
|
||||
|
||||
|
||||
return {
|
||||
success: result.exitCode === 0,
|
||||
stdout: result.stdout,
|
||||
|
|
@ -648,4 +902,162 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ============================================================================
|
||||
// Slack Tools (via Composio)
|
||||
// ============================================================================
|
||||
|
||||
'slack-checkConnection': {
|
||||
description: 'Check if Slack is connected and ready to use. Use this before other Slack operations.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!isComposioConfigured()) {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Composio is not configured. Please set up your Composio API key first.',
|
||||
};
|
||||
}
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return {
|
||||
connected: false,
|
||||
error: 'Slack is not connected. Please connect Slack from the settings.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
accountId: account.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listAvailableTools': {
|
||||
description: 'List available Slack tools from Composio. Use this to discover the correct tool slugs before executing actions. Call this first if other Slack tools return errors.',
|
||||
inputSchema: z.object({
|
||||
search: z.string().optional().describe('Optional search query to filter tools (e.g., "message", "channel", "user")'),
|
||||
}),
|
||||
execute: async ({ search }: { search?: string }) => {
|
||||
if (!isComposioConfigured()) {
|
||||
return { success: false, error: 'Composio is not configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await listToolkitTools('slack', search || null);
|
||||
return {
|
||||
success: true,
|
||||
tools: result.items,
|
||||
count: result.items.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-executeAction': {
|
||||
description: 'Execute a Slack action by its Composio tool slug. Use slack-listAvailableTools first to discover correct slugs. Pass the exact slug and the required input parameters.',
|
||||
inputSchema: z.object({
|
||||
toolSlug: z.string().describe('The exact Composio tool slug (e.g., "SLACKBOT_SEND_A_MESSAGE_TO_A_SLACK_CHANNEL")'),
|
||||
input: z.record(z.string(), z.unknown()).describe('Input parameters for the tool (check the tool description for required fields)'),
|
||||
}),
|
||||
execute: async ({ toolSlug, input }: { toolSlug: string; input: Record<string, unknown> }) => {
|
||||
const account = composioAccountsRepo.getAccount('slack');
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
return { success: false, error: 'Slack is not connected' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await executeComposioAction(toolSlug, account.id, input);
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'slack-sendMessage': {
|
||||
description: 'Send a message to a Slack channel or user. Requires channel ID (starts with C for channels, D for DMs) or user ID.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID (e.g., C01234567) or user ID (e.g., U01234567) to send the message to'),
|
||||
text: z.string().describe('The message text to send'),
|
||||
}),
|
||||
execute: async ({ channel, text }: { channel: string; text: string }) => {
|
||||
return executeSlackTool("sendMessage", { channel, text });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listChannels': {
|
||||
description: 'List Slack channels the user has access to. Returns channel IDs and names.',
|
||||
inputSchema: z.object({
|
||||
types: z.string().optional().describe('Comma-separated channel types: public_channel, private_channel, mpim, im (default: public_channel,private_channel)'),
|
||||
limit: z.number().optional().describe('Maximum number of channels to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ types, limit }: { types?: string; limit?: number }) => {
|
||||
return executeSlackTool("listConversations", {
|
||||
types: types || "public_channel,private_channel",
|
||||
limit: limit ?? 100,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getChannelHistory': {
|
||||
description: 'Get recent messages from a Slack channel. Returns message history with timestamps and user IDs.',
|
||||
inputSchema: z.object({
|
||||
channel: z.string().describe('Channel ID to get history from (e.g., C01234567)'),
|
||||
limit: z.number().optional().describe('Maximum number of messages to return (default: 20, max: 100)'),
|
||||
}),
|
||||
execute: async ({ channel, limit }: { channel: string; limit?: number }) => {
|
||||
return executeSlackTool("getConversationHistory", {
|
||||
channel,
|
||||
limit: limit !== undefined ? Math.min(limit, 100) : 20,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
'slack-listUsers': {
|
||||
description: 'List users in the Slack workspace. Returns user IDs, names, and profile info.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of users to return (default: 100)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listUsers", { limit: limit ?? 100 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getUserInfo': {
|
||||
description: 'Get detailed information about a specific Slack user by their user ID.',
|
||||
inputSchema: z.object({
|
||||
user: z.string().describe('User ID to get info for (e.g., U01234567)'),
|
||||
}),
|
||||
execute: async ({ user }: { user: string }) => {
|
||||
return executeSlackTool("getUserInfo", { user });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-searchMessages': {
|
||||
description: 'Search for messages in Slack. Find messages containing specific text across channels.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('Search query text'),
|
||||
count: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
}),
|
||||
execute: async ({ query, count }: { query: string; count?: number }) => {
|
||||
return executeSlackTool("searchMessages", { query, count: count ?? 20 });
|
||||
},
|
||||
},
|
||||
|
||||
'slack-getDirectMessages': {
|
||||
description: 'List direct message (DM) channels. Returns IDs of DM conversations with other users.',
|
||||
inputSchema: z.object({
|
||||
limit: z.number().optional().describe('Maximum number of DM channels to return (default: 50)'),
|
||||
}),
|
||||
execute: async ({ limit }: { limit?: number }) => {
|
||||
return executeSlackTool("listConversations", { types: "im", limit: limit ?? 50 });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { exec, execSync } from 'child_process';
|
||||
import { exec, execSync, spawn, ChildProcess } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getSecurityAllowList } from '../../config/security.js';
|
||||
|
||||
|
|
@ -110,6 +110,159 @@ export async function executeCommand(
|
|||
}
|
||||
}
|
||||
|
||||
export interface AbortableCommandResult extends CommandResult {
|
||||
wasAborted: boolean;
|
||||
}
|
||||
|
||||
const SIGKILL_GRACE_MS = 200;
|
||||
|
||||
/**
|
||||
* Kill a process tree using negative PID (process group kill on Unix).
|
||||
* Falls back to direct kill if group kill fails.
|
||||
*/
|
||||
function killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (!proc.pid || proc.killed) return;
|
||||
try {
|
||||
// Negative PID kills the entire process group (Unix)
|
||||
process.kill(-proc.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command with abort support.
|
||||
* Uses spawn with detached=true to create a process group for proper tree killing.
|
||||
* Returns both the promise and the child process handle.
|
||||
*/
|
||||
export function executeCommandAbortable(
|
||||
command: string,
|
||||
options?: {
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
maxBuffer?: number;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): { promise: Promise<AbortableCommandResult>; process: ChildProcess } {
|
||||
// Check if already aborted before spawning
|
||||
if (options?.signal?.aborted) {
|
||||
// Return a dummy process and a resolved result
|
||||
const dummyProc = spawn('true', { shell: true });
|
||||
dummyProc.kill();
|
||||
return {
|
||||
process: dummyProc,
|
||||
promise: Promise.resolve({
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: 130,
|
||||
wasAborted: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const proc = spawn(command, [], {
|
||||
shell: '/bin/sh',
|
||||
cwd: options?.cwd,
|
||||
detached: process.platform !== 'win32', // Create process group on Unix
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
const promise = new Promise<AbortableCommandResult>((resolve) => {
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let wasAborted = false;
|
||||
let exited = false;
|
||||
|
||||
// Collect output
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
||||
if (stdout.length < maxBuffer) {
|
||||
stdout += chunk.toString();
|
||||
}
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
const maxBuffer = options?.maxBuffer || 1024 * 1024;
|
||||
if (stderr.length < maxBuffer) {
|
||||
stderr += chunk.toString();
|
||||
}
|
||||
});
|
||||
|
||||
// Abort handler
|
||||
const abortHandler = () => {
|
||||
wasAborted = true;
|
||||
killProcessTree(proc, 'SIGTERM');
|
||||
// Force kill after grace period
|
||||
setTimeout(() => {
|
||||
if (!exited) {
|
||||
killProcessTree(proc, 'SIGKILL');
|
||||
}
|
||||
}, SIGKILL_GRACE_MS);
|
||||
};
|
||||
|
||||
if (options?.signal) {
|
||||
options.signal.addEventListener('abort', abortHandler, { once: true });
|
||||
}
|
||||
|
||||
// Timeout handler
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
if (options?.timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
wasAborted = true;
|
||||
killProcessTree(proc, 'SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (!exited) {
|
||||
killProcessTree(proc, 'SIGKILL');
|
||||
}
|
||||
}, SIGKILL_GRACE_MS);
|
||||
}, options.timeout);
|
||||
}
|
||||
|
||||
proc.once('exit', (code) => {
|
||||
exited = true;
|
||||
// Cleanup listeners
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (wasAborted) {
|
||||
stdout += '\n\n(Command was aborted)';
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
wasAborted,
|
||||
});
|
||||
});
|
||||
|
||||
proc.once('error', (err) => {
|
||||
exited = true;
|
||||
if (options?.signal) {
|
||||
options.signal.removeEventListener('abort', abortHandler);
|
||||
}
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
resolve({
|
||||
stdout: '',
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
wasAborted,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { promise, process: proc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a command synchronously (blocking)
|
||||
* Use with caution - prefer executeCommand for async execution
|
||||
|
|
|
|||
|
|
@ -2,22 +2,36 @@ import { ToolAttachment } from "@x/shared/dist/agent.js";
|
|||
import { z } from "zod";
|
||||
import { BuiltinTools } from "./builtin-tools.js";
|
||||
import { executeTool } from "../../mcp/mcp.js";
|
||||
import { IAbortRegistry } from "../../runs/abort-registry.js";
|
||||
|
||||
/**
|
||||
* Context passed to every tool execution, providing abort signal and run metadata.
|
||||
*/
|
||||
export interface ToolContext {
|
||||
runId: string;
|
||||
signal: AbortSignal;
|
||||
abortRegistry: IAbortRegistry;
|
||||
}
|
||||
|
||||
async function execMcpTool(agentTool: z.infer<typeof ToolAttachment> & { type: "mcp" }, input: Record<string, unknown>): Promise<unknown> {
|
||||
const result = await executeTool(agentTool.mcpServerName, agentTool.name, input);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: Record<string, unknown>): Promise<unknown> {
|
||||
export async function execTool(agentTool: z.infer<typeof ToolAttachment>, input: Record<string, unknown>, ctx?: ToolContext): Promise<unknown> {
|
||||
// Check abort before starting any tool
|
||||
ctx?.signal.throwIfAborted();
|
||||
|
||||
switch (agentTool.type) {
|
||||
case "mcp":
|
||||
// MCP tools: let complete on graceful stop (most are fast)
|
||||
return execMcpTool(agentTool, input);
|
||||
case "builtin": {
|
||||
const builtinTool = BuiltinTools[agentTool.name];
|
||||
if (!builtinTool || !builtinTool.execute) {
|
||||
throw new Error(`Unsupported builtin tool: ${agentTool.name}`);
|
||||
}
|
||||
return builtinTool.execute(input);
|
||||
return builtinTool.execute(input, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/x/packages/core/src/auth/provider-client-id.ts
Normal file
23
apps/x/packages/core/src/auth/provider-client-id.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
type ProviderClientIdOverrides = Map<string, string>;
|
||||
|
||||
const providerClientIdOverrides: ProviderClientIdOverrides = new Map();
|
||||
|
||||
export function setProviderClientIdOverride(provider: string, clientId: string): void {
|
||||
const trimmed = clientId.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
providerClientIdOverrides.set(provider, trimmed);
|
||||
}
|
||||
|
||||
export function getProviderClientIdOverride(provider: string): string | undefined {
|
||||
return providerClientIdOverrides.get(provider);
|
||||
}
|
||||
|
||||
export function hasProviderClientIdOverride(provider: string): boolean {
|
||||
return providerClientIdOverrides.has(provider);
|
||||
}
|
||||
|
||||
export function clearProviderClientIdOverride(provider: string): void {
|
||||
providerClientIdOverrides.delete(provider);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
359
apps/x/packages/core/src/composio/client.ts
Normal file
359
apps/x/packages/core/src/composio/client.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { z } from "zod";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Composio } from "@composio/core";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import {
|
||||
ZAuthConfig,
|
||||
ZConnectedAccount,
|
||||
ZCreateAuthConfigRequest,
|
||||
ZCreateAuthConfigResponse,
|
||||
ZCreateConnectedAccountRequest,
|
||||
ZCreateConnectedAccountResponse,
|
||||
ZDeleteOperationResponse,
|
||||
ZErrorResponse,
|
||||
ZExecuteActionResponse,
|
||||
ZListResponse,
|
||||
ZToolkit,
|
||||
} from "./types.js";
|
||||
|
||||
const BASE_URL = 'https://backend.composio.dev/api/v3';
|
||||
const CONFIG_FILE = path.join(WorkDir, 'config', 'composio.json');
|
||||
|
||||
// Composio SDK client (lazily initialized)
|
||||
let composioClient: Composio | null = null;
|
||||
|
||||
function getComposioClient(): Composio {
|
||||
if (composioClient) {
|
||||
return composioClient;
|
||||
}
|
||||
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
composioClient = new Composio({ apiKey });
|
||||
return composioClient;
|
||||
}
|
||||
|
||||
function resetComposioClient(): void {
|
||||
composioClient = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration schema for Composio
|
||||
*/
|
||||
const ZComposioConfig = z.object({
|
||||
apiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
type ComposioConfig = z.infer<typeof ZComposioConfig>;
|
||||
|
||||
/**
|
||||
* Load Composio configuration
|
||||
*/
|
||||
function loadConfig(): ComposioConfig {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const data = fs.readFileSync(CONFIG_FILE, 'utf-8');
|
||||
return ZComposioConfig.parse(JSON.parse(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Composio] Failed to load config:', error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Composio configuration
|
||||
*/
|
||||
export function saveConfig(config: ComposioConfig): void {
|
||||
const dir = path.dirname(CONFIG_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Composio API key
|
||||
*/
|
||||
export function getApiKey(): string | null {
|
||||
const config = loadConfig();
|
||||
return config.apiKey || process.env.COMPOSIO_API_KEY || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Composio API key
|
||||
*/
|
||||
export function setApiKey(apiKey: string): void {
|
||||
const config = loadConfig();
|
||||
config.apiKey = apiKey;
|
||||
saveConfig(config);
|
||||
resetComposioClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Composio is configured
|
||||
*/
|
||||
export function isConfigured(): boolean {
|
||||
return !!getApiKey();
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API call to Composio
|
||||
*/
|
||||
export async function composioApiCall<T extends z.ZodTypeAny>(
|
||||
schema: T,
|
||||
url: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<z.infer<T>> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
console.log(`[Composio] ${options.method || 'GET'} ${url}`);
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
"x-api-key": apiKey,
|
||||
...(options.method === 'POST' ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.log(`[Composio] Response in ${duration}ms`);
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const rawText = await response.text();
|
||||
|
||||
if (!response.ok || !contentType.includes('application/json')) {
|
||||
console.error(`[Composio] Error response:`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
contentType,
|
||||
preview: rawText.slice(0, 200),
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Composio API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Expected JSON response');
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(rawText);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Unknown error';
|
||||
throw new Error(`Failed to parse response: ${message}`);
|
||||
}
|
||||
|
||||
if (typeof data === 'object' && data !== null && 'error' in data) {
|
||||
const parsedError = ZErrorResponse.parse(data);
|
||||
throw new Error(`Composio error (${parsedError.error.error_code}): ${parsedError.error.message}`);
|
||||
}
|
||||
|
||||
return schema.parse(data);
|
||||
} catch (error) {
|
||||
console.error(`[Composio] Error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available toolkits
|
||||
*/
|
||||
export async function listToolkits(cursor: string | null = null): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZToolkit>>>> {
|
||||
const url = new URL(`${BASE_URL}/toolkits`);
|
||||
url.searchParams.set("sort_by", "usage");
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZToolkit), url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific toolkit
|
||||
*/
|
||||
export async function getToolkit(toolkitSlug: string): Promise<z.infer<typeof ZToolkit>> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
const url = `${BASE_URL}/toolkits/${toolkitSlug}`;
|
||||
console.log(`[Composio] GET ${url}`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { "x-api-key": apiKey },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch toolkit: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const no_auth = data.composio_managed_auth_schemes?.includes('NO_AUTH') ||
|
||||
data.auth_config_details?.some((config: { mode: string }) => config.mode === 'NO_AUTH') ||
|
||||
false;
|
||||
|
||||
return ZToolkit.parse({
|
||||
...data,
|
||||
no_auth,
|
||||
meta: data.meta || { description: '', logo: '', tools_count: 0, triggers_count: 0 },
|
||||
auth_schemes: data.auth_schemes || [],
|
||||
composio_managed_auth_schemes: data.composio_managed_auth_schemes || [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List auth configs for a toolkit
|
||||
*/
|
||||
export async function listAuthConfigs(
|
||||
toolkitSlug: string,
|
||||
cursor: string | null = null,
|
||||
managedOnly: boolean = false
|
||||
): Promise<z.infer<ReturnType<typeof ZListResponse<typeof ZAuthConfig>>>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs`);
|
||||
url.searchParams.set("toolkit_slug", toolkitSlug);
|
||||
if (cursor) {
|
||||
url.searchParams.set("cursor", cursor);
|
||||
}
|
||||
if (managedOnly) {
|
||||
url.searchParams.set("is_composio_managed", "true");
|
||||
}
|
||||
return composioApiCall(ZListResponse(ZAuthConfig), url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an auth config
|
||||
*/
|
||||
export async function createAuthConfig(
|
||||
request: z.infer<typeof ZCreateAuthConfigRequest>
|
||||
): Promise<z.infer<typeof ZCreateAuthConfigResponse>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs`);
|
||||
return composioApiCall(ZCreateAuthConfigResponse, url.toString(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an auth config
|
||||
*/
|
||||
export async function deleteAuthConfig(authConfigId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
|
||||
const url = new URL(`${BASE_URL}/auth_configs/${authConfigId}`);
|
||||
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connected account
|
||||
*/
|
||||
export async function createConnectedAccount(
|
||||
request: z.infer<typeof ZCreateConnectedAccountRequest>
|
||||
): Promise<z.infer<typeof ZCreateConnectedAccountResponse>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts`);
|
||||
return composioApiCall(ZCreateConnectedAccountResponse, url.toString(), {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connected account
|
||||
*/
|
||||
export async function getConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZConnectedAccount>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
|
||||
return composioApiCall(ZConnectedAccount, url.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a connected account
|
||||
*/
|
||||
export async function deleteConnectedAccount(connectedAccountId: string): Promise<z.infer<typeof ZDeleteOperationResponse>> {
|
||||
const url = new URL(`${BASE_URL}/connected_accounts/${connectedAccountId}`);
|
||||
return composioApiCall(ZDeleteOperationResponse, url.toString(), {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List available tools for a toolkit
|
||||
*/
|
||||
export async function listToolkitTools(
|
||||
toolkitSlug: string,
|
||||
searchQuery: string | null = null,
|
||||
): Promise<{ items: Array<{ slug: string; name: string; description: string }> }> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
throw new Error('Composio API key not configured');
|
||||
}
|
||||
|
||||
const url = new URL(`${BASE_URL}/tools`);
|
||||
url.searchParams.set('toolkit_slug', toolkitSlug);
|
||||
url.searchParams.set('limit', '200');
|
||||
if (searchQuery) {
|
||||
url.searchParams.set('search', searchQuery);
|
||||
}
|
||||
|
||||
console.log(`[Composio] Listing tools for toolkit: ${toolkitSlug}`);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: { "x-api-key": apiKey },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to list tools: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as { items?: Array<Record<string, unknown>> };
|
||||
|
||||
return {
|
||||
items: (data.items || []).map((item) => ({
|
||||
slug: String(item.slug ?? ''),
|
||||
name: String(item.name ?? ''),
|
||||
description: String(item.description ?? ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool action using Composio SDK
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionSlug: string,
|
||||
connectedAccountId: string,
|
||||
input: Record<string, unknown>
|
||||
): Promise<z.infer<typeof ZExecuteActionResponse>> {
|
||||
console.log(`[Composio] Executing action: ${actionSlug} (account: ${connectedAccountId})`);
|
||||
|
||||
try {
|
||||
const client = getComposioClient();
|
||||
const result = await client.tools.execute(actionSlug, {
|
||||
userId: connectedAccountId,
|
||||
arguments: input,
|
||||
connectedAccountId,
|
||||
dangerouslySkipVersionCheck: true,
|
||||
});
|
||||
|
||||
console.log(`[Composio] Action completed successfully`);
|
||||
return { success: true, data: result.data };
|
||||
} catch (error) {
|
||||
console.error(`[Composio] Action execution failed:`, error);
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, data: null, error: message };
|
||||
}
|
||||
}
|
||||
5
apps/x/packages/core/src/composio/index.ts
Normal file
5
apps/x/packages/core/src/composio/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Composio integration for Rowboat X
|
||||
|
||||
export * from './types.js';
|
||||
export * from './client.js';
|
||||
export * from './repo.js';
|
||||
140
apps/x/packages/core/src/composio/repo.ts
Normal file
140
apps/x/packages/core/src/composio/repo.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import { ZLocalConnectedAccount, LocalConnectedAccount, ConnectedAccountStatus } from "./types.js";
|
||||
|
||||
const ACCOUNTS_FILE = path.join(WorkDir, 'data', 'composio', 'connected_accounts.json');
|
||||
|
||||
/**
|
||||
* Schema for the connected accounts storage file
|
||||
*/
|
||||
const ZConnectedAccountsStorage = z.object({
|
||||
accounts: z.record(z.string(), ZLocalConnectedAccount), // keyed by toolkit slug
|
||||
});
|
||||
|
||||
type ConnectedAccountsStorage = z.infer<typeof ZConnectedAccountsStorage>;
|
||||
|
||||
/**
|
||||
* Interface for Composio accounts repository
|
||||
*/
|
||||
export interface IComposioAccountsRepo {
|
||||
getAccount(toolkitSlug: string): LocalConnectedAccount | null;
|
||||
getAllAccounts(): Record<string, LocalConnectedAccount>;
|
||||
saveAccount(account: LocalConnectedAccount): void;
|
||||
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean;
|
||||
deleteAccount(toolkitSlug: string): void;
|
||||
isConnected(toolkitSlug: string): boolean;
|
||||
getConnectedToolkits(): string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the storage directory exists
|
||||
*/
|
||||
function ensureStorageDir(): void {
|
||||
const dir = path.dirname(ACCOUNTS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load connected accounts from storage
|
||||
*/
|
||||
function loadAccounts(): ConnectedAccountsStorage {
|
||||
try {
|
||||
if (fs.existsSync(ACCOUNTS_FILE)) {
|
||||
const data = fs.readFileSync(ACCOUNTS_FILE, 'utf-8');
|
||||
return ZConnectedAccountsStorage.parse(JSON.parse(data));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ComposioRepo] Failed to load accounts:', error);
|
||||
}
|
||||
return { accounts: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save connected accounts to storage
|
||||
*/
|
||||
function saveAccounts(storage: ConnectedAccountsStorage): void {
|
||||
ensureStorageDir();
|
||||
fs.writeFileSync(ACCOUNTS_FILE, JSON.stringify(storage, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Composio Connected Accounts Repository
|
||||
* Stores connected account information locally
|
||||
*/
|
||||
export class ComposioAccountsRepo implements IComposioAccountsRepo {
|
||||
/**
|
||||
* Get a connected account by toolkit slug
|
||||
*/
|
||||
getAccount(toolkitSlug: string): LocalConnectedAccount | null {
|
||||
const storage = loadAccounts();
|
||||
return storage.accounts[toolkitSlug] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connected accounts
|
||||
*/
|
||||
getAllAccounts(): Record<string, LocalConnectedAccount> {
|
||||
const storage = loadAccounts();
|
||||
return storage.accounts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a connected account
|
||||
*/
|
||||
saveAccount(account: LocalConnectedAccount): void {
|
||||
const storage = loadAccounts();
|
||||
storage.accounts[account.toolkitSlug] = account;
|
||||
saveAccounts(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account status
|
||||
* @returns true if account was found and updated, false if account doesn't exist
|
||||
*/
|
||||
updateAccountStatus(toolkitSlug: string, status: ConnectedAccountStatus): boolean {
|
||||
const storage = loadAccounts();
|
||||
const account = storage.accounts[toolkitSlug];
|
||||
if (!account) {
|
||||
console.warn(`[ComposioRepo] Cannot update status: account '${toolkitSlug}' not found`);
|
||||
return false;
|
||||
}
|
||||
account.status = status;
|
||||
account.lastUpdatedAt = new Date().toISOString();
|
||||
saveAccounts(storage);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a connected account
|
||||
*/
|
||||
deleteAccount(toolkitSlug: string): void {
|
||||
const storage = loadAccounts();
|
||||
delete storage.accounts[toolkitSlug];
|
||||
saveAccounts(storage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a toolkit is connected
|
||||
*/
|
||||
isConnected(toolkitSlug: string): boolean {
|
||||
const account = this.getAccount(toolkitSlug);
|
||||
return account?.status === 'ACTIVE';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of connected toolkit slugs
|
||||
*/
|
||||
getConnectedToolkits(): string[] {
|
||||
const storage = loadAccounts();
|
||||
return Object.entries(storage.accounts)
|
||||
.filter(([, account]) => account.status === 'ACTIVE')
|
||||
.map(([slug]) => slug);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const composioAccountsRepo = new ComposioAccountsRepo();
|
||||
237
apps/x/packages/core/src/composio/types.ts
Normal file
237
apps/x/packages/core/src/composio/types.ts
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Composio authentication schemes
|
||||
*/
|
||||
export const ZAuthScheme = z.enum([
|
||||
'API_KEY',
|
||||
'BASIC',
|
||||
'BASIC_WITH_JWT',
|
||||
'BEARER_TOKEN',
|
||||
'COMPOSIO_LINK',
|
||||
'SERVICE_ACCOUNT',
|
||||
'GOOGLE_SERVICE_ACCOUNT',
|
||||
'NO_AUTH',
|
||||
'OAUTH1',
|
||||
'OAUTH2',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Connected account status
|
||||
*/
|
||||
export const ZConnectedAccountStatus = z.enum([
|
||||
'INITIALIZING',
|
||||
'INITIATED',
|
||||
'ACTIVE',
|
||||
'FAILED',
|
||||
'EXPIRED',
|
||||
'INACTIVE',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Toolkit metadata
|
||||
*/
|
||||
export const ZToolkitMeta = z.object({
|
||||
description: z.string(),
|
||||
logo: z.string(),
|
||||
tools_count: z.number(),
|
||||
triggers_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Toolkit schema
|
||||
*/
|
||||
export const ZToolkit = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
meta: ZToolkitMeta,
|
||||
no_auth: z.boolean(),
|
||||
auth_schemes: z.array(ZAuthScheme),
|
||||
composio_managed_auth_schemes: z.array(ZAuthScheme),
|
||||
});
|
||||
|
||||
/**
|
||||
* Tool schema
|
||||
*/
|
||||
export const ZTool = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
}),
|
||||
input_parameters: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.string(), z.unknown()),
|
||||
required: z.array(z.string()).optional(),
|
||||
additionalProperties: z.boolean().optional(),
|
||||
}),
|
||||
no_auth: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth config schema
|
||||
*/
|
||||
export const ZAuthConfig = z.object({
|
||||
id: z.string(),
|
||||
is_composio_managed: z.boolean(),
|
||||
auth_scheme: ZAuthScheme,
|
||||
});
|
||||
|
||||
/**
|
||||
* Credentials schema
|
||||
*/
|
||||
export const ZCredentials = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()]));
|
||||
|
||||
/**
|
||||
* Create auth config request
|
||||
*/
|
||||
export const ZCreateAuthConfigRequest = z.object({
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
auth_config: z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('use_composio_managed_auth'),
|
||||
name: z.string().optional(),
|
||||
credentials: ZCredentials.optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('use_custom_auth'),
|
||||
authScheme: ZAuthScheme,
|
||||
credentials: ZCredentials,
|
||||
name: z.string().optional(),
|
||||
}),
|
||||
]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create auth config response
|
||||
*/
|
||||
export const ZCreateAuthConfigResponse = z.object({
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
auth_config: ZAuthConfig,
|
||||
});
|
||||
|
||||
/**
|
||||
* Connection data schema
|
||||
*/
|
||||
export const ZConnectionData = z.object({
|
||||
authScheme: ZAuthScheme,
|
||||
val: z.record(z.string(), z.unknown())
|
||||
.and(z.object({
|
||||
status: ZConnectedAccountStatus,
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create connected account request
|
||||
*/
|
||||
export const ZCreateConnectedAccountRequest = z.object({
|
||||
auth_config: z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
connection: z.object({
|
||||
state: ZConnectionData.optional(),
|
||||
user_id: z.string().optional(),
|
||||
callback_url: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create connected account response
|
||||
*/
|
||||
export const ZCreateConnectedAccountResponse = z.object({
|
||||
id: z.string(),
|
||||
connectionData: ZConnectionData,
|
||||
});
|
||||
|
||||
/**
|
||||
* Connected account schema
|
||||
*/
|
||||
export const ZConnectedAccount = z.object({
|
||||
id: z.string(),
|
||||
toolkit: z.object({
|
||||
slug: z.string(),
|
||||
}),
|
||||
auth_config: z.object({
|
||||
id: z.string(),
|
||||
is_composio_managed: z.boolean(),
|
||||
is_disabled: z.boolean(),
|
||||
}),
|
||||
status: ZConnectedAccountStatus,
|
||||
});
|
||||
|
||||
/**
|
||||
* Error response schema
|
||||
*/
|
||||
export const ZErrorResponse = z.object({
|
||||
error: z.object({
|
||||
message: z.string(),
|
||||
error_code: z.number(),
|
||||
suggested_fix: z.string().nullable(),
|
||||
errors: z.array(z.string()).nullable(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete operation response
|
||||
*/
|
||||
export const ZDeleteOperationResponse = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Generic list response
|
||||
*/
|
||||
export const ZListResponse = <T extends z.ZodTypeAny>(schema: T) => z.object({
|
||||
items: z.array(schema),
|
||||
next_cursor: z.string().nullable(),
|
||||
total_pages: z.number(),
|
||||
current_page: z.number(),
|
||||
total_items: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute action request
|
||||
*/
|
||||
export const ZExecuteActionRequest = z.object({
|
||||
action: z.string(),
|
||||
connected_account_id: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute action response
|
||||
*/
|
||||
export const ZExecuteActionResponse = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.unknown(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Local connected account storage schema
|
||||
*/
|
||||
export const ZLocalConnectedAccount = z.object({
|
||||
id: z.string(),
|
||||
authConfigId: z.string(),
|
||||
status: ZConnectedAccountStatus,
|
||||
toolkitSlug: z.string(),
|
||||
createdAt: z.string(),
|
||||
lastUpdatedAt: z.string(),
|
||||
});
|
||||
|
||||
export type AuthScheme = z.infer<typeof ZAuthScheme>;
|
||||
export type ConnectedAccountStatus = z.infer<typeof ZConnectedAccountStatus>;
|
||||
export type Toolkit = z.infer<typeof ZToolkit>;
|
||||
export type Tool = z.infer<typeof ZTool>;
|
||||
export type AuthConfig = z.infer<typeof ZAuthConfig>;
|
||||
export type ConnectedAccount = z.infer<typeof ZConnectedAccount>;
|
||||
export type LocalConnectedAccount = z.infer<typeof ZLocalConnectedAccount>;
|
||||
export type ExecuteActionRequest = z.infer<typeof ZExecuteActionRequest>;
|
||||
export type ExecuteActionResponse = z.infer<typeof ZExecuteActionResponse>;
|
||||
20
apps/x/packages/core/src/config/initConfigs.ts
Normal file
20
apps/x/packages/core/src/config/initConfigs.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import container from "../di/container.js";
|
||||
import type { IModelConfigRepo } from "../models/repo.js";
|
||||
import type { IMcpConfigRepo } from "../mcp/repo.js";
|
||||
import { ensureSecurityConfig } from "./security.js";
|
||||
|
||||
/**
|
||||
* Initialize all config files at app startup.
|
||||
* Ensures config files exist before the UI might access them.
|
||||
*/
|
||||
export async function initConfigs(): Promise<void> {
|
||||
// Resolve repos and explicitly call their ensureConfig methods
|
||||
const modelConfigRepo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const mcpConfigRepo = container.resolve<IMcpConfigRepo>("mcpConfigRepo");
|
||||
|
||||
await Promise.all([
|
||||
modelConfigRepo.ensureConfig(),
|
||||
mcpConfigRepo.ensureConfig(),
|
||||
ensureSecurityConfig(),
|
||||
]);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import fsPromises from "fs/promises";
|
||||
import { WorkDir } from "./config.js";
|
||||
|
||||
export const SECURITY_CONFIG_PATH = path.join(WorkDir, "config", "security.json");
|
||||
|
|
@ -19,7 +20,26 @@ const DEFAULT_ALLOW_LIST = [
|
|||
let cachedAllowList: string[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
function ensureSecurityConfig() {
|
||||
/**
|
||||
* Async function to ensure security config file exists.
|
||||
* Called explicitly at app startup via initConfigs().
|
||||
*/
|
||||
export async function ensureSecurityConfig(): Promise<void> {
|
||||
try {
|
||||
await fsPromises.access(SECURITY_CONFIG_PATH);
|
||||
} catch {
|
||||
await fsPromises.writeFile(
|
||||
SECURITY_CONFIG_PATH,
|
||||
JSON.stringify(DEFAULT_ALLOW_LIST, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync version for internal use by getSecurityAllowList() and readAllowList().
|
||||
*/
|
||||
function ensureSecurityConfigSync() {
|
||||
if (!fs.existsSync(SECURITY_CONFIG_PATH)) {
|
||||
fs.writeFileSync(
|
||||
SECURITY_CONFIG_PATH,
|
||||
|
|
@ -63,7 +83,7 @@ function parseSecurityPayload(payload: unknown): string[] {
|
|||
}
|
||||
|
||||
function readAllowList(): string[] {
|
||||
ensureSecurityConfig();
|
||||
ensureSecurityConfigSync();
|
||||
|
||||
try {
|
||||
const configContent = fs.readFileSync(SECURITY_CONFIG_PATH, "utf8");
|
||||
|
|
@ -76,7 +96,7 @@ function readAllowList(): string[] {
|
|||
}
|
||||
|
||||
export function getSecurityAllowList(): string[] {
|
||||
ensureSecurityConfig();
|
||||
ensureSecurityConfigSync();
|
||||
try {
|
||||
const stats = fs.statSync(SECURITY_CONFIG_PATH);
|
||||
if (cachedAllowList && cachedMtimeMs === stats.mtimeMs) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
|||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
|
||||
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||
|
||||
const container = createContainer({
|
||||
injectionMode: InjectionMode.PROXY,
|
||||
|
|
@ -22,6 +23,7 @@ container.register({
|
|||
messageQueue: asClass<IMessageQueue>(InMemoryMessageQueue).singleton(),
|
||||
bus: asClass<IBus>(InMemoryBus).singleton(),
|
||||
runsLock: asClass<IRunsLock>(InMemoryRunsLock).singleton(),
|
||||
abortRegistry: asClass<IAbortRegistry>(InMemoryAbortRegistry).singleton(),
|
||||
agentRuntime: asClass<IAgentRuntime>(AgentRuntime).singleton(),
|
||||
|
||||
mcpConfigRepo: asClass<IMcpConfigRepo>(FSMcpConfigRepo).singleton(),
|
||||
|
|
|
|||
|
|
@ -2,4 +2,7 @@
|
|||
export * as workspace from './workspace/workspace.js';
|
||||
|
||||
// Workspace watcher
|
||||
export * as watcher from './workspace/watcher.js';
|
||||
export * as watcher from './workspace/watcher.js';
|
||||
|
||||
// Config initialization
|
||||
export { initConfigs } from './config/initConfigs.js';
|
||||
|
|
@ -29,7 +29,89 @@ const SOURCE_FOLDERS = [
|
|||
'fireflies_transcripts',
|
||||
'granola_notes',
|
||||
];
|
||||
const MAX_CONCURRENT_BATCHES = 1; // Process only 1 batch at a time to avoid overwhelming the agent
|
||||
|
||||
// Voice memos are now created directly in knowledge/Voice Memos/<date>/
|
||||
const VOICE_MEMOS_KNOWLEDGE_DIR = path.join(NOTES_OUTPUT_DIR, 'Voice Memos');
|
||||
|
||||
/**
|
||||
* Get unprocessed voice memo files from knowledge/Voice Memos/
|
||||
* Voice memos are created directly in this directory by the UI.
|
||||
* Returns paths to files that need entity extraction.
|
||||
*/
|
||||
function getUnprocessedVoiceMemos(state: GraphState): string[] {
|
||||
console.log(`[GraphBuilder] Checking directory: ${VOICE_MEMOS_KNOWLEDGE_DIR}`);
|
||||
|
||||
if (!fs.existsSync(VOICE_MEMOS_KNOWLEDGE_DIR)) {
|
||||
console.log(`[GraphBuilder] Directory does not exist`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const unprocessedFiles: string[] = [];
|
||||
|
||||
// Scan date folders (e.g., 2026-02-03)
|
||||
const dateFolders = fs.readdirSync(VOICE_MEMOS_KNOWLEDGE_DIR);
|
||||
console.log(`[GraphBuilder] Found ${dateFolders.length} date folders: ${dateFolders.join(', ')}`);
|
||||
|
||||
for (const dateFolder of dateFolders) {
|
||||
const dateFolderPath = path.join(VOICE_MEMOS_KNOWLEDGE_DIR, dateFolder);
|
||||
|
||||
// Skip if not a directory
|
||||
try {
|
||||
if (!fs.statSync(dateFolderPath).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[GraphBuilder] Error checking ${dateFolderPath}:`, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan markdown files in this date folder
|
||||
const files = fs.readdirSync(dateFolderPath);
|
||||
console.log(`[GraphBuilder] Found ${files.length} files in ${dateFolder}: ${files.join(', ')}`);
|
||||
|
||||
for (const file of files) {
|
||||
// Only process voice memo markdown files
|
||||
if (!file.endsWith('.md') || !file.startsWith('voice-memo-')) {
|
||||
console.log(`[GraphBuilder] Skipping ${file} - not a voice memo file`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = path.join(dateFolderPath, file);
|
||||
|
||||
// Skip if already processed
|
||||
if (state.processedFiles[filePath]) {
|
||||
console.log(`[GraphBuilder] Skipping ${file} - already processed`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the file has actual content (not still recording/transcribing)
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
// Skip files that are still recording or transcribing
|
||||
if (content.includes('*Recording in progress...*')) {
|
||||
console.log(`[GraphBuilder] Skipping ${file} - still recording`);
|
||||
continue;
|
||||
}
|
||||
if (content.includes('*Transcribing...*')) {
|
||||
console.log(`[GraphBuilder] Skipping ${file} - still transcribing`);
|
||||
continue;
|
||||
}
|
||||
if (content.includes('*Transcription failed')) {
|
||||
console.log(`[GraphBuilder] Skipping ${file} - transcription failed`);
|
||||
continue;
|
||||
}
|
||||
console.log(`[GraphBuilder] Found unprocessed voice memo: ${file}`);
|
||||
unprocessedFiles.push(filePath);
|
||||
} catch (err) {
|
||||
console.log(`[GraphBuilder] Error reading ${file}:`, err);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[GraphBuilder] Total unprocessed files: ${unprocessedFiles.length}`);
|
||||
return unprocessedFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read content for specific files
|
||||
|
|
@ -186,6 +268,69 @@ export async function buildGraph(sourceDir: string): Promise<void> {
|
|||
console.log(`Knowledge graph build complete. Processed ${processedFiles.length} files.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process voice memos from knowledge/Voice Memos/ and run entity extraction on them
|
||||
* Voice memos are now created directly in the knowledge directory by the UI.
|
||||
*/
|
||||
async function processVoiceMemosForKnowledge(): Promise<boolean> {
|
||||
console.log(`[GraphBuilder] Starting voice memo processing...`);
|
||||
const state = loadState();
|
||||
|
||||
// Get unprocessed voice memos from knowledge/Voice Memos/
|
||||
const unprocessedFiles = getUnprocessedVoiceMemos(state);
|
||||
|
||||
if (unprocessedFiles.length === 0) {
|
||||
console.log(`[GraphBuilder] No unprocessed voice memos found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[GraphBuilder] Processing ${unprocessedFiles.length} voice memo transcripts for entity extraction...`);
|
||||
console.log(`[GraphBuilder] Files to process: ${unprocessedFiles.map(f => path.basename(f)).join(', ')}`);
|
||||
|
||||
// Read the files
|
||||
const contentFiles = await readFileContents(unprocessedFiles);
|
||||
|
||||
if (contentFiles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process in batches like other sources
|
||||
const BATCH_SIZE = 10;
|
||||
const totalBatches = Math.ceil(contentFiles.length / BATCH_SIZE);
|
||||
|
||||
for (let i = 0; i < contentFiles.length; i += BATCH_SIZE) {
|
||||
const batch = contentFiles.slice(i, i + BATCH_SIZE);
|
||||
const batchNumber = Math.floor(i / BATCH_SIZE) + 1;
|
||||
|
||||
try {
|
||||
// Build knowledge index
|
||||
console.log(`[GraphBuilder] Building knowledge index for batch ${batchNumber}...`);
|
||||
const index = buildKnowledgeIndex();
|
||||
const indexForPrompt = formatIndexForPrompt(index);
|
||||
|
||||
console.log(`[GraphBuilder] Processing batch ${batchNumber}/${totalBatches} (${batch.length} files)...`);
|
||||
await createNotesFromBatch(batch, batchNumber, indexForPrompt);
|
||||
console.log(`[GraphBuilder] Batch ${batchNumber}/${totalBatches} complete`);
|
||||
|
||||
// Mark files as processed
|
||||
for (const file of batch) {
|
||||
markFileAsProcessed(file.path, state);
|
||||
}
|
||||
|
||||
// Save state after each batch
|
||||
saveState(state);
|
||||
} catch (error) {
|
||||
console.error(`[GraphBuilder] Error processing batch ${batchNumber}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update last build time
|
||||
state.lastBuildTime = new Date().toISOString();
|
||||
saveState(state);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all configured source directories
|
||||
*/
|
||||
|
|
@ -197,6 +342,16 @@ async function processAllSources(): Promise<void> {
|
|||
|
||||
let anyFilesProcessed = false;
|
||||
|
||||
// Process voice memos first (they get moved to knowledge/)
|
||||
try {
|
||||
const voiceMemosProcessed = await processVoiceMemosForKnowledge();
|
||||
if (voiceMemosProcessed) {
|
||||
anyFilesProcessed = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[GraphBuilder] Error processing voice memos:', error);
|
||||
}
|
||||
|
||||
for (const folder of SOURCE_FOLDERS) {
|
||||
const sourceDir = path.join(WorkDir, folder);
|
||||
|
||||
|
|
@ -234,7 +389,7 @@ async function processAllSources(): Promise<void> {
|
|||
*/
|
||||
export async function init() {
|
||||
console.log('[GraphBuilder] Starting Knowledge Graph Builder Service...');
|
||||
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}`);
|
||||
console.log(`[GraphBuilder] Monitoring folders: ${SOURCE_FOLDERS.join(', ')}, knowledge/Voice Memos`);
|
||||
console.log(`[GraphBuilder] Will check for new content every ${SYNC_INTERVAL_MS / 1000} seconds`);
|
||||
|
||||
// Initial run
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import container from '../di/container.js';
|
|||
import { IOAuthRepo } from '../auth/repo.js';
|
||||
import { IClientRegistrationRepo } from '../auth/client-repo.js';
|
||||
import { getProviderConfig } from '../auth/providers.js';
|
||||
import { getProviderClientIdOverride } from '../auth/provider-client-id.js';
|
||||
import * as oauthClient from '../auth/oauth-client.js';
|
||||
import type { Configuration } from '../auth/oauth-client.js';
|
||||
import { OAuthTokens } from '../auth/types.js';
|
||||
|
|
@ -17,12 +18,22 @@ export class GoogleClientFactory {
|
|||
config: Configuration | null;
|
||||
client: OAuth2Client | null;
|
||||
tokens: OAuthTokens | null;
|
||||
clientId: string | null;
|
||||
} = {
|
||||
config: null,
|
||||
client: null,
|
||||
tokens: null,
|
||||
clientId: null,
|
||||
};
|
||||
|
||||
private static resolveClientId(): string {
|
||||
const override = getProviderClientIdOverride(this.PROVIDER_NAME);
|
||||
if (!override) {
|
||||
throw new Error('Google client ID not provided for this session.');
|
||||
}
|
||||
return override;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create OAuth2Client, reusing cached instance when possible
|
||||
*/
|
||||
|
|
@ -36,7 +47,13 @@ export class GoogleClientFactory {
|
|||
}
|
||||
|
||||
// Initialize config cache if needed
|
||||
await this.initializeConfigCache();
|
||||
try {
|
||||
await this.initializeConfigCache();
|
||||
} catch (error) {
|
||||
console.error("[OAuth] Failed to initialize Google OAuth configuration:", error);
|
||||
this.clearCache();
|
||||
return null;
|
||||
}
|
||||
if (!this.cache.config) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -95,6 +112,10 @@ export class GoogleClientFactory {
|
|||
return false;
|
||||
}
|
||||
|
||||
if (!getProviderClientIdOverride(this.PROVIDER_NAME)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokens = await oauthRepo.getTokens(this.PROVIDER_NAME);
|
||||
if (!tokens) {
|
||||
return false;
|
||||
|
|
@ -116,14 +137,21 @@ export class GoogleClientFactory {
|
|||
this.cache.config = null;
|
||||
this.cache.client = null;
|
||||
this.cache.tokens = null;
|
||||
this.cache.clientId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize cached configuration (called once)
|
||||
*/
|
||||
private static async initializeConfigCache(): Promise<void> {
|
||||
if (this.cache.config) {
|
||||
return; // Already initialized
|
||||
const clientId = this.resolveClientId();
|
||||
|
||||
if (this.cache.config && this.cache.clientId === clientId) {
|
||||
return; // Already initialized for this client ID
|
||||
}
|
||||
|
||||
if (this.cache.clientId && this.cache.clientId !== clientId) {
|
||||
this.clearCache();
|
||||
}
|
||||
|
||||
console.log(`[OAuth] Initializing Google OAuth configuration...`);
|
||||
|
|
@ -135,7 +163,7 @@ export class GoogleClientFactory {
|
|||
console.log(`[OAuth] Discovery mode: issuer with static client ID`);
|
||||
this.cache.config = await oauthClient.discoverConfiguration(
|
||||
providerConfig.discovery.issuer,
|
||||
providerConfig.client.clientId
|
||||
clientId
|
||||
);
|
||||
} else {
|
||||
// DCR mode - need existing registration
|
||||
|
|
@ -162,11 +190,12 @@ export class GoogleClientFactory {
|
|||
this.cache.config = oauthClient.createStaticConfiguration(
|
||||
providerConfig.discovery.authorizationEndpoint,
|
||||
providerConfig.discovery.tokenEndpoint,
|
||||
providerConfig.client.clientId,
|
||||
clientId,
|
||||
providerConfig.discovery.revocationEndpoint
|
||||
);
|
||||
}
|
||||
|
||||
this.cache.clientId = clientId;
|
||||
console.log(`[OAuth] Google OAuth configuration initialized`);
|
||||
}
|
||||
|
||||
|
|
@ -174,17 +203,7 @@ export class GoogleClientFactory {
|
|||
* Create OAuth2Client from OAuthTokens
|
||||
*/
|
||||
private static createClientFromTokens(tokens: OAuthTokens): OAuth2Client {
|
||||
const providerConfig = getProviderConfig(this.PROVIDER_NAME);
|
||||
|
||||
// Get client ID from config
|
||||
let clientId: string;
|
||||
if (providerConfig.client.mode === 'static') {
|
||||
clientId = providerConfig.client.clientId;
|
||||
} else {
|
||||
// For DCR, we'd need to look up the registered client ID
|
||||
// This is a fallback - normally initializeConfigCache handles this
|
||||
throw new Error('Cannot create client without static client ID');
|
||||
}
|
||||
const clientId = this.resolveClientId();
|
||||
|
||||
// Create OAuth2Client directly (PKCE flow doesn't use client secret)
|
||||
const client = new OAuth2Client(
|
||||
|
|
|
|||
|
|
@ -7,19 +7,25 @@ tools:
|
|||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
executeCommand:
|
||||
workspace-grep:
|
||||
type: builtin
|
||||
name: executeCommand
|
||||
name: workspace-grep
|
||||
workspace-glob:
|
||||
type: builtin
|
||||
name: workspace-glob
|
||||
---
|
||||
# Task
|
||||
|
||||
You are a memory agent. Given a single source file (email or meeting transcript), you will:
|
||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
||||
|
||||
1. **Determine source type (meeting or email)**
|
||||
2. **Evaluate if the source is worth processing**
|
||||
|
|
@ -31,7 +37,7 @@ You are a memory agent. Given a single source file (email or meeting transcript)
|
|||
8. Create new notes (meetings only) or update existing notes
|
||||
9. **Apply state changes to existing notes**
|
||||
|
||||
The core rule: **Meetings create notes. Emails enrich them.**
|
||||
The core rule: **Meetings and voice memos create notes. Emails enrich them.**
|
||||
|
||||
You have full read access to the existing knowledge directory. Use this extensively to:
|
||||
- Find existing notes for people, organizations, projects mentioned
|
||||
|
|
@ -70,20 +76,51 @@ When you need to:
|
|||
|
||||
# Tools Available
|
||||
|
||||
You have access to \`executeCommand\` to run shell commands:
|
||||
You have access to these tools:
|
||||
|
||||
**For reading files:**
|
||||
\`\`\`
|
||||
executeCommand("ls {path}") # List directory contents
|
||||
executeCommand("cat {path}") # Read file contents
|
||||
executeCommand("head -50 {path}") # Read first 50 lines
|
||||
executeCommand("write {path} {content}") # Create or overwrite file
|
||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Important:** Use shell escaping for paths with spaces:
|
||||
**For creating NEW files:**
|
||||
\`\`\`
|
||||
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
|
||||
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
|
||||
\`\`\`
|
||||
|
||||
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
|
||||
**For editing EXISTING files (preferred for updates):**
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "knowledge/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For listing directories:**
|
||||
\`\`\`
|
||||
workspace-readdir({ path: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**For creating directories:**
|
||||
\`\`\`
|
||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
\`\`\`
|
||||
|
||||
**For searching files:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
|
||||
\`\`\`
|
||||
|
||||
**For finding files by pattern:**
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT:**
|
||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`workspace-writeFile\` only for creating new notes
|
||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
||||
|
||||
# Output
|
||||
|
||||
|
|
@ -113,7 +150,7 @@ Either:
|
|||
|
||||
Read the source file and determine if it's a meeting or email.
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
**Meeting indicators:**
|
||||
|
|
@ -126,9 +163,15 @@ executeCommand("cat '{source_file}'")
|
|||
- Has \`Subject:\` field
|
||||
- Email signature
|
||||
|
||||
**Voice memo indicators:**
|
||||
- Has \`**Type:** voice memo\` field
|
||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has \`## Transcript\` section
|
||||
|
||||
**Set processing mode:**
|
||||
- \`source_type = "meeting"\` → Can create new notes
|
||||
- \`source_type = "email"\` → Can only update existing notes
|
||||
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -301,9 +344,9 @@ If someone only appears in your memory as "CC'd on outreach emails from [Sender]
|
|||
## Email-Specific Filtering
|
||||
|
||||
For emails, check if sender/recipients have existing notes:
|
||||
\`\`\`bash
|
||||
executeCommand("grep -r -i -l '{sender email}' '{knowledge_folder}/'")
|
||||
executeCommand("grep -r -i -l '{sender name}' '{knowledge_folder}/People/'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "{sender email}", searchPath: "{knowledge_folder}" })
|
||||
workspace-grep({ pattern: "{sender name}", searchPath: "{knowledge_folder}/People" })
|
||||
\`\`\`
|
||||
|
||||
**If no existing note found:**
|
||||
|
|
@ -343,7 +386,7 @@ If processing, continue to Step 2.
|
|||
|
||||
# Step 2: Read and Parse Source File
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
Extract metadata:
|
||||
|
|
@ -440,7 +483,7 @@ From index, find matches for:
|
|||
|
||||
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
|
||||
\`\`\`bash
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Why read these notes:**
|
||||
|
|
@ -524,27 +567,27 @@ Resolution Map:
|
|||
When multiple candidates match a variant, disambiguate:
|
||||
|
||||
**By organization (strongest signal):**
|
||||
\`\`\`bash
|
||||
\`\`\`
|
||||
# "David" could be David Kim or David Chen
|
||||
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Kim.md'")
|
||||
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
|
||||
# Output: **Organization:** [[Acme Corp]]
|
||||
|
||||
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Chen.md'")
|
||||
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
|
||||
# Output: **Organization:** [[Other Corp]]
|
||||
|
||||
# Source is from Acme context → "David" = "David Kim"
|
||||
\`\`\`
|
||||
|
||||
**By email (definitive):**
|
||||
\`\`\`bash
|
||||
executeCommand("grep -i 'david@acme.com' '{knowledge_folder}/People/David Kim.md'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
|
||||
# Exact email match is definitive
|
||||
\`\`\`
|
||||
|
||||
**By role:**
|
||||
\`\`\`bash
|
||||
\`\`\`
|
||||
# Source mentions "their CTO"
|
||||
executeCommand("grep -r -i 'Role.*CTO' '{knowledge_folder}/People/'")
|
||||
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
|
||||
# Filter results by organization context
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -844,7 +887,12 @@ If role is unknown but context suggests it, say so:
|
|||
|
||||
One line summarizing this source's relevance to the entity:
|
||||
\`\`\`
|
||||
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
\`\`\`
|
||||
|
||||
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
|
||||
|
|
@ -968,8 +1016,8 @@ STATE CHANGES:
|
|||
Before writing, compare extracted content against existing notes.
|
||||
|
||||
## Check Activity Log
|
||||
\`\`\`bash
|
||||
executeCommand("grep '2025-01-15' '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
|
||||
|
|
@ -999,28 +1047,28 @@ If new info contradicts existing:
|
|||
|
||||
**IMPORTANT: Write sequentially, one file at a time.**
|
||||
- Generate content for exactly one note.
|
||||
- Issue exactly one \`write\` command.
|
||||
- Issue exactly one write/edit command.
|
||||
- Wait for the tool to return before generating the next note.
|
||||
- Do NOT batch multiple \`write\` commands in a single response.
|
||||
- Do NOT batch multiple write commands in a single response.
|
||||
|
||||
**For new entities (meetings only):**
|
||||
\`\`\`bash
|
||||
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
|
||||
**For NEW entities (use workspace-writeFile):**
|
||||
\`\`\`
|
||||
workspace-writeFile({
|
||||
path: "{knowledge_folder}/People/Jennifer.md",
|
||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For existing entities:**
|
||||
- Read current content first
|
||||
- Add activity entry at TOP of Activity section (reverse chronological)
|
||||
- Update "Last seen" date
|
||||
- Add new key facts (skip duplicates)
|
||||
- Add new open items
|
||||
- Add new decisions
|
||||
- Add new relationships
|
||||
- Update summary ONLY if significant new understanding
|
||||
\`\`\`bash
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
# ... modify content ...
|
||||
executeCommand("write '{knowledge_folder}/People/Sarah Chen.md' '{full_updated_content}'")
|
||||
**For EXISTING entities (use workspace-edit):**
|
||||
- Read current content first with workspace-readFile
|
||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
||||
- Update fields using targeted edits
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
## 9b: Emails — Update Existing Notes Only
|
||||
|
|
@ -1043,7 +1091,7 @@ For each state change identified in Step 7:
|
|||
### Update Project Status
|
||||
\`\`\`bash
|
||||
# Read current project note
|
||||
executeCommand("cat '{knowledge_folder}/Projects/Acme Integration.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/Projects/Acme Integration.md" })
|
||||
|
||||
# Update the Status field
|
||||
# Change: **Status:** planning
|
||||
|
|
@ -1053,7 +1101,7 @@ executeCommand("cat '{knowledge_folder}/Projects/Acme Integration.md'")
|
|||
### Mark Open Items Complete
|
||||
\`\`\`bash
|
||||
# Read current note
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
|
||||
# Find matching open item and update
|
||||
# Change: - [ ] Send API documentation — by Friday
|
||||
|
|
@ -1063,7 +1111,7 @@ executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
|||
### Update Role
|
||||
\`\`\`bash
|
||||
# Read current person note
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
|
||||
# Update role field
|
||||
# Change: **Role:** Engineering Lead
|
||||
|
|
@ -1073,7 +1121,7 @@ executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
|||
### Update Relationship
|
||||
\`\`\`bash
|
||||
# Read current org note
|
||||
executeCommand("cat '{knowledge_folder}/Organizations/Acme Corp.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/Organizations/Acme Corp.md" })
|
||||
|
||||
# Update relationship field
|
||||
# Change: **Relationship:** prospect
|
||||
|
|
@ -1138,8 +1186,8 @@ This ensures:
|
|||
## Check Each New Link
|
||||
|
||||
If you added \`[[People/Jennifer]]\` to \`Organizations/Acme Corp.md\`:
|
||||
\`\`\`bash
|
||||
executeCommand("grep 'Acme Corp' '{knowledge_folder}/People/Jennifer.md'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Acme Corp", searchPath: "{knowledge_folder}/People/Jennifer.md" })
|
||||
\`\`\`
|
||||
|
||||
If not found, update Jennifer.md to add the link.
|
||||
|
|
@ -1179,7 +1227,7 @@ If not found, update Jennifer.md to add the link.
|
|||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links} {[State changes if any]}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none. Never include data gap commentary.}
|
||||
|
|
@ -1216,7 +1264,7 @@ If not found, update Jennifer.md to add the link.
|
|||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links} {[State changes if any]}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links} {[State changes if any]}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
|
@ -1373,11 +1421,11 @@ Not mass email, not automated. Continue.
|
|||
- Variants: "Sarah Chen", "sarah@acme.com", "David Kim", "David", "Jennifer", "CTO", "Acme", "the pilot"
|
||||
|
||||
### Step 3: Search Existing Notes
|
||||
\`\`\`bash
|
||||
executeCommand("grep -r -i -l 'Sarah Chen' 'knowledge/'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Sarah Chen", searchPath: "knowledge" })
|
||||
# Output: (none)
|
||||
|
||||
executeCommand("grep -r -i -l 'acme' 'knowledge/'")
|
||||
workspace-grep({ pattern: "acme", searchPath: "knowledge" })
|
||||
# Output: (none)
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -1516,8 +1564,8 @@ VP Engineering, Acme Corp
|
|||
### Step 1: Filter
|
||||
|
||||
Check for existing relationship:
|
||||
\`\`\`bash
|
||||
executeCommand("grep -r -i -l 'sarah@acme.com' 'knowledge/'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "sarah@acme.com", searchPath: "knowledge" })
|
||||
# Output: notes/People/Sarah Chen.md
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -1652,11 +1700,11 @@ John Smith
|
|||
### Step 1: Filter
|
||||
|
||||
Check for existing relationship:
|
||||
\`\`\`bash
|
||||
executeCommand("grep -r -i -l 'randomvendor' 'knowledge/'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "randomvendor", searchPath: "knowledge" })
|
||||
# Output: (none)
|
||||
|
||||
executeCommand("grep -r -i -l 'John Smith' 'knowledge/'")
|
||||
workspace-grep({ pattern: "John Smith", searchPath: "knowledge" })
|
||||
# Output: (none)
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -1699,8 +1747,8 @@ David
|
|||
### Step 1: Filter
|
||||
|
||||
Check for sender:
|
||||
\`\`\`bash
|
||||
executeCommand("grep -r -i -l 'david@friendly.vc' 'knowledge/'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "david@friendly.vc", searchPath: "knowledge" })
|
||||
# Output: notes/People/David Park.md
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -1803,10 +1851,16 @@ Business banking provider. Account setup completed January 2025.
|
|||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes | Yes | Yes |
|
||||
| Voice memo | Yes | Yes | Yes |
|
||||
| Email (known contact) | No | Yes | Yes |
|
||||
| Email (unknown contact) | No | No (SKIP) | No |
|
||||
| Email (warm intro) | Yes (exception) | Yes | Yes |
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# State Change Reference
|
||||
|
|
|
|||
|
|
@ -7,19 +7,25 @@ tools:
|
|||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
executeCommand:
|
||||
workspace-grep:
|
||||
type: builtin
|
||||
name: executeCommand
|
||||
name: workspace-grep
|
||||
workspace-glob:
|
||||
type: builtin
|
||||
name: workspace-glob
|
||||
---
|
||||
# Task
|
||||
|
||||
You are a memory agent. Given a single source file (email or meeting transcript), you will:
|
||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
||||
|
||||
1. **Determine source type (meeting or email)**
|
||||
2. **Evaluate if the source is worth processing**
|
||||
|
|
@ -31,7 +37,7 @@ You are a memory agent. Given a single source file (email or meeting transcript)
|
|||
8. Create new notes or update existing notes
|
||||
9. **Apply state changes to existing notes**
|
||||
|
||||
The core rule: **Capture broadly. Both meetings and emails create notes for most external contacts.**
|
||||
The core rule: **Capture broadly. Meetings, voice memos, and emails create notes for most external contacts.**
|
||||
|
||||
You have full read access to the existing knowledge directory. Use this extensively to:
|
||||
- Find existing notes for people, organizations, projects mentioned
|
||||
|
|
@ -70,20 +76,51 @@ When you need to:
|
|||
|
||||
# Tools Available
|
||||
|
||||
You have access to \`executeCommand\` to run shell commands:
|
||||
You have access to these tools:
|
||||
|
||||
**For reading files:**
|
||||
\`\`\`
|
||||
executeCommand("ls {path}") # List directory contents
|
||||
executeCommand("cat {path}") # Read file contents
|
||||
executeCommand("head -50 {path}") # Read first 50 lines
|
||||
executeCommand("write {path} {content}") # Create or overwrite file
|
||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Important:** Use shell escaping for paths with spaces:
|
||||
**For creating NEW files:**
|
||||
\`\`\`
|
||||
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
|
||||
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
|
||||
\`\`\`
|
||||
|
||||
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
|
||||
**For editing EXISTING files (preferred for updates):**
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "knowledge/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For listing directories:**
|
||||
\`\`\`
|
||||
workspace-readdir({ path: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**For creating directories:**
|
||||
\`\`\`
|
||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
\`\`\`
|
||||
|
||||
**For searching files:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
|
||||
\`\`\`
|
||||
|
||||
**For finding files by pattern:**
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT:**
|
||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`workspace-writeFile\` only for creating new notes
|
||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
||||
|
||||
# Output
|
||||
|
||||
|
|
@ -120,7 +157,7 @@ This mode prioritizes comprehensive capture over selectivity. The goal is to nev
|
|||
|
||||
Read the source file and determine if it's a meeting or email.
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
**Meeting indicators:**
|
||||
|
|
@ -133,9 +170,15 @@ executeCommand("cat '{source_file}'")
|
|||
- Has \`Subject:\` field
|
||||
- Email signature
|
||||
|
||||
**Voice memo indicators:**
|
||||
- Has \`**Type:** voice memo\` field
|
||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has \`## Transcript\` section
|
||||
|
||||
**Set processing mode:**
|
||||
- \`source_type = "meeting"\` → Create notes for all external attendees
|
||||
- \`source_type = "email"\` → Create notes for sender if identifiable human
|
||||
- \`source_type = "voice_memo"\` → Create notes for all mentioned entities (treat like meetings)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -204,7 +247,7 @@ If processing, continue to Step 2.
|
|||
|
||||
# Step 2: Read and Parse Source File
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
Extract metadata:
|
||||
|
|
@ -291,8 +334,8 @@ From index, find matches for:
|
|||
## 3d: Read Full Notes When Needed
|
||||
|
||||
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
|
||||
\`\`\`bash
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Why read these notes:**
|
||||
|
|
@ -509,7 +552,12 @@ Write 2-3 sentences covering their role/function, context of the relationship, a
|
|||
|
||||
One line summarizing this source's relevance to the entity:
|
||||
\`\`\`
|
||||
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
|
@ -555,21 +603,29 @@ Before writing:
|
|||
|
||||
**IMPORTANT: Write sequentially, one file at a time.**
|
||||
- Generate content for exactly one note.
|
||||
- Issue exactly one \`write\` command.
|
||||
- Issue exactly one write/edit command.
|
||||
- Wait for the tool to return before generating the next note.
|
||||
- Do NOT batch multiple \`write\` commands in a single response.
|
||||
- Do NOT batch multiple write commands in a single response.
|
||||
|
||||
**For new entities:**
|
||||
\`\`\`bash
|
||||
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
|
||||
**For NEW entities (use workspace-writeFile):**
|
||||
\`\`\`
|
||||
workspace-writeFile({
|
||||
path: "{knowledge_folder}/People/Jennifer.md",
|
||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For existing entities:**
|
||||
- Read current content first
|
||||
- Add activity entry at TOP (reverse chronological)
|
||||
- Update "Last seen" date
|
||||
- Add new key facts (skip duplicates)
|
||||
- Add new open items
|
||||
**For EXISTING entities (use workspace-edit):**
|
||||
- Read current content first with workspace-readFile
|
||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
||||
- Update fields using targeted edits
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
## 9b: Apply State Changes
|
||||
|
||||
|
|
@ -638,7 +694,7 @@ After writing, verify links go both ways.
|
|||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
|
@ -673,7 +729,7 @@ After writing, verify links go both ways.
|
|||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
|
@ -705,7 +761,7 @@ After writing, verify links go both ways.
|
|||
- [[Topics/{Topic}]] — {relationship}
|
||||
|
||||
## Timeline
|
||||
**{YYYY-MM-DD}** ({meeting|email})
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo})
|
||||
{What happened.}
|
||||
|
||||
## Decisions
|
||||
|
|
@ -756,9 +812,15 @@ After writing, verify links go both ways.
|
|||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes — ALL external attendees | Yes | Yes |
|
||||
| Voice memo | Yes — all mentioned entities | Yes | Yes |
|
||||
| Email (any human sender) | Yes | Yes | Yes |
|
||||
| Email (automated/newsletter) | No (SKIP) | No | No |
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
||||
\`\`\`
|
||||
|
||||
**Philosophy:** Capture broadly, filter later if needed.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -7,19 +7,25 @@ tools:
|
|||
workspace-readFile:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
executeCommand:
|
||||
workspace-grep:
|
||||
type: builtin
|
||||
name: executeCommand
|
||||
name: workspace-grep
|
||||
workspace-glob:
|
||||
type: builtin
|
||||
name: workspace-glob
|
||||
---
|
||||
# Task
|
||||
|
||||
You are a memory agent. Given a single source file (email or meeting transcript), you will:
|
||||
You are a memory agent. Given a single source file (email, meeting transcript, or voice memo), you will:
|
||||
|
||||
1. **Determine source type (meeting or email)**
|
||||
2. **Evaluate if the source is worth processing**
|
||||
|
|
@ -70,20 +76,51 @@ When you need to:
|
|||
|
||||
# Tools Available
|
||||
|
||||
You have access to \`executeCommand\` to run shell commands:
|
||||
You have access to these tools:
|
||||
|
||||
**For reading files:**
|
||||
\`\`\`
|
||||
executeCommand("ls {path}") # List directory contents
|
||||
executeCommand("cat {path}") # Read file contents
|
||||
executeCommand("head -50 {path}") # Read first 50 lines
|
||||
executeCommand("write {path} {content}") # Create or overwrite file
|
||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Important:** Use shell escaping for paths with spaces:
|
||||
**For creating NEW files:**
|
||||
\`\`\`
|
||||
executeCommand("cat 'knowledge_folder/People/Sarah Chen.md'")
|
||||
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
|
||||
\`\`\`
|
||||
|
||||
**NOTE:** Do NOT use grep to search for entities. Use the provided knowledge_index instead.
|
||||
**For editing EXISTING files (preferred for updates):**
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "knowledge/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For listing directories:**
|
||||
\`\`\`
|
||||
workspace-readdir({ path: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**For creating directories:**
|
||||
\`\`\`
|
||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
\`\`\`
|
||||
|
||||
**For searching files:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
|
||||
\`\`\`
|
||||
|
||||
**For finding files by pattern:**
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT:**
|
||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`workspace-writeFile\` only for creating new notes
|
||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
||||
|
||||
# Output
|
||||
|
||||
|
|
@ -119,7 +156,7 @@ Either:
|
|||
|
||||
Read the source file and determine if it's a meeting or email.
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
**Meeting indicators:**
|
||||
|
|
@ -132,9 +169,15 @@ executeCommand("cat '{source_file}'")
|
|||
- Has \`Subject:\` field
|
||||
- Email signature
|
||||
|
||||
**Voice memo indicators:**
|
||||
- Has \`**Type:** voice memo\` field
|
||||
- Has \`**Path:**\` field with path like \`Voice Memos/YYYY-MM-DD/...\`
|
||||
- Has \`## Transcript\` section
|
||||
|
||||
**Set processing mode:**
|
||||
- \`source_type = "meeting"\` → Can create new notes
|
||||
- \`source_type = "email"\` → Can create notes if personalized and relevant
|
||||
- \`source_type = "voice_memo"\` → Can create new notes (treat like meetings)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -344,7 +387,7 @@ If processing, continue to Step 2.
|
|||
|
||||
# Step 2: Read and Parse Source File
|
||||
\`\`\`
|
||||
executeCommand("cat '{source_file}'")
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
Extract metadata:
|
||||
|
|
@ -441,7 +484,7 @@ From index, find matches for:
|
|||
|
||||
Only read the full note content when you need details not in the index (e.g., activity logs, open items):
|
||||
\`\`\`bash
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Why read these notes:**
|
||||
|
|
@ -525,27 +568,27 @@ Resolution Map:
|
|||
When multiple candidates match a variant, disambiguate:
|
||||
|
||||
**By organization (strongest signal):**
|
||||
\`\`\`bash
|
||||
\`\`\`
|
||||
# "David" could be David Kim or David Chen
|
||||
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Kim.md'")
|
||||
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
|
||||
# Output: **Organization:** [[Acme Corp]]
|
||||
|
||||
executeCommand("grep -i 'Acme' '{knowledge_folder}/People/David Chen.md'")
|
||||
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
|
||||
# Output: **Organization:** [[Other Corp]]
|
||||
|
||||
# Source is from Acme context → "David" = "David Kim"
|
||||
\`\`\`
|
||||
|
||||
**By email (definitive):**
|
||||
\`\`\`bash
|
||||
executeCommand("grep -i 'david@acme.com' '{knowledge_folder}/People/David Kim.md'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
|
||||
# Exact email match is definitive
|
||||
\`\`\`
|
||||
|
||||
**By role:**
|
||||
\`\`\`bash
|
||||
\`\`\`
|
||||
# Source mentions "their CTO"
|
||||
executeCommand("grep -r -i 'Role.*CTO' '{knowledge_folder}/People/'")
|
||||
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
|
||||
# Filter results by organization context
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -784,7 +827,12 @@ The summary should answer: **"Who is this person and why do I know them?"**
|
|||
|
||||
One line summarizing this source's relevance to the entity:
|
||||
\`\`\`
|
||||
**{YYYY-MM-DD}** ({meeting|email}): {Summary with [[links]]}
|
||||
**{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[links]]}
|
||||
\`\`\`
|
||||
|
||||
**For voice memos:** Include a link to the voice memo file using the Path field:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed [[Projects/Acme Integration]] timeline. See [[Voice Memos/2025-01-15/voice-memo-2025-01-15T10-30-00-000Z]]
|
||||
\`\`\`
|
||||
|
||||
**Important:** Use canonical names with absolute paths from resolution map in all summaries:
|
||||
|
|
@ -877,8 +925,8 @@ STATE CHANGES:
|
|||
Before writing, compare extracted content against existing notes.
|
||||
|
||||
## Check Activity Log
|
||||
\`\`\`bash
|
||||
executeCommand("grep '2025-01-15' '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
|
||||
|
|
@ -908,28 +956,28 @@ If new info contradicts existing:
|
|||
|
||||
**IMPORTANT: Write sequentially, one file at a time.**
|
||||
- Generate content for exactly one note.
|
||||
- Issue exactly one \`write\` command.
|
||||
- Issue exactly one write/edit command.
|
||||
- Wait for the tool to return before generating the next note.
|
||||
- Do NOT batch multiple \`write\` commands in a single response.
|
||||
- Do NOT batch multiple write commands in a single response.
|
||||
|
||||
**For new entities (meetings and qualifying emails):**
|
||||
\`\`\`bash
|
||||
executeCommand("write '{knowledge_folder}/People/Jennifer.md' '{content}'")
|
||||
**For NEW entities (use workspace-writeFile):**
|
||||
\`\`\`
|
||||
workspace-writeFile({
|
||||
path: "{knowledge_folder}/People/Jennifer.md",
|
||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For existing entities:**
|
||||
- Read current content first
|
||||
- Add activity entry at TOP of Activity section (reverse chronological)
|
||||
- Update "Last seen" date
|
||||
- Add new key facts (skip duplicates)
|
||||
- Add new open items
|
||||
- Add new decisions
|
||||
- Add new relationships
|
||||
- Update summary ONLY if significant new understanding
|
||||
\`\`\`bash
|
||||
executeCommand("cat '{knowledge_folder}/People/Sarah Chen.md'")
|
||||
# ... modify content ...
|
||||
executeCommand("write '{knowledge_folder}/People/Sarah Chen.md' '{full_updated_content}'")
|
||||
**For EXISTING entities (use workspace-edit):**
|
||||
- Read current content first with workspace-readFile
|
||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
||||
- Update fields using targeted edits
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
## 9b: Apply State Changes
|
||||
|
|
@ -1000,7 +1048,7 @@ After writing, verify links go both ways.
|
|||
- [[Projects/{Project}]] — {role}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
|
@ -1035,7 +1083,7 @@ After writing, verify links go both ways.
|
|||
- [[Projects/{Project}]] — {relationship}
|
||||
|
||||
## Activity
|
||||
- **{YYYY-MM-DD}** ({meeting|email}): {Summary with [[Folder/Name]] links}
|
||||
- **{YYYY-MM-DD}** ({meeting|email|voice memo}): {Summary with [[Folder/Name]] links}
|
||||
|
||||
## Key facts
|
||||
{Substantive facts only. Leave empty if none.}
|
||||
|
|
@ -1119,11 +1167,17 @@ After writing, verify links go both ways.
|
|||
| Source Type | Creates Notes? | Updates Notes? | Detects State Changes? |
|
||||
|-------------|---------------|----------------|------------------------|
|
||||
| Meeting | Yes | Yes | Yes |
|
||||
| Voice memo | Yes | Yes | Yes |
|
||||
| Email (personalized, business-relevant) | Yes | Yes | Yes |
|
||||
| Email (mass/automated/consumer) | No (SKIP) | No | No |
|
||||
| Email (cold outreach with personalization) | Yes | Yes | Yes |
|
||||
| Email (generic cold outreach) | No | No | No |
|
||||
|
||||
**Voice memo activity format:** Always include a link to the source voice memo:
|
||||
\`\`\`
|
||||
**2025-01-15** (voice memo): Discussed project timeline with [[People/Sarah Chen]]. See [[Voice Memos/2025-01-15/voice-memo-...]]
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# Error Handling
|
||||
|
|
|
|||
|
|
@ -84,6 +84,22 @@ export async function cleanup() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-close all MCP client connections.
|
||||
* Used during force abort to immediately reject any pending MCP tool calls.
|
||||
* Clients will be lazily reconnected on next use.
|
||||
*/
|
||||
export async function forceCloseAllMcpClients(): Promise<void> {
|
||||
for (const [serverName, { client }] of Object.entries(clients)) {
|
||||
try {
|
||||
await client?.close();
|
||||
} catch {
|
||||
// Ignore errors during force close
|
||||
}
|
||||
delete clients[serverName];
|
||||
}
|
||||
}
|
||||
|
||||
export async function listServers(): Promise<z.infer<typeof McpServerList>> {
|
||||
const repo = container.resolve<IMcpConfigRepo>('mcpConfigRepo');
|
||||
const { mcpServers } = await repo.getConfig();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,15 @@ import fs from "fs/promises";
|
|||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
const DEFAULT_MCP_SERVERS = {
|
||||
exa: {
|
||||
type: "http" as const,
|
||||
url: "https://mcp.exa.ai/mcp",
|
||||
},
|
||||
};
|
||||
|
||||
export interface IMcpConfigRepo {
|
||||
ensureConfig(): Promise<void>;
|
||||
getConfig(): Promise<z.infer<typeof McpServerConfig>>;
|
||||
upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void>;
|
||||
delete(serverName: string): Promise<void>;
|
||||
|
|
@ -13,15 +21,11 @@ export interface IMcpConfigRepo {
|
|||
export class FSMcpConfigRepo implements IMcpConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
async ensureConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: {} }, null, 2));
|
||||
await fs.writeFile(this.configPath, JSON.stringify({ mcpServers: DEFAULT_MCP_SERVERS }, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
174
apps/x/packages/core/src/models/models-dev.ts
Normal file
174
apps/x/packages/core/src/models/models-dev.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import z from "zod";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
||||
const CACHE_PATH = path.join(WorkDir, "config", "models.dev.json");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const ModelsDevModel = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
release_date: z.string().optional(),
|
||||
tool_call: z.boolean().optional(),
|
||||
experimental: z.boolean().optional(),
|
||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const ModelsDevProvider = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string(),
|
||||
models: z.record(z.string(), ModelsDevModel),
|
||||
}).passthrough();
|
||||
|
||||
const ModelsDevResponse = z.record(z.string(), ModelsDevProvider);
|
||||
|
||||
type ProviderSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name?: string;
|
||||
release_date?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type CacheFile = {
|
||||
fetchedAt: string;
|
||||
data: unknown;
|
||||
};
|
||||
|
||||
async function readCache(): Promise<CacheFile | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(CACHE_PATH, "utf8");
|
||||
return JSON.parse(raw) as CacheFile;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(data: unknown): Promise<void> {
|
||||
const payload: CacheFile = {
|
||||
fetchedAt: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
await fs.writeFile(CACHE_PATH, JSON.stringify(payload, null, 2));
|
||||
}
|
||||
|
||||
async function fetchModelsDev(): Promise<unknown> {
|
||||
const response = await fetch("https://models.dev/api.json", {
|
||||
headers: { "User-Agent": "Rowboat" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`models.dev fetch failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isCacheFresh(fetchedAt: string): boolean {
|
||||
const age = Date.now() - new Date(fetchedAt).getTime();
|
||||
return age < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
async function getModelsDevData(): Promise<{ data: z.infer<typeof ModelsDevResponse>; fetchedAt?: string }> {
|
||||
const cached = await readCache();
|
||||
if (cached?.fetchedAt && isCacheFresh(cached.fetchedAt)) {
|
||||
const parsed = ModelsDevResponse.safeParse(cached.data);
|
||||
if (parsed.success) {
|
||||
return { data: parsed.data, fetchedAt: cached.fetchedAt };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fresh = await fetchModelsDev();
|
||||
const parsed = ModelsDevResponse.parse(fresh);
|
||||
await writeCache(parsed);
|
||||
return { data: parsed, fetchedAt: new Date().toISOString() };
|
||||
} catch (error) {
|
||||
if (cached) {
|
||||
const parsed = ModelsDevResponse.safeParse(cached.data);
|
||||
if (parsed.success) {
|
||||
return { data: parsed.data, fetchedAt: cached.fetchedAt };
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function scoreProvider(flavor: string, id: string, name: string): number {
|
||||
const normalizedId = id.toLowerCase();
|
||||
const normalizedName = name.toLowerCase();
|
||||
let score = 0;
|
||||
if (normalizedId === flavor) score += 100;
|
||||
if (normalizedName.includes(flavor)) score += 20;
|
||||
if (flavor === "google") {
|
||||
if (normalizedName.includes("gemini")) score += 10;
|
||||
if (normalizedName.includes("vertex")) score -= 5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function pickProvider(
|
||||
data: z.infer<typeof ModelsDevResponse>,
|
||||
flavor: "openai" | "anthropic" | "google",
|
||||
): z.infer<typeof ModelsDevProvider> | null {
|
||||
if (data[flavor]) return data[flavor];
|
||||
let best: { score: number; provider: z.infer<typeof ModelsDevProvider> } | null = null;
|
||||
for (const [id, provider] of Object.entries(data)) {
|
||||
const s = scoreProvider(flavor, id, provider.name);
|
||||
if (s <= 0) continue;
|
||||
if (!best || s > best.score) {
|
||||
best = { score: s, provider };
|
||||
}
|
||||
}
|
||||
return best?.provider ?? null;
|
||||
}
|
||||
|
||||
function isStableModel(model: z.infer<typeof ModelsDevModel>): boolean {
|
||||
if (model.experimental) return false;
|
||||
if (model.status && ["alpha", "beta", "deprecated"].includes(model.status)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function supportsToolCall(model: z.infer<typeof ModelsDevModel>): boolean {
|
||||
return model.tool_call === true;
|
||||
}
|
||||
|
||||
function normalizeModels(models: Record<string, z.infer<typeof ModelsDevModel>>): ProviderSummary["models"] {
|
||||
const list = Object.entries(models)
|
||||
.map(([id, model]) => ({
|
||||
id: model.id ?? id,
|
||||
name: model.name,
|
||||
release_date: model.release_date,
|
||||
tool_call: model.tool_call,
|
||||
experimental: model.experimental,
|
||||
status: model.status,
|
||||
}))
|
||||
.filter((model) => isStableModel(model) && supportsToolCall(model))
|
||||
.map(({ id, name, release_date }) => ({ id, name, release_date }));
|
||||
|
||||
list.sort((a, b) => {
|
||||
const aDate = a.release_date ? Date.parse(a.release_date) : 0;
|
||||
const bDate = b.release_date ? Date.parse(b.release_date) : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
export async function listOnboardingModels(): Promise<{ providers: ProviderSummary[]; lastUpdated?: string }> {
|
||||
const { data, fetchedAt } = await getModelsDevData();
|
||||
const providers: ProviderSummary[] = [];
|
||||
const flavors: Array<"openai" | "anthropic" | "google"> = ["openai", "anthropic", "google"];
|
||||
|
||||
for (const flavor of flavors) {
|
||||
const provider = pickProvider(data, flavor);
|
||||
if (!provider) continue;
|
||||
providers.push({
|
||||
id: flavor,
|
||||
name: provider.name,
|
||||
models: normalizeModels(provider.models),
|
||||
});
|
||||
}
|
||||
|
||||
return { providers, lastUpdated: fetchedAt };
|
||||
}
|
||||
|
|
@ -1,119 +1,87 @@
|
|||
import { ProviderV2 } from "@ai-sdk/provider";
|
||||
import { createGateway } from "ai";
|
||||
import { createGateway, generateText } from "ai";
|
||||
import { createOpenAI } from "@ai-sdk/openai";
|
||||
import { createGoogleGenerativeAI } from "@ai-sdk/google";
|
||||
import { createAnthropic } from "@ai-sdk/anthropic";
|
||||
import { createOllama } from "ollama-ai-provider-v2";
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import container from "../di/container.js";
|
||||
import { LlmModelConfig, LlmProvider } from "@x/shared/dist/models.js";
|
||||
import z from "zod";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
export const Provider = LlmProvider;
|
||||
export const ModelConfig = LlmModelConfig;
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
const providerMap: Record<string, ProviderV2> = {};
|
||||
|
||||
export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
||||
// get model conf
|
||||
const repo = container.resolve<IModelConfigRepo>("modelConfigRepo");
|
||||
const modelConfig = await repo.getConfig();
|
||||
if (!modelConfig) {
|
||||
throw new Error("Model config not found");
|
||||
}
|
||||
if (!name) {
|
||||
name = modelConfig.defaults.provider;
|
||||
}
|
||||
if (providerMap[name]) {
|
||||
return providerMap[name];
|
||||
}
|
||||
const providerConfig = modelConfig.providers[name];
|
||||
if (!providerConfig) {
|
||||
throw new Error(`Provider ${name} not found`);
|
||||
}
|
||||
const { apiKey, baseURL, headers } = providerConfig;
|
||||
switch (providerConfig.flavor) {
|
||||
case "rowboat [free]":
|
||||
providerMap[name] = createGateway({
|
||||
apiKey: "rowboatx",
|
||||
baseURL: "https://ai-gateway.rowboatlabs.com/v1/ai",
|
||||
});
|
||||
break;
|
||||
case "openai":
|
||||
providerMap[name] = createOpenAI({
|
||||
export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
|
||||
const { apiKey, baseURL, headers } = config;
|
||||
switch (config.flavor) {
|
||||
case "openai":
|
||||
return createOpenAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
break;
|
||||
case "aigateway":
|
||||
providerMap[name] = createGateway({
|
||||
return createGateway({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "anthropic":
|
||||
providerMap[name] = createAnthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "google":
|
||||
providerMap[name] = createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "ollama":
|
||||
providerMap[name] = createOllama({
|
||||
baseURL,
|
||||
headers
|
||||
});
|
||||
break;
|
||||
case "openai-compatible":
|
||||
providerMap[name] = createOpenAICompatible({
|
||||
name,
|
||||
apiKey,
|
||||
baseURL : baseURL || "",
|
||||
headers,
|
||||
});
|
||||
break;
|
||||
case "openrouter":
|
||||
providerMap[name] = createOpenRouter({
|
||||
case "anthropic":
|
||||
return createAnthropic({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers
|
||||
headers,
|
||||
});
|
||||
case "google":
|
||||
return createGoogleGenerativeAI({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
case "ollama":
|
||||
return createOllama({
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
case "openai-compatible":
|
||||
return createOpenAICompatible({
|
||||
name: "openai-compatible",
|
||||
apiKey,
|
||||
baseURL: baseURL || "",
|
||||
headers,
|
||||
});
|
||||
case "openrouter":
|
||||
return createOpenRouter({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Provider ${name} not found`);
|
||||
throw new Error(`Unsupported provider flavor: ${config.flavor}`);
|
||||
}
|
||||
return providerMap[name];
|
||||
}
|
||||
}
|
||||
|
||||
export async function testModelConnection(
|
||||
providerConfig: z.infer<typeof Provider>,
|
||||
model: string,
|
||||
timeoutMs: number = 8000,
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const provider = createProvider(providerConfig);
|
||||
const languageModel = provider.languageModel(model);
|
||||
await generateText({
|
||||
model: languageModel,
|
||||
prompt: "ping",
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Connection test failed";
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,26 @@
|
|||
import { ModelConfig, Provider } from "./models.js";
|
||||
import { ModelConfig } from "./models.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
|
||||
export interface IModelConfigRepo {
|
||||
ensureConfig(): Promise<void>;
|
||||
getConfig(): Promise<z.infer<typeof ModelConfig>>;
|
||||
upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void>;
|
||||
delete(providerName: string): Promise<void>;
|
||||
setDefault(providerName: string, model: string): Promise<void>;
|
||||
setConfig(config: z.infer<typeof ModelConfig>): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultConfig: z.infer<typeof ModelConfig> = {
|
||||
providers: {
|
||||
"rowboat": {
|
||||
flavor: "rowboat [free]",
|
||||
}
|
||||
provider: {
|
||||
flavor: "openai",
|
||||
},
|
||||
defaults: {
|
||||
provider: "rowboat",
|
||||
model: "gpt-5.1",
|
||||
}
|
||||
model: "gpt-4.1",
|
||||
};
|
||||
|
||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
async ensureConfig(): Promise<void> {
|
||||
try {
|
||||
await fs.access(this.configPath);
|
||||
} catch {
|
||||
|
|
@ -43,28 +33,7 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
return ModelConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
async upsert(providerName: string, config: z.infer<typeof Provider>): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.providers[providerName] = config;
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async delete(providerName: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
delete conf.providers[providerName];
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
|
||||
async setDefault(providerName: string, model: string): Promise<void> {
|
||||
const conf = await this.getConfig();
|
||||
conf.defaults = {
|
||||
provider: providerName,
|
||||
model,
|
||||
};
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
170
apps/x/packages/core/src/runs/abort-registry.ts
Normal file
170
apps/x/packages/core/src/runs/abort-registry.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { ChildProcess } from "child_process";
|
||||
|
||||
export interface IAbortRegistry {
|
||||
/**
|
||||
* Create and track an AbortController for a run.
|
||||
* Returns the AbortSignal to thread through all operations.
|
||||
*/
|
||||
createForRun(runId: string): AbortSignal;
|
||||
|
||||
/**
|
||||
* Track a child process for a run (so we can kill it on abort).
|
||||
*/
|
||||
registerProcess(runId: string, process: ChildProcess): void;
|
||||
|
||||
/**
|
||||
* Untrack a child process after it exits.
|
||||
*/
|
||||
unregisterProcess(runId: string, process: ChildProcess): void;
|
||||
|
||||
/**
|
||||
* Graceful abort:
|
||||
* 1. Fires the AbortSignal (cancels LLM streaming, etc.)
|
||||
* 2. Sends SIGTERM to all tracked process groups
|
||||
* 3. Schedules SIGKILL fallback after grace period
|
||||
*/
|
||||
abort(runId: string): void;
|
||||
|
||||
/**
|
||||
* Force abort:
|
||||
* 1. Fires AbortSignal if not already fired
|
||||
* 2. Sends SIGKILL to all tracked process groups immediately
|
||||
*/
|
||||
forceAbort(runId: string): void;
|
||||
|
||||
/**
|
||||
* Check if a run has been aborted.
|
||||
*/
|
||||
isAborted(runId: string): boolean;
|
||||
|
||||
/**
|
||||
* Clean up tracking state after a run completes or is fully stopped.
|
||||
*/
|
||||
cleanup(runId: string): void;
|
||||
}
|
||||
|
||||
interface RunAbortState {
|
||||
controller: AbortController;
|
||||
processes: Set<ChildProcess>;
|
||||
killTimers: Set<ReturnType<typeof setTimeout>>;
|
||||
}
|
||||
|
||||
const SIGKILL_GRACE_MS = 200;
|
||||
|
||||
export class InMemoryAbortRegistry implements IAbortRegistry {
|
||||
private runs: Map<string, RunAbortState> = new Map();
|
||||
|
||||
createForRun(runId: string): AbortSignal {
|
||||
// If a previous run state exists, clean it up first
|
||||
this.cleanup(runId);
|
||||
|
||||
const state: RunAbortState = {
|
||||
controller: new AbortController(),
|
||||
processes: new Set(),
|
||||
killTimers: new Set(),
|
||||
};
|
||||
this.runs.set(runId, state);
|
||||
return state.controller.signal;
|
||||
}
|
||||
|
||||
registerProcess(runId: string, process: ChildProcess): void {
|
||||
const state = this.runs.get(runId);
|
||||
if (!state) return;
|
||||
state.processes.add(process);
|
||||
|
||||
// Auto-unregister when process exits
|
||||
const onExit = () => {
|
||||
state.processes.delete(process);
|
||||
};
|
||||
process.once("exit", onExit);
|
||||
process.once("error", onExit);
|
||||
}
|
||||
|
||||
unregisterProcess(runId: string, process: ChildProcess): void {
|
||||
const state = this.runs.get(runId);
|
||||
if (!state) return;
|
||||
state.processes.delete(process);
|
||||
}
|
||||
|
||||
abort(runId: string): void {
|
||||
const state = this.runs.get(runId);
|
||||
if (!state) return;
|
||||
|
||||
// 1. Fire the abort signal
|
||||
if (!state.controller.signal.aborted) {
|
||||
state.controller.abort();
|
||||
}
|
||||
|
||||
// 2. SIGTERM all tracked process groups
|
||||
for (const proc of state.processes) {
|
||||
this.killProcessTree(proc, "SIGTERM");
|
||||
|
||||
// 3. Schedule SIGKILL fallback
|
||||
const timer = setTimeout(() => {
|
||||
if (!proc.killed) {
|
||||
this.killProcessTree(proc, "SIGKILL");
|
||||
}
|
||||
state.killTimers.delete(timer);
|
||||
}, SIGKILL_GRACE_MS);
|
||||
state.killTimers.add(timer);
|
||||
}
|
||||
}
|
||||
|
||||
forceAbort(runId: string): void {
|
||||
const state = this.runs.get(runId);
|
||||
if (!state) return;
|
||||
|
||||
// 1. Fire abort signal if not already
|
||||
if (!state.controller.signal.aborted) {
|
||||
state.controller.abort();
|
||||
}
|
||||
|
||||
// 2. Clear any pending graceful kill timers
|
||||
for (const timer of state.killTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
state.killTimers.clear();
|
||||
|
||||
// 3. SIGKILL all tracked process groups immediately
|
||||
for (const proc of state.processes) {
|
||||
this.killProcessTree(proc, "SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
isAborted(runId: string): boolean {
|
||||
const state = this.runs.get(runId);
|
||||
return state?.controller.signal.aborted ?? false;
|
||||
}
|
||||
|
||||
cleanup(runId: string): void {
|
||||
const state = this.runs.get(runId);
|
||||
if (!state) return;
|
||||
|
||||
// Clear any pending kill timers
|
||||
for (const timer of state.killTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
this.runs.delete(runId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process tree using negative PID (process group kill on Unix).
|
||||
* Falls back to direct kill if group kill fails.
|
||||
*/
|
||||
private killProcessTree(proc: ChildProcess, signal: NodeJS.Signals): void {
|
||||
if (!proc.pid || proc.killed) return;
|
||||
|
||||
try {
|
||||
// Negative PID kills the entire process group (Unix)
|
||||
process.kill(-proc.pid, signal);
|
||||
} catch {
|
||||
// Fallback: kill just the process directly
|
||||
try {
|
||||
proc.kill(signal);
|
||||
} catch {
|
||||
// Process may already be dead
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { AskHumanResponseEvent, ToolPermissionResponseEvent, CreateRunOptions, R
|
|||
import { IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { IAbortRegistry } from "./abort-registry.js";
|
||||
import { forceCloseAllMcpClients } from "../mcp/mcp.js";
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
|
|
@ -46,9 +48,21 @@ export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof
|
|||
runtime.trigger(runId);
|
||||
}
|
||||
|
||||
export async function stop(runId: string): Promise<void> {
|
||||
console.log(`Stopping run ${runId}`);
|
||||
throw new Error('Not implemented');
|
||||
export async function stop(runId: string, force: boolean = false): Promise<void> {
|
||||
const abortRegistry = container.resolve<IAbortRegistry>('abortRegistry');
|
||||
|
||||
if (force && abortRegistry.isAborted(runId)) {
|
||||
// Second click: aggressive cleanup — SIGKILL + force close MCP clients
|
||||
console.log(`Force stopping run ${runId}`);
|
||||
abortRegistry.forceAbort(runId);
|
||||
await forceCloseAllMcpClients();
|
||||
} else {
|
||||
// First click: graceful — fires AbortSignal + SIGTERM
|
||||
console.log(`Gracefully stopping run ${runId}`);
|
||||
abortRegistry.abort(runId);
|
||||
}
|
||||
// Note: The run-stopped event is emitted by AgentRuntime.trigger() when it detects the abort.
|
||||
// This avoids duplicate events and ensures proper sequencing.
|
||||
}
|
||||
|
||||
export async function fetchRun(runId: string): Promise<z.infer<typeof Run>> {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { PrefixLogger } from './prefix-logger.js';
|
||||
|
||||
export * as ipc from './ipc.js';
|
||||
export * as models from './models.js';
|
||||
export * as workspace from './workspace.js';
|
||||
export * as mcp from './mcp.js';
|
||||
export { PrefixLogger };
|
||||
export { PrefixLogger };
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||
import { RelPath, Encoding, Stat, DirEntry, ReaddirOptions, ReadFileResult, WorkspaceChangeEvent, WriteFileOptions, WriteFileResult, RemoveOptions } from './workspace.js';
|
||||
import { ListToolsResponse } from './mcp.js';
|
||||
import { AskHumanResponsePayload, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload } from './runs.js';
|
||||
import { LlmModelConfig } from './models.js';
|
||||
|
||||
// ============================================================================
|
||||
// Runtime Validation Schemas (Single Source of Truth)
|
||||
|
|
@ -151,6 +152,7 @@ const ipcSchemas = {
|
|||
'runs:stop': {
|
||||
req: z.object({
|
||||
runId: z.string(),
|
||||
force: z.boolean().optional().default(false),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
|
|
@ -172,9 +174,38 @@ const ipcSchemas = {
|
|||
req: z.null(),
|
||||
res: z.null(),
|
||||
},
|
||||
'models:list': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
providers: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
models: z.array(z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
release_date: z.string().optional(),
|
||||
})),
|
||||
})),
|
||||
lastUpdated: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:test': {
|
||||
req: LlmModelConfig,
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'models:saveConfig': {
|
||||
req: LlmModelConfig,
|
||||
res: z.object({
|
||||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
'oauth:connect': {
|
||||
req: z.object({
|
||||
provider: z.string(),
|
||||
clientId: z.string().optional(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
|
|
@ -243,6 +274,85 @@ const ipcSchemas = {
|
|||
success: z.literal(true),
|
||||
}),
|
||||
},
|
||||
// Composio integration channels
|
||||
'composio:is-configured': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
configured: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'composio:set-api-key': {
|
||||
req: z.object({
|
||||
apiKey: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'composio:initiate-connection': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
redirectUrl: z.string().optional(),
|
||||
connectedAccountId: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'composio:get-connection-status': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
isConnected: z.boolean(),
|
||||
status: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'composio:sync-connection': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
connectedAccountId: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
status: z.string(),
|
||||
}),
|
||||
},
|
||||
'composio:disconnect': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
},
|
||||
'composio:list-connected': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
toolkits: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
'composio:execute-action': {
|
||||
req: z.object({
|
||||
actionSlug: z.string(),
|
||||
toolkitSlug: z.string(),
|
||||
input: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
res: z.object({
|
||||
success: z.boolean(),
|
||||
data: z.unknown(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
'composio:didConnect': {
|
||||
req: z.object({
|
||||
toolkitSlug: z.string(),
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -292,4 +402,4 @@ export function validateResponse<K extends keyof IPCChannels>(
|
|||
): IPCChannels[K]['res'] {
|
||||
const schema = ipcSchemas[channel].res;
|
||||
return schema.parse(data) as IPCChannels[K]['res'];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
apps/x/packages/shared/src/models.ts
Normal file
13
apps/x/packages/shared/src/models.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const LlmProvider = z.object({
|
||||
flavor: z.enum(["openai", "anthropic", "google", "openrouter", "aigateway", "ollama", "openai-compatible"]),
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const LlmModelConfig = z.object({
|
||||
provider: LlmProvider,
|
||||
model: z.string(),
|
||||
});
|
||||
|
|
@ -80,6 +80,11 @@ export const RunErrorEvent = BaseRunEvent.extend({
|
|||
error: z.string(),
|
||||
});
|
||||
|
||||
export const RunStoppedEvent = BaseRunEvent.extend({
|
||||
type: z.literal("run-stopped"),
|
||||
reason: z.enum(["user-requested", "force-stopped"]).optional(),
|
||||
});
|
||||
|
||||
export const RunEvent = z.union([
|
||||
RunProcessingStartEvent,
|
||||
RunProcessingEndEvent,
|
||||
|
|
@ -94,6 +99,7 @@ export const RunEvent = z.union([
|
|||
ToolPermissionRequestEvent,
|
||||
ToolPermissionResponseEvent,
|
||||
RunErrorEvent,
|
||||
RunStoppedEvent,
|
||||
]);
|
||||
|
||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||
|
|
|
|||
462
apps/x/pnpm-lock.yaml
generated
462
apps/x/pnpm-lock.yaml
generated
|
|
@ -290,6 +290,9 @@ importers:
|
|||
'@ai-sdk/provider':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.1
|
||||
'@composio/core':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.2(zod@4.2.1)
|
||||
'@google-cloud/local-auth':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(encoding@0.1.13)
|
||||
|
|
@ -299,6 +302,12 @@ importers:
|
|||
'@openrouter/ai-sdk-provider':
|
||||
specifier: ^1.2.6
|
||||
version: 1.5.4(ai@5.0.117(zod@4.2.1))(zod@4.2.1)
|
||||
'@react-pdf/renderer':
|
||||
specifier: ^4.3.2
|
||||
version: 4.3.2(react@19.2.3)
|
||||
'@types/react':
|
||||
specifier: ^19.2.7
|
||||
version: 19.2.7
|
||||
'@x/shared':
|
||||
specifier: workspace:*
|
||||
version: link:../shared
|
||||
|
|
@ -329,6 +338,9 @@ importers:
|
|||
openid-client:
|
||||
specifier: ^6.8.1
|
||||
version: 6.8.1
|
||||
react:
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
|
|
@ -631,6 +643,10 @@ packages:
|
|||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
|
@ -661,6 +677,19 @@ packages:
|
|||
'@chevrotain/utils@11.0.3':
|
||||
resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==}
|
||||
|
||||
'@composio/client@0.1.0-alpha.56':
|
||||
resolution: {integrity: sha512-hNgChB5uhdvT4QXNzzfUuvtG6vrfanQQFY2hPyKwbeR4x6mEmIGFiZ4y2qynErdUWldAZiB/7pY/MBMg6Q9E0g==}
|
||||
|
||||
'@composio/core@0.6.2':
|
||||
resolution: {integrity: sha512-q4ICwbKdSfFcdWHkgaKf8FBUu7/dohYOCn04MhPU2JcbaVOZ2haGEGv7TdACxMx6zUQpx6HVGKCA0ewH+9MBQA==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
'@composio/json-schema-to-zod@0.1.20':
|
||||
resolution: {integrity: sha512-d4V34itLrUWG/VBh7ciznKcxF/T22MBLHmuEzHoX0zsBOHsUmjYz5qtDh20S2p3FE+HHvLZxpXiv8yfdd4yI+Q==}
|
||||
peerDependencies:
|
||||
zod: '>=3.25.76 <5'
|
||||
|
||||
'@electron-forge/cli@7.11.1':
|
||||
resolution: {integrity: sha512-pk8AoLsr7t7LBAt0cFD06XFA6uxtPdvtLx06xeal7O9o7GHGCbj29WGwFoJ8Br/ENM0Ho868S3PrAn1PtBXt5g==}
|
||||
engines: {node: '>= 16.4.0'}
|
||||
|
|
@ -1947,6 +1976,49 @@ packages:
|
|||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@react-pdf/fns@3.1.2':
|
||||
resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
|
||||
|
||||
'@react-pdf/font@4.0.4':
|
||||
resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
|
||||
|
||||
'@react-pdf/image@3.0.4':
|
||||
resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
|
||||
|
||||
'@react-pdf/layout@4.4.2':
|
||||
resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
|
||||
|
||||
'@react-pdf/pdfkit@4.1.0':
|
||||
resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
|
||||
|
||||
'@react-pdf/primitives@4.1.1':
|
||||
resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
|
||||
|
||||
'@react-pdf/reconciler@2.0.0':
|
||||
resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/render@4.3.2':
|
||||
resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
|
||||
|
||||
'@react-pdf/renderer@4.3.2':
|
||||
resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@react-pdf/stylesheet@6.1.2':
|
||||
resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
|
||||
|
||||
'@react-pdf/textkit@6.1.0':
|
||||
resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
|
||||
|
||||
'@react-pdf/types@2.9.2':
|
||||
resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
|
||||
|
||||
'@remirror/core-constants@3.0.0':
|
||||
resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
|
||||
|
||||
|
|
@ -2307,6 +2379,9 @@ packages:
|
|||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.18':
|
||||
resolution: {integrity: sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==}
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -2931,6 +3006,9 @@ packages:
|
|||
abbrev@1.1.1:
|
||||
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
|
||||
|
||||
abs-svg-path@0.1.1:
|
||||
resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
|
@ -3071,6 +3149,10 @@ packages:
|
|||
base32-encode@1.2.0:
|
||||
resolution: {integrity: sha512-cHFU8XeRyx0GgmoWi5qHMCVRiqU6J3MHWxVgun7jggCBUpVzm1Ir7M9dYr2whjSNc3tFeXfQ/oZjQu/4u55h9A==}
|
||||
|
||||
base64-js@0.0.8:
|
||||
resolution: {integrity: sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
|
||||
|
|
@ -3078,6 +3160,9 @@ packages:
|
|||
resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==}
|
||||
hasBin: true
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
|
|
@ -3114,6 +3199,12 @@ packages:
|
|||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
brotli@1.3.3:
|
||||
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
|
||||
|
||||
browserslist@4.28.1:
|
||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
|
|
@ -3251,6 +3342,10 @@ packages:
|
|||
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
clone@2.1.2:
|
||||
resolution: {integrity: sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -3271,6 +3366,9 @@ packages:
|
|||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
|
|
@ -3369,6 +3467,9 @@ packages:
|
|||
resolution: {integrity: sha512-n63i0lZ0rvQ6FXiGQ+/JFCKAUyPFhLQYJIqKaa+tSJtfKeULF/IDNDAbdnSIxgS4NTuw2b0+lj8LzfITuq+ZxQ==}
|
||||
engines: {node: '>=12.10'}
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
css-select@5.2.2:
|
||||
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
|
||||
|
||||
|
|
@ -3612,6 +3713,9 @@ packages:
|
|||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
dfa@1.2.0:
|
||||
resolution: {integrity: sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==}
|
||||
|
||||
|
|
@ -3674,6 +3778,9 @@ packages:
|
|||
engines: {node: '>= 12.20.55'}
|
||||
hasBin: true
|
||||
|
||||
emoji-regex-xs@1.0.0:
|
||||
resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
|
|
@ -3975,6 +4082,9 @@ packages:
|
|||
debug:
|
||||
optional: true
|
||||
|
||||
fontkit@2.0.4:
|
||||
resolution: {integrity: sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==}
|
||||
|
||||
foreground-child@3.3.1:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
|
@ -4298,6 +4408,12 @@ packages:
|
|||
hosted-git-info@2.8.9:
|
||||
resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==}
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1:
|
||||
resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
|
|
@ -4330,6 +4446,9 @@ packages:
|
|||
humanize-ms@1.2.1:
|
||||
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
|
||||
|
||||
hyphen@1.14.1:
|
||||
resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4419,6 +4538,9 @@ packages:
|
|||
is-arrayish@0.2.1:
|
||||
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
|
||||
|
||||
is-arrayish@0.3.4:
|
||||
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -4506,6 +4628,9 @@ packages:
|
|||
jackspeak@3.4.3:
|
||||
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
|
||||
|
||||
jay-peg@1.1.1:
|
||||
resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
|
||||
|
||||
jest-worker@27.5.1:
|
||||
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
|
@ -4679,6 +4804,9 @@ packages:
|
|||
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
linebreak@1.1.0:
|
||||
resolution: {integrity: sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
|
|
@ -4735,6 +4863,10 @@ packages:
|
|||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lower-case@2.0.2:
|
||||
resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==}
|
||||
|
||||
|
|
@ -4855,6 +4987,9 @@ packages:
|
|||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
media-engine@1.0.3:
|
||||
resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
|
||||
|
||||
media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
|
@ -5200,6 +5335,9 @@ packages:
|
|||
normalize-package-data@2.5.0:
|
||||
resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==}
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
|
||||
|
||||
normalize-url@6.1.0:
|
||||
resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5253,6 +5391,18 @@ packages:
|
|||
resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
openai@6.17.0:
|
||||
resolution: {integrity: sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ws: ^8.18.0
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
ws:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openid-client@6.8.1:
|
||||
resolution: {integrity: sha512-VoYT6enBo6Vj2j3Q5Ec0AezS+9YGzQo1f5Xc42lreMGlfP4ljiXPKVDvCADh+XHCV/bqPu/wWSiCVXbJKvrODw==}
|
||||
|
||||
|
|
@ -5317,6 +5467,12 @@ packages:
|
|||
package-manager-detector@1.6.0:
|
||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||
|
||||
pako@0.2.9:
|
||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -5335,6 +5491,9 @@ packages:
|
|||
resolution: {integrity: sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
parse-svg-path@0.1.2:
|
||||
resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
|
||||
|
||||
parse5@7.3.0:
|
||||
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
|
||||
|
||||
|
|
@ -5428,6 +5587,9 @@ packages:
|
|||
points-on-path@0.2.1:
|
||||
resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
|
||||
|
||||
postcss@8.5.6:
|
||||
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -5472,6 +5634,9 @@ packages:
|
|||
resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
|
|
@ -5555,6 +5720,9 @@ packages:
|
|||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
pusher-js@8.4.0:
|
||||
resolution: {integrity: sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==}
|
||||
|
||||
qs@6.14.1:
|
||||
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
|
@ -5565,6 +5733,9 @@ packages:
|
|||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue@6.0.2:
|
||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||
|
||||
quick-lru@5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -5588,6 +5759,9 @@ packages:
|
|||
peerDependencies:
|
||||
react: ^19.2.3
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5745,6 +5919,9 @@ packages:
|
|||
resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
restructure@3.0.2:
|
||||
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
|
||||
|
||||
retry@0.12.0:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
|
@ -5807,6 +5984,9 @@ packages:
|
|||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029:
|
||||
resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
|
|
@ -5897,6 +6077,9 @@ packages:
|
|||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
|
@ -6034,6 +6217,9 @@ packages:
|
|||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0:
|
||||
resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
|
||||
|
||||
tailwind-merge@3.4.0:
|
||||
resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==}
|
||||
|
||||
|
|
@ -6077,6 +6263,9 @@ packages:
|
|||
tiny-each-async@2.0.3:
|
||||
resolution: {integrity: sha512-5ROII7nElnAirvFn8g7H7MtpfV1daMcyfTGQwsn/x2VtyV+VPiO5CjReCJtWLvoKTDEDmZocf3cNPraiMnBXLA==}
|
||||
|
||||
tiny-inflate@1.0.3:
|
||||
resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -6152,6 +6341,9 @@ packages:
|
|||
tw-animate-css@1.4.0:
|
||||
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
|
@ -6201,6 +6393,12 @@ packages:
|
|||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==}
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
resolution: {integrity: sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==}
|
||||
|
||||
unified@11.0.5:
|
||||
resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==}
|
||||
|
||||
|
|
@ -6325,6 +6523,10 @@ packages:
|
|||
vfile@6.0.3:
|
||||
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
vite@7.3.0:
|
||||
resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -6508,6 +6710,9 @@ packages:
|
|||
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zod-to-json-schema@3.25.1:
|
||||
resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==}
|
||||
peerDependencies:
|
||||
|
|
@ -7159,6 +7364,8 @@ snapshots:
|
|||
'@babel/core': 7.28.5
|
||||
'@babel/helper-plugin-utils': 7.27.1
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@babel/template@7.27.2':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
|
|
@ -7201,6 +7408,26 @@ snapshots:
|
|||
|
||||
'@chevrotain/utils@11.0.3': {}
|
||||
|
||||
'@composio/client@0.1.0-alpha.56': {}
|
||||
|
||||
'@composio/core@0.6.2(zod@4.2.1)':
|
||||
dependencies:
|
||||
'@composio/client': 0.1.0-alpha.56
|
||||
'@composio/json-schema-to-zod': 0.1.20(zod@4.2.1)
|
||||
'@types/json-schema': 7.0.15
|
||||
chalk: 4.1.2
|
||||
openai: 6.17.0(zod@4.2.1)
|
||||
pusher-js: 8.4.0
|
||||
semver: 7.7.3
|
||||
zod: 4.2.1
|
||||
zod-to-json-schema: 3.25.1(zod@4.2.1)
|
||||
transitivePeerDependencies:
|
||||
- ws
|
||||
|
||||
'@composio/json-schema-to-zod@0.1.20(zod@4.2.1)':
|
||||
dependencies:
|
||||
zod: 4.2.1
|
||||
|
||||
'@electron-forge/cli@7.11.1(encoding@0.1.13)(esbuild@0.24.2)':
|
||||
dependencies:
|
||||
'@electron-forge/core': 7.11.1(encoding@0.1.13)(esbuild@0.24.2)
|
||||
|
|
@ -8648,6 +8875,107 @@ snapshots:
|
|||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@react-pdf/fns@3.1.2': {}
|
||||
|
||||
'@react-pdf/font@4.0.4':
|
||||
dependencies:
|
||||
'@react-pdf/pdfkit': 4.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
fontkit: 2.0.4
|
||||
is-url: 1.2.4
|
||||
|
||||
'@react-pdf/image@3.0.4':
|
||||
dependencies:
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
jay-peg: 1.1.1
|
||||
|
||||
'@react-pdf/layout@4.4.2':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/image': 3.0.4
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.2
|
||||
'@react-pdf/textkit': 6.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
emoji-regex-xs: 1.0.0
|
||||
queue: 6.0.2
|
||||
yoga-layout: 3.2.1
|
||||
|
||||
'@react-pdf/pdfkit@4.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@react-pdf/png-js': 3.0.0
|
||||
browserify-zlib: 0.2.0
|
||||
crypto-js: 4.2.0
|
||||
fontkit: 2.0.4
|
||||
jay-peg: 1.1.1
|
||||
linebreak: 1.1.0
|
||||
vite-compatible-readable-stream: 3.6.1
|
||||
|
||||
'@react-pdf/png-js@3.0.0':
|
||||
dependencies:
|
||||
browserify-zlib: 0.2.0
|
||||
|
||||
'@react-pdf/primitives@4.1.1': {}
|
||||
|
||||
'@react-pdf/reconciler@2.0.0(react@19.2.3)':
|
||||
dependencies:
|
||||
object-assign: 4.1.1
|
||||
react: 19.2.3
|
||||
scheduler: 0.25.0-rc-603e6108-20241029
|
||||
|
||||
'@react-pdf/render@4.3.2':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/textkit': 6.1.0
|
||||
'@react-pdf/types': 2.9.2
|
||||
abs-svg-path: 0.1.1
|
||||
color-string: 1.9.1
|
||||
normalize-svg-path: 1.1.0
|
||||
parse-svg-path: 0.1.2
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
'@react-pdf/renderer@4.3.2(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/font': 4.0.4
|
||||
'@react-pdf/layout': 4.4.2
|
||||
'@react-pdf/pdfkit': 4.1.0
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/reconciler': 2.0.0(react@19.2.3)
|
||||
'@react-pdf/render': 4.3.2
|
||||
'@react-pdf/types': 2.9.2
|
||||
events: 3.3.0
|
||||
object-assign: 4.1.1
|
||||
prop-types: 15.8.1
|
||||
queue: 6.0.2
|
||||
react: 19.2.3
|
||||
|
||||
'@react-pdf/stylesheet@6.1.2':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
'@react-pdf/types': 2.9.2
|
||||
color-string: 1.9.1
|
||||
hsl-to-hex: 1.0.0
|
||||
media-engine: 1.0.3
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
'@react-pdf/textkit@6.1.0':
|
||||
dependencies:
|
||||
'@react-pdf/fns': 3.1.2
|
||||
bidi-js: 1.0.3
|
||||
hyphen: 1.14.1
|
||||
unicode-properties: 1.4.1
|
||||
|
||||
'@react-pdf/types@2.9.2':
|
||||
dependencies:
|
||||
'@react-pdf/font': 4.0.4
|
||||
'@react-pdf/primitives': 4.1.1
|
||||
'@react-pdf/stylesheet': 6.1.2
|
||||
|
||||
'@remirror/core-constants@3.0.0': {}
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-beta.53': {}
|
||||
|
|
@ -9093,6 +9421,10 @@ snapshots:
|
|||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.18':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@szmarczak/http-timer@4.0.6':
|
||||
dependencies:
|
||||
defer-to-connect: 2.0.1
|
||||
|
|
@ -9827,6 +10159,8 @@ snapshots:
|
|||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
abs-svg-path@0.1.1: {}
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.2
|
||||
|
|
@ -9966,10 +10300,16 @@ snapshots:
|
|||
to-data-view: 1.1.0
|
||||
optional: true
|
||||
|
||||
base64-js@0.0.8: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
|
||||
baseline-browser-mapping@2.9.11: {}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
bl@4.1.0:
|
||||
|
|
@ -10019,6 +10359,14 @@ snapshots:
|
|||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
brotli@1.3.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
|
||||
browserify-zlib@0.2.0:
|
||||
dependencies:
|
||||
pako: 1.0.11
|
||||
|
||||
browserslist@4.28.1:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.9.11
|
||||
|
|
@ -10180,6 +10528,8 @@ snapshots:
|
|||
|
||||
clone@1.0.4: {}
|
||||
|
||||
clone@2.1.2: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
|
|
@ -10203,6 +10553,11 @@ snapshots:
|
|||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-string@1.9.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.4
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
|
|
@ -10283,6 +10638,8 @@ snapshots:
|
|||
|
||||
cross-zip@4.0.1: {}
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
css-select@5.2.2:
|
||||
dependencies:
|
||||
boolbase: 1.0.0
|
||||
|
|
@ -10542,6 +10899,8 @@ snapshots:
|
|||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
dfa@1.2.0: {}
|
||||
|
||||
dir-compare@4.2.0:
|
||||
dependencies:
|
||||
minimatch: 3.1.2
|
||||
|
|
@ -10655,6 +11014,8 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
emoji-regex-xs@1.0.0: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
emoji-regex@9.2.2: {}
|
||||
|
|
@ -11046,6 +11407,18 @@ snapshots:
|
|||
|
||||
follow-redirects@1.15.11: {}
|
||||
|
||||
fontkit@2.0.4:
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.18
|
||||
brotli: 1.3.3
|
||||
clone: 2.1.2
|
||||
dfa: 1.2.0
|
||||
fast-deep-equal: 3.1.3
|
||||
restructure: 3.0.2
|
||||
tiny-inflate: 1.0.3
|
||||
unicode-properties: 1.4.1
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
foreground-child@3.3.1:
|
||||
dependencies:
|
||||
cross-spawn: 7.0.6
|
||||
|
|
@ -11535,6 +11908,12 @@ snapshots:
|
|||
|
||||
hosted-git-info@2.8.9: {}
|
||||
|
||||
hsl-to-hex@1.0.0:
|
||||
dependencies:
|
||||
hsl-to-rgb-for-reals: 1.1.1
|
||||
|
||||
hsl-to-rgb-for-reals@1.1.1: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
|
@ -11580,6 +11959,8 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
hyphen@1.14.1: {}
|
||||
|
||||
iconv-lite@0.4.24:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
|
@ -11645,6 +12026,8 @@ snapshots:
|
|||
|
||||
is-arrayish@0.2.1: {}
|
||||
|
||||
is-arrayish@0.3.4: {}
|
||||
|
||||
is-core-module@2.16.1:
|
||||
dependencies:
|
||||
hasown: 2.0.2
|
||||
|
|
@ -11712,6 +12095,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@pkgjs/parseargs': 0.11.0
|
||||
|
||||
jay-peg@1.1.1:
|
||||
dependencies:
|
||||
restructure: 3.0.2
|
||||
|
||||
jest-worker@27.5.1:
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
|
|
@ -11865,6 +12252,11 @@ snapshots:
|
|||
lightningcss-win32-arm64-msvc: 1.30.2
|
||||
lightningcss-win32-x64-msvc: 1.30.2
|
||||
|
||||
linebreak@1.1.0:
|
||||
dependencies:
|
||||
base64-js: 0.0.8
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
|
@ -11925,6 +12317,10 @@ snapshots:
|
|||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lower-case@2.0.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -12173,6 +12569,8 @@ snapshots:
|
|||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
media-engine@1.0.3: {}
|
||||
|
||||
media-typer@1.1.0: {}
|
||||
|
||||
mem@4.3.0:
|
||||
|
|
@ -12628,6 +13026,10 @@ snapshots:
|
|||
semver: 5.7.2
|
||||
validate-npm-package-license: 3.0.4
|
||||
|
||||
normalize-svg-path@1.1.0:
|
||||
dependencies:
|
||||
svg-arc-to-cubic-bezier: 3.2.0
|
||||
|
||||
normalize-url@6.1.0: {}
|
||||
|
||||
npm-run-path@2.0.2:
|
||||
|
|
@ -12678,6 +13080,10 @@ snapshots:
|
|||
is-docker: 2.2.1
|
||||
is-wsl: 2.2.0
|
||||
|
||||
openai@6.17.0(zod@4.2.1):
|
||||
optionalDependencies:
|
||||
zod: 4.2.1
|
||||
|
||||
openid-client@6.8.1:
|
||||
dependencies:
|
||||
jose: 6.1.3
|
||||
|
|
@ -12742,6 +13148,10 @@ snapshots:
|
|||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
pako@0.2.9: {}
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
|
|
@ -12769,6 +13179,8 @@ snapshots:
|
|||
dependencies:
|
||||
error-ex: 1.3.4
|
||||
|
||||
parse-svg-path@0.1.2: {}
|
||||
|
||||
parse5@7.3.0:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
|
@ -12845,6 +13257,8 @@ snapshots:
|
|||
path-data-parser: 0.1.0
|
||||
points-on-curve: 0.2.0
|
||||
|
||||
postcss-value-parser@4.2.0: {}
|
||||
|
||||
postcss@8.5.6:
|
||||
dependencies:
|
||||
nanoid: 3.3.11
|
||||
|
|
@ -12888,6 +13302,12 @@ snapshots:
|
|||
err-code: 2.0.3
|
||||
retry: 0.12.0
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
prosemirror-changeset@2.3.1:
|
||||
|
|
@ -13024,6 +13444,10 @@ snapshots:
|
|||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pusher-js@8.4.0:
|
||||
dependencies:
|
||||
tweetnacl: 1.0.3
|
||||
|
||||
qs@6.14.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
|
@ -13032,6 +13456,10 @@ snapshots:
|
|||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue@6.0.2:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
random-path@0.1.2:
|
||||
|
|
@ -13058,6 +13486,8 @@ snapshots:
|
|||
react: 19.2.3
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.7)(react@19.2.3):
|
||||
|
|
@ -13248,6 +13678,8 @@ snapshots:
|
|||
onetime: 5.1.2
|
||||
signal-exit: 3.0.7
|
||||
|
||||
restructure@3.0.2: {}
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
|
@ -13340,6 +13772,8 @@ snapshots:
|
|||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.25.0-rc-603e6108-20241029: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
schema-utils@4.3.3:
|
||||
|
|
@ -13453,6 +13887,10 @@ snapshots:
|
|||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
simple-swizzle@0.2.4:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.4
|
||||
|
||||
slice-ansi@5.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
|
|
@ -13618,6 +14056,8 @@ snapshots:
|
|||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
svg-arc-to-cubic-bezier@3.2.0: {}
|
||||
|
||||
tailwind-merge@3.4.0: {}
|
||||
|
||||
tailwindcss@4.1.18: {}
|
||||
|
|
@ -13660,6 +14100,8 @@ snapshots:
|
|||
tiny-each-async@2.0.3:
|
||||
optional: true
|
||||
|
||||
tiny-inflate@1.0.3: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
|
@ -13730,6 +14172,8 @@ snapshots:
|
|||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
|
|
@ -13770,6 +14214,16 @@ snapshots:
|
|||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
unicode-properties@1.4.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
unicode-trie: 2.0.0
|
||||
|
||||
unicode-trie@2.0.0:
|
||||
dependencies:
|
||||
pako: 0.2.9
|
||||
tiny-inflate: 1.0.3
|
||||
|
||||
unified@11.0.5:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
|
@ -13903,6 +14357,12 @@ snapshots:
|
|||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vite-compatible-readable-stream@3.6.1:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
|
|
@ -14082,6 +14542,8 @@ snapshots:
|
|||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.2.1):
|
||||
dependencies:
|
||||
zod: 4.2.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue