diff --git a/.gitignore b/.gitignore index fdb4a16..c43f483 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ letta-code-sdk/ # WhatsApp session (contains credentials) data/whatsapp-session/ + +# Config with secrets +lettabot.yaml +lettabot.yml diff --git a/lettabot.example.yaml b/lettabot.example.yaml new file mode 100644 index 0000000..fa15fa7 --- /dev/null +++ b/lettabot.example.yaml @@ -0,0 +1,52 @@ +# LettaBot Configuration +# Copy this to lettabot.yaml and fill in your values. +# +# Server modes: +# - 'cloud': Use Letta Cloud (api.letta.com) with API key +# - 'selfhosted': Use self-hosted Letta server + +server: + mode: cloud + # For cloud mode, set your API key (get one at https://app.letta.com): + apiKey: sk-let-YOUR-API-KEY + # For selfhosted mode, uncomment and set the base URL: + # mode: selfhosted + # baseUrl: http://localhost:8283 + +agent: + name: LettaBot + # Model to use: + # - Free plan: zai/glm-4.7, minimax/MiniMax-M1-80k + # - BYOK: lc-anthropic/claude-sonnet-4-5-20250929, lc-openai/gpt-5.2 + model: zai/glm-4.7 + +# BYOK Providers (optional, cloud mode only) +# These will be synced to Letta Cloud on startup +# providers: +# - id: anthropic +# name: lc-anthropic +# type: anthropic +# apiKey: sk-ant-YOUR-ANTHROPIC-KEY +# - id: openai +# name: lc-openai +# type: openai +# apiKey: sk-YOUR-OPENAI-KEY + +channels: + telegram: + enabled: true + token: YOUR-TELEGRAM-BOT-TOKEN + dmPolicy: pairing # 'pairing', 'allowlist', or 'open' + # slack: + # enabled: true + # appToken: xapp-... + # botToken: xoxb-... + # whatsapp: + # enabled: true + # selfChat: false + +features: + cron: false + heartbeat: + enabled: false + intervalMin: 30 diff --git a/package-lock.json b/package-lock.json index 65a9f5c..ca73b42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "bin": { "lettabot": "dist/cli.js", @@ -6384,6 +6385,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 7fd4665..d92d556 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "optionalDependencies": { "@slack/bolt": "^4.6.0", diff --git a/src/cli.ts b/src/cli.ts index 50f9611..f2d7a67 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,10 @@ * lettabot configure - Configure settings */ -import 'dotenv/config'; +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from './config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; @@ -18,90 +21,30 @@ const args = process.argv.slice(2); const command = args[0]; const subCommand = args[1]; -const ENV_PATH = resolve(process.cwd(), '.env'); -const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); - // Check if value is a placeholder const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val); -// Simple prompt helper -function prompt(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); - resolve(answer.trim()); - }); - }); -} - -// Load current env values -function loadEnv(): Record { - const env: Record = {}; - if (existsSync(ENV_PATH)) { - const content = readFileSync(ENV_PATH, 'utf-8'); - for (const line of content.split('\n')) { - if (line.startsWith('#') || !line.includes('=')) continue; - const [key, ...valueParts] = line.split('='); - env[key.trim()] = valueParts.join('=').trim(); - } - } - return env; -} - -// Save env values -function saveEnv(env: Record): void { - // Start with example if no .env exists - let content = ''; - if (existsSync(ENV_EXAMPLE_PATH)) { - content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8'); - } - - // Update values - for (const [key, value] of Object.entries(env)) { - const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm'); - if (regex.test(content)) { - content = content.replace(regex, `${key}=${value}`); - } else { - content += `\n${key}=${value}`; - } - } - - writeFileSync(ENV_PATH, content); -} - // Import onboard from separate module import { onboard } from './onboard.js'; async function configure() { const p = await import('@clack/prompts'); + const { resolveConfigPath } = await import('./config/index.js'); p.intro('🤖 LettaBot Configuration'); - const env = loadEnv(); - - // Check both .env file and shell environment, filtering placeholders - const checkVar = (key: string) => { - const fileValue = env[key]; - const envValue = process.env[key]; - const value = fileValue || envValue; - return isPlaceholder(value) ? undefined : value; - }; - + // Show current config from YAML const configRows = [ - ['LETTA_API_KEY', checkVar('LETTA_API_KEY') ? '✓ Set' : '✗ Not set'], - ['TELEGRAM_BOT_TOKEN', checkVar('TELEGRAM_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], - ['SLACK_BOT_TOKEN', checkVar('SLACK_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], - ['SLACK_APP_TOKEN', checkVar('SLACK_APP_TOKEN') ? '✓ Set' : '✗ Not set'], - ['HEARTBEAT_INTERVAL_MIN', checkVar('HEARTBEAT_INTERVAL_MIN') || 'Not set'], - ['CRON_ENABLED', checkVar('CRON_ENABLED') || 'false'], - ['WORKING_DIR', checkVar('WORKING_DIR') || '/tmp/lettabot'], - ['AGENT_NAME', checkVar('AGENT_NAME') || 'LettaBot'], - ['MODEL', checkVar('MODEL') || '(default)'], + ['Server Mode', config.server.mode], + ['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'], + ['Agent Name', config.agent.name], + ['Model', config.agent.model], + ['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'], + ['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'], + ['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'], + ['Heartbeat', config.features?.heartbeat?.enabled ? `✓ ${config.features.heartbeat.intervalMin}min` : '✗ Disabled'], + ['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'], ]; const maxKeyLength = Math.max(...configRows.map(([key]) => key.length)); @@ -109,20 +52,14 @@ async function configure() { .map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`) .join('\n'); - p.note(summary, 'Current Configuration'); + p.note(summary, `Current Configuration (${resolveConfigPath()})`); const choice = await p.select({ - message: 'What would you like to configure?', + message: 'What would you like to do?', options: [ - { value: '1', label: 'Letta API Key', hint: '' }, - { value: '2', label: 'Telegram', hint: '' }, - { value: '3', label: 'Slack', hint: '' }, - { value: '4', label: 'Heartbeat', hint: '' }, - { value: '5', label: 'Cron', hint: '' }, - { value: '6', label: 'Working Directory', hint: '' }, - { value: '7', label: 'Agent Name & Model', hint: '' }, - { value: '8', label: 'Edit .env directly', hint: '' }, - { value: '9', label: 'Exit', hint: '' }, + { value: 'onboard', label: 'Run setup wizard', hint: 'lettabot onboard' }, + { value: 'edit', label: 'Edit config file', hint: resolveConfigPath() }, + { value: 'exit', label: 'Exit', hint: '' }, ], }); @@ -132,89 +69,28 @@ async function configure() { } switch (choice) { - case '1': - env.LETTA_API_KEY = await prompt('Enter Letta API Key: '); - saveEnv(env); - console.log('✓ Saved'); + case 'onboard': + await onboard(); break; - case '2': - env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '3': - env.SLACK_BOT_TOKEN = await prompt('Enter Slack Bot Token: '); - env.SLACK_APP_TOKEN = await prompt('Enter Slack App Token: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '4': - env.HEARTBEAT_INTERVAL_MIN = await prompt('Heartbeat interval (minutes, 0 to disable): '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '5': - env.CRON_ENABLED = (await prompt('Enable cron? (y/n): ')).toLowerCase() === 'y' ? 'true' : 'false'; - saveEnv(env); - console.log('✓ Saved'); - break; - case '6': - env.WORKING_DIR = await prompt('Working directory: '); - saveEnv(env); - console.log('✓ Saved'); - break; - case '7': { - const p = await import('@clack/prompts'); - const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js'); - - const currentName = env.AGENT_NAME || 'LettaBot'; - const name = await p.text({ - message: 'Agent name', - placeholder: currentName, - initialValue: currentName, - }); - if (!p.isCancel(name) && name) env.AGENT_NAME = name; - - const currentModel = env.MODEL || 'default'; - p.log.info(`Current model: ${currentModel}\n`); - - const spinner = p.spinner(); - spinner.start('Fetching available models...'); - const modelOptions = await buildModelOptions(); - spinner.stop('Models loaded'); - - const modelChoice = await p.select({ - message: 'Select model', - options: modelOptions, - maxItems: 10, - }); - - if (!p.isCancel(modelChoice)) { - const selectedModel = await handleModelSelection(modelChoice, p.text); - if (selectedModel) { - env.MODEL = selectedModel; - } - } - - saveEnv(env); - p.log.success('Saved'); + case 'edit': { + const configPath = resolveConfigPath(); + const editor = process.env.EDITOR || 'nano'; + console.log(`Opening ${configPath} in ${editor}...`); + spawnSync(editor, [configPath], { stdio: 'inherit' }); break; } - case '8': - const editor = process.env.EDITOR || 'nano'; - spawnSync(editor, [ENV_PATH], { stdio: 'inherit' }); + case 'exit': break; - case '9': - return; - default: - console.log('Invalid choice'); } } async function server() { + const { resolveConfigPath } = await import('./config/index.js'); + const configPath = resolveConfigPath(); + // Check if configured - if (!existsSync(ENV_PATH)) { - console.log('No .env found. Run "lettabot onboard" first.\n'); + if (!existsSync(configPath)) { + console.log(`No config found at ${configPath}. Run "lettabot onboard" first.\n`); process.exit(1); } diff --git a/src/cli/message.ts b/src/cli/message.ts index 7294f40..f29e82e 100644 --- a/src/cli/message.ts +++ b/src/cli/message.ts @@ -10,7 +10,10 @@ * (heartbeats, cron jobs) or to send to different channels during conversations. */ -import 'dotenv/config'; +// Config loaded from lettabot.yaml +import { loadConfig, applyConfigToEnv } from '../config/index.js'; +const config = loadConfig(); +applyConfigToEnv(config); import { resolve } from 'node:path'; import { existsSync, readFileSync } from 'node:fs'; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..47bd215 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './io.js'; diff --git a/src/config/io.ts b/src/config/io.ts new file mode 100644 index 0000000..045000e --- /dev/null +++ b/src/config/io.ts @@ -0,0 +1,219 @@ +/** + * LettaBot Configuration I/O + * + * Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project) + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import YAML from 'yaml'; +import type { LettaBotConfig, ProviderConfig } from './types.js'; +import { DEFAULT_CONFIG } from './types.js'; + +// Config file locations (checked in order) +const CONFIG_PATHS = [ + resolve(process.cwd(), 'lettabot.yaml'), // Project-local + resolve(process.cwd(), 'lettabot.yml'), // Project-local alt + join(homedir(), '.lettabot', 'config.yaml'), // User global + join(homedir(), '.lettabot', 'config.yml'), // User global alt +]; + +const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); + +/** + * Find the config file path (first existing, or default) + */ +export function resolveConfigPath(): string { + for (const p of CONFIG_PATHS) { + if (existsSync(p)) { + return p; + } + } + return DEFAULT_CONFIG_PATH; +} + +/** + * Load config from YAML file + */ +export function loadConfig(): LettaBotConfig { + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(content) as Partial; + + // Merge with defaults + return { + ...DEFAULT_CONFIG, + ...parsed, + server: { ...DEFAULT_CONFIG.server, ...parsed.server }, + agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent }, + channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels }, + }; + } catch (err) { + console.error(`[Config] Failed to load ${configPath}:`, err); + return { ...DEFAULT_CONFIG }; + } +} + +/** + * Save config to YAML file + */ +export function saveConfig(config: LettaBotConfig, path?: string): void { + const configPath = path || resolveConfigPath(); + + // Ensure directory exists + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Convert to YAML with comments + const content = YAML.stringify(config, { + indent: 2, + lineWidth: 0, // Don't wrap lines + }); + + writeFileSync(configPath, content, 'utf-8'); + console.log(`[Config] Saved to ${configPath}`); +} + +/** + * Get environment variables from config (for backwards compatibility) + */ +export function configToEnv(config: LettaBotConfig): Record { + const env: Record = {}; + + // Server + if (config.server.mode === 'selfhosted' && config.server.baseUrl) { + env.LETTA_BASE_URL = config.server.baseUrl; + } + if (config.server.apiKey) { + env.LETTA_API_KEY = config.server.apiKey; + } + + // Agent + if (config.agent.id) { + env.LETTA_AGENT_ID = config.agent.id; + } + if (config.agent.name) { + env.AGENT_NAME = config.agent.name; + } + if (config.agent.model) { + env.MODEL = config.agent.model; + } + + // Channels + if (config.channels.telegram?.token) { + env.TELEGRAM_BOT_TOKEN = config.channels.telegram.token; + if (config.channels.telegram.dmPolicy) { + env.TELEGRAM_DM_POLICY = config.channels.telegram.dmPolicy; + } + } + if (config.channels.slack?.appToken) { + env.SLACK_APP_TOKEN = config.channels.slack.appToken; + } + if (config.channels.slack?.botToken) { + env.SLACK_BOT_TOKEN = config.channels.slack.botToken; + } + if (config.channels.whatsapp?.enabled) { + env.WHATSAPP_ENABLED = 'true'; + if (config.channels.whatsapp.selfChat) { + env.WHATSAPP_SELF_CHAT_MODE = 'true'; + } + } + if (config.channels.signal?.phone) { + env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; + } + + // Features + if (config.features?.cron) { + env.CRON_ENABLED = 'true'; + } + if (config.features?.heartbeat?.enabled) { + env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); + } + + return env; +} + +/** + * Apply config to process.env (YAML config takes priority over .env) + */ +export function applyConfigToEnv(config: LettaBotConfig): void { + const env = configToEnv(config); + for (const [key, value] of Object.entries(env)) { + // YAML config always takes priority + process.env[key] = value; + } +} + +/** + * Create BYOK providers on Letta Cloud + */ +export async function syncProviders(config: LettaBotConfig): Promise { + if (config.server.mode !== 'cloud' || !config.server.apiKey) { + return; + } + + if (!config.providers || config.providers.length === 0) { + return; + } + + const apiKey = config.server.apiKey; + const baseUrl = 'https://api.letta.com'; + + // List existing providers + const listResponse = await fetch(`${baseUrl}/v1/providers`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + const existingProviders = listResponse.ok + ? await listResponse.json() as Array<{ id: string; name: string }> + : []; + + // Create or update each provider + for (const provider of config.providers) { + const existing = existingProviders.find(p => p.name === provider.name); + + try { + if (existing) { + // Update existing + await fetch(`${baseUrl}/v1/providers/${existing.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ api_key: provider.apiKey }), + }); + console.log(`[Config] Updated provider: ${provider.name}`); + } else { + // Create new + await fetch(`${baseUrl}/v1/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.type, + api_key: provider.apiKey, + }), + }); + console.log(`[Config] Created provider: ${provider.name}`); + } + } catch (err) { + console.error(`[Config] Failed to sync provider ${provider.name}:`, err); + } + } +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..bf8c786 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,93 @@ +/** + * LettaBot Configuration Types + * + * Two modes: + * 1. Self-hosted: Uses baseUrl (e.g., http://localhost:8283), no API key + * 2. Letta Cloud: Uses apiKey, optional BYOK providers + */ + +export interface LettaBotConfig { + // Server connection + server: { + // 'cloud' (api.letta.com) or 'selfhosted' + mode: 'cloud' | 'selfhosted'; + // Only for selfhosted mode + baseUrl?: string; + // Only for cloud mode + apiKey?: string; + }; + + // Agent configuration + agent: { + id?: string; + name: string; + model: string; + }; + + // BYOK providers (cloud mode only) + providers?: ProviderConfig[]; + + // Channel configurations + channels: { + telegram?: TelegramConfig; + slack?: SlackConfig; + whatsapp?: WhatsAppConfig; + signal?: SignalConfig; + }; + + // Features + features?: { + cron?: boolean; + heartbeat?: { + enabled: boolean; + intervalMin?: number; + }; + }; +} + +export interface ProviderConfig { + id: string; // e.g., 'anthropic', 'openai' + name: string; // e.g., 'lc-anthropic' + type: string; // e.g., 'anthropic', 'openai' + apiKey: string; +} + +export interface TelegramConfig { + enabled: boolean; + token?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SlackConfig { + enabled: boolean; + appToken?: string; + botToken?: string; + allowedUsers?: string[]; +} + +export interface WhatsAppConfig { + enabled: boolean; + selfChat?: boolean; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SignalConfig { + enabled: boolean; + phone?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +// Default config +export const DEFAULT_CONFIG: LettaBotConfig = { + server: { + mode: 'cloud', + }, + agent: { + name: 'LettaBot', + model: 'zai/glm-4.7', // Free model default + }, + channels: {}, +}; diff --git a/src/core/bot.ts b/src/core/bot.ts index 5098dc0..aa4ad56 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -190,19 +190,64 @@ export class LettaBot { console.log(`[Bot] Session _agentId:`, (session as any)._agentId); console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode); - // Hook into transport errors + // Hook into transport errors and stdout const transport = (session as any).transport; if (transport?.process) { + console.log('[Bot] Transport process PID:', transport.process.pid); + transport.process.stdout?.on('data', (data: Buffer) => { + console.log('[Bot] CLI stdout:', data.toString().slice(0, 500)); + }); transport.process.stderr?.on('data', (data: Buffer) => { console.error('[Bot] CLI stderr:', data.toString()); }); + transport.process.on('exit', (code: number) => { + console.log('[Bot] CLI process exited with code:', code); + }); + transport.process.on('error', (err: Error) => { + console.error('[Bot] CLI process error:', err); + }); + } else { + console.log('[Bot] No transport process found'); } + // Initialize session explicitly (so we can log timing/failures) + console.log('[Bot] About to initialize session...'); + console.log('[Bot] LETTA_API_KEY in env:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 30)}...` : 'NOT SET'); + console.log('[Bot] LETTA_CLI_PATH:', process.env.LETTA_CLI_PATH || 'not set (will use default)'); + + const initTimeoutMs = 30000; // Increased to 30s + const withTimeout = async (promise: Promise, label: string): Promise => { + let timeoutId: NodeJS.Timeout; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`${label} timed out after ${initTimeoutMs}ms`)); + }, initTimeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + clearTimeout(timeoutId!); + } + }; + + console.log('[Bot] Initializing session...'); + const initInfo = await withTimeout(session.initialize(), 'Session initialize'); + console.log('[Bot] Session initialized:', initInfo); + // Send message to agent with metadata envelope const formattedMessage = formatMessageEnvelope(msg); - console.log('[Bot] Sending message...'); - await session.send(formattedMessage); - console.log('[Bot] Message sent, starting stream...'); + console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200)); + console.log('[Bot] Target server:', process.env.LETTA_BASE_URL || 'https://api.letta.com (default)'); + console.log('[Bot] API key:', process.env.LETTA_API_KEY ? `${process.env.LETTA_API_KEY.slice(0, 20)}...` : '(not set)'); + console.log('[Bot] Agent ID:', this.store.agentId || '(new agent)'); + console.log('[Bot] Sending message to session...'); + try { + await withTimeout(session.send(formattedMessage), 'Session send'); + console.log('[Bot] Message sent successfully, starting stream...'); + } catch (sendError) { + console.error('[Bot] Error in session.send():', sendError); + throw sendError; + } // Stream response let response = ''; @@ -214,8 +259,12 @@ export class LettaBot { adapter.sendTypingIndicator(msg.chatId).catch(() => {}); }, 4000); + let streamCount = 0; try { + console.log('[Bot] Entering stream loop...'); for await (const streamMsg of session.stream()) { + streamCount++; + console.log(`[Bot] Stream msg #${streamCount}: type=${streamMsg.type}, content=${streamMsg.type === 'assistant' ? streamMsg.content?.slice(0, 50) + '...' : '(n/a)'}`); if (streamMsg.type === 'assistant') { response += streamMsg.content; @@ -260,32 +309,44 @@ export class LettaBot { clearInterval(typingInterval); } + console.log(`[Bot] Stream complete. Total messages: ${streamCount}, Response length: ${response.length}`); + console.log(`[Bot] Response preview: ${response.slice(0, 100)}...`); + // Send final response if (response) { + console.log(`[Bot] Sending final response (messageId=${messageId})`); try { if (messageId) { await adapter.editMessage(msg.chatId, messageId, response); + console.log('[Bot] Edited existing message'); } else { await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); + console.log('[Bot] Sent new message'); } - } catch { + } catch (sendError) { + console.error('[Bot] Error sending final message:', sendError); // If we already sent a streamed message, don't duplicate — the user already saw it. if (!messageId) { await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); } } } else { + console.log('[Bot] No response from agent, sending placeholder'); await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId }); } } catch (error) { - console.error('Error processing message:', error); + console.error('[Bot] Error processing message:', error); + if (error instanceof Error) { + console.error('[Bot] Error stack:', error.stack); + } await adapter.sendMessage({ chatId: msg.chatId, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, threadId: msg.threadId, }); } finally { + console.log('[Bot] Closing session'); session!?.close(); } } diff --git a/src/main.ts b/src/main.ts index 95c8e0c..2a42f57 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,12 +5,21 @@ * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ -import 'dotenv/config'; import { createServer } from 'node:http'; import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawn } from 'node:child_process'; +// Load YAML config and apply to process.env (overrides .env values) +import { loadConfig, applyConfigToEnv, syncProviders, resolveConfigPath } from './config/index.js'; +const yamlConfig = loadConfig(); +console.log(`[Config] Loaded from ${resolveConfigPath()}`); +console.log(`[Config] Mode: ${yamlConfig.server.mode}, Agent: ${yamlConfig.agent.name}, Model: ${yamlConfig.agent.model}`); +applyConfigToEnv(yamlConfig); + +// Sync BYOK providers on startup (async, don't block) +syncProviders(yamlConfig).catch(err => console.error('[Config] Failed to sync providers:', err)); + // Load agent ID from store and set as env var (SDK needs this) // Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json'); @@ -110,13 +119,11 @@ import { PollingService } from './polling/service.js'; import { agentExists } from './tools/letta-api.js'; import { installSkillsToWorkingDir } from './skills/loader.js'; -// Check if setup is needed -const ENV_PATH = resolve(process.cwd(), '.env'); -if (!existsSync(ENV_PATH)) { - console.log('\n No .env file found. Running setup wizard...\n'); - const setupPath = new URL('./setup.ts', import.meta.url).pathname; - spawn('npx', ['tsx', setupPath], { stdio: 'inherit', cwd: process.cwd() }); - process.exit(0); +// Check if config exists +const configPath = resolveConfigPath(); +if (!existsSync(configPath)) { + console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`); + process.exit(1); } // Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890") diff --git a/src/models.json b/src/models.json new file mode 100644 index 0000000..984857d --- /dev/null +++ b/src/models.json @@ -0,0 +1,61 @@ +[ + { + "id": "sonnet-4.5", + "handle": "anthropic/claude-sonnet-4-5-20250929", + "label": "Sonnet 4.5", + "description": "The recommended default model", + "isDefault": true, + "isFeatured": true + }, + { + "id": "opus", + "handle": "anthropic/claude-opus-4-5-20251101", + "label": "Opus 4.5", + "description": "Anthropic's best model", + "isFeatured": true + }, + { + "id": "haiku", + "handle": "anthropic/claude-haiku-4-5-20251001", + "label": "Haiku 4.5", + "description": "Anthropic's fastest model", + "isFeatured": true + }, + { + "id": "gpt-5.2-medium", + "handle": "openai/gpt-5.2", + "label": "GPT-5.2", + "description": "Latest general-purpose GPT (med reasoning)", + "isFeatured": true + }, + { + "id": "gemini-3", + "handle": "google_ai/gemini-3-pro-preview", + "label": "Gemini 3 Pro", + "description": "Google's smartest model", + "isFeatured": true + }, + { + "id": "gemini-3-flash", + "handle": "google_ai/gemini-3-flash-preview", + "label": "Gemini 3 Flash", + "description": "Google's fastest Gemini 3 model", + "isFeatured": true + }, + { + "id": "glm-4.7", + "handle": "zai/glm-4.7", + "label": "GLM-4.7", + "description": "zAI's latest coding model", + "isFeatured": true, + "free": true + }, + { + "id": "minimax-m2.1", + "handle": "minimax/MiniMax-M2.1", + "label": "MiniMax 2.1", + "description": "MiniMax's latest coding model", + "isFeatured": true, + "free": true + } +] diff --git a/src/onboard.ts b/src/onboard.ts index 6cbe420..0b1f115 100644 --- a/src/onboard.ts +++ b/src/onboard.ts @@ -6,9 +6,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import * as p from '@clack/prompts'; - -const ENV_PATH = resolve(process.cwd(), '.env'); -const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); +import { saveConfig, syncProviders } from './config/index.js'; +import type { LettaBotConfig, ProviderConfig } from './config/types.js'; // ============================================================================ // Config Types @@ -16,8 +15,10 @@ const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example'); interface OnboardConfig { // Auth - authMethod: 'keep' | 'oauth' | 'apikey' | 'skip'; + authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip'; apiKey?: string; + baseUrl?: string; + billingTier?: string; // Agent agentChoice: 'new' | 'existing' | 'env' | 'skip'; @@ -27,6 +28,9 @@ interface OnboardConfig { // Model (only for new agents) model?: string; + // BYOK Providers (for free tier) + providers?: Array<{ id: string; name: string; apiKey: string }>; + // Channels (with access control) telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] }; @@ -39,60 +43,6 @@ interface OnboardConfig { cron: boolean; } -// ============================================================================ -// Env Helpers -// ============================================================================ - -function loadEnv(): Record { - const env: Record = {}; - if (existsSync(ENV_PATH)) { - const content = readFileSync(ENV_PATH, 'utf-8'); - for (const line of content.split('\n')) { - if (line.startsWith('#') || !line.includes('=')) continue; - const [key, ...valueParts] = line.split('='); - env[key.trim()] = valueParts.join('=').trim(); - } - } - return env; -} - -function saveEnv(env: Record): void { - // Start with .env.example as template, fall back to existing .env if example doesn't exist - let content = ''; - if (existsSync(ENV_EXAMPLE_PATH)) { - content = readFileSync(ENV_EXAMPLE_PATH, 'utf-8'); - } else if (existsSync(ENV_PATH)) { - content = readFileSync(ENV_PATH, 'utf-8'); - } - - // Track which keys we've seen in the template to detect deletions - const keysInTemplate = new Set(); - for (const line of content.split('\n')) { - const match = line.match(/^#?\s*(\w+)=/); - if (match) keysInTemplate.add(match[1]); - } - - // Update or add keys that exist in env - for (const [key, value] of Object.entries(env)) { - const regex = new RegExp(`^#?\\s*${key}=.*$`, 'm'); - if (regex.test(content)) { - content = content.replace(regex, `${key}=${value}`); - } else { - content += `\n${key}=${value}`; - } - } - - // Comment out keys that were in template but deleted from env - for (const key of keysInTemplate) { - if (!(key in env)) { - const regex = new RegExp(`^(${key}=.*)$`, 'm'); - content = content.replace(regex, '# $1'); - } - } - - writeFileSync(ENV_PATH, content); -} - const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val); // ============================================================================ @@ -103,11 +53,12 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js'); const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.js'); - const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL; + const baseUrl = config.baseUrl || env.LETTA_BASE_URL || process.env.LETTA_BASE_URL; const isLettaCloud = !baseUrl || baseUrl === LETTA_CLOUD_API_URL || baseUrl === 'https://api.letta.com'; const existingTokens = loadTokens(); - const realApiKey = isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY; + // Check both env and config for existing API key + const realApiKey = config.apiKey || (isPlaceholder(env.LETTA_API_KEY) ? undefined : env.LETTA_API_KEY); const validOAuthToken = isLettaCloud ? existingTokens?.accessToken : undefined; const hasExistingAuth = !!realApiKey || !!validOAuthToken; const displayKey = realApiKey || validOAuthToken; @@ -123,7 +74,8 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro ...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []), ...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []), { value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' }, - { value: 'skip', label: 'Skip', hint: 'Local server without auth' }, + { value: 'selfhosted', label: 'Enter self-hosted URL', hint: 'Local Letta server' }, + { value: 'skip', label: 'Skip', hint: 'Continue without auth' }, ]; const authMethod = await p.select({ @@ -194,6 +146,22 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro config.apiKey = apiKey; env.LETTA_API_KEY = apiKey; } + } else if (authMethod === 'selfhosted') { + const serverUrl = await p.text({ + message: 'Letta server URL', + placeholder: 'http://localhost:8283', + initialValue: config.baseUrl || 'http://localhost:8283', + }); + if (p.isCancel(serverUrl)) { p.cancel('Setup cancelled'); process.exit(0); } + + const url = serverUrl || 'http://localhost:8283'; + config.baseUrl = url; + env.LETTA_BASE_URL = url; + process.env.LETTA_BASE_URL = url; // Set immediately so model listing works + + // Clear any cloud API key since we're using self-hosted + delete env.LETTA_API_KEY; + delete process.env.LETTA_API_KEY; } else if (authMethod === 'keep') { // For OAuth tokens, refresh if needed if (existingTokens?.refreshToken) { @@ -238,7 +206,7 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro } } - // Validate connection (only if not skipping auth) + // Validate connection (skip if 'skip' was chosen) if (config.authMethod !== 'skip') { const keyToValidate = config.apiKey || env.LETTA_API_KEY; if (keyToValidate) { @@ -246,11 +214,16 @@ async function stepAuth(config: OnboardConfig, env: Record): Pro } const spinner = p.spinner(); - spinner.start('Checking connection...'); + const serverLabel = config.baseUrl || 'Letta Cloud'; + spinner.start(`Checking connection to ${serverLabel}...`); try { const { testConnection } = await import('./tools/letta-api.js'); const ok = await testConnection(); - spinner.stop(ok ? 'Connected to server' : 'Connection issue'); + spinner.stop(ok ? `Connected to ${serverLabel}` : 'Connection issue'); + + if (!ok && config.authMethod === 'selfhosted') { + p.log.warn(`Could not connect to ${config.baseUrl}. Make sure the server is running.`); + } } catch { spinner.stop('Connection check skipped'); } @@ -333,28 +306,161 @@ async function stepAgent(config: OnboardConfig, env: Record): Pr } } +// BYOK Provider definitions (same as letta-code) +const BYOK_PROVIDERS = [ + { id: 'anthropic', name: 'lc-anthropic', displayName: 'Anthropic (Claude)', providerType: 'anthropic' }, + { id: 'openai', name: 'lc-openai', displayName: 'OpenAI', providerType: 'openai' }, + { id: 'gemini', name: 'lc-gemini', displayName: 'Google Gemini', providerType: 'google_ai' }, + { id: 'zai', name: 'lc-zai', displayName: 'zAI', providerType: 'zai' }, + { id: 'minimax', name: 'lc-minimax', displayName: 'MiniMax', providerType: 'minimax' }, + { id: 'openrouter', name: 'lc-openrouter', displayName: 'OpenRouter', providerType: 'openrouter' }, +]; + +async function stepProviders(config: OnboardConfig, env: Record): Promise { + // Only for free tier users on Letta Cloud (not self-hosted, not paid) + if (config.authMethod === 'selfhosted') return; + if (config.billingTier !== 'free') return; + + const selectedProviders = await p.multiselect({ + message: 'Add LLM provider keys (optional - for BYOK models)', + options: BYOK_PROVIDERS.map(provider => ({ + value: provider.id, + label: provider.displayName, + hint: `Connect your ${provider.displayName} API key`, + })), + required: false, + }); + + if (p.isCancel(selectedProviders)) { p.cancel('Setup cancelled'); process.exit(0); } + + // If no providers selected, skip + if (!selectedProviders || selectedProviders.length === 0) { + return; + } + + config.providers = []; + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + + // Collect API keys for each selected provider + for (const providerId of selectedProviders as string[]) { + const provider = BYOK_PROVIDERS.find(p => p.id === providerId); + if (!provider) continue; + + const providerKey = await p.text({ + message: `${provider.displayName} API Key`, + placeholder: 'sk-...', + }); + + if (p.isCancel(providerKey)) { p.cancel('Setup cancelled'); process.exit(0); } + + if (providerKey) { + // Create or update provider via Letta API + const spinner = p.spinner(); + spinner.start(`Connecting ${provider.displayName}...`); + + try { + // First check if provider already exists + const listResponse = await fetch('https://api.letta.com/v1/providers', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + let existingProvider: { id: string; name: string } | undefined; + if (listResponse.ok) { + const providers = await listResponse.json() as Array<{ id: string; name: string }>; + existingProvider = providers.find(p => p.name === provider.name); + } + + let response: Response; + if (existingProvider) { + // Update existing provider + response = await fetch(`https://api.letta.com/v1/providers/${existingProvider.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + api_key: providerKey, + }), + }); + } else { + // Create new provider + response = await fetch('https://api.letta.com/v1/providers', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.providerType, + api_key: providerKey, + }), + }); + } + + if (response.ok) { + spinner.stop(`Connected ${provider.displayName}`); + config.providers.push({ id: provider.id, name: provider.name, apiKey: providerKey }); + } else { + const error = await response.text(); + spinner.stop(`Failed to connect ${provider.displayName}: ${error}`); + } + } catch (err) { + spinner.stop(`Failed to connect ${provider.displayName}`); + } + } + } +} + async function stepModel(config: OnboardConfig, env: Record): Promise { // Only for new agents if (config.agentChoice !== 'new') return; - const { buildModelOptions, handleModelSelection } = await import('./utils/model-selection.js'); + const { buildModelOptions, handleModelSelection, getBillingTier } = await import('./utils/model-selection.js'); const spinner = p.spinner(); + + // Determine if self-hosted (not Letta Cloud) + const isSelfHosted = config.authMethod === 'selfhosted'; + + // Fetch billing tier for Letta Cloud users (if not already fetched) + let billingTier: string | null = config.billingTier || null; + if (!isSelfHosted && !billingTier) { + spinner.start('Checking account...'); + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + billingTier = await getBillingTier(apiKey, isSelfHosted); + config.billingTier = billingTier ?? undefined; + spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'unknown'}`); + } + spinner.start('Fetching models...'); - const modelOptions = await buildModelOptions(); + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + const modelOptions = await buildModelOptions({ billingTier, isSelfHosted, apiKey }); spinner.stop('Models loaded'); - const modelChoice = await p.select({ - message: 'Select model', - options: modelOptions, - maxItems: 10, - }); - if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); } - - const selectedModel = await handleModelSelection(modelChoice, p.text); - if (selectedModel) { - config.model = selectedModel; + // Show appropriate message for free tier + if (billingTier === 'free') { + p.log.info('Free plan: GLM and MiniMax models are free. Other models require BYOK (Bring Your Own Key).'); } + + let selectedModel: string | null = null; + while (!selectedModel) { + const modelChoice = await p.select({ + message: 'Select model', + options: modelOptions, + maxItems: 12, + }); + if (p.isCancel(modelChoice)) { p.cancel('Setup cancelled'); process.exit(0); } + + selectedModel = await handleModelSelection(modelChoice, p.text); + // If null (e.g., header selected), loop again + } + + config.model = selectedModel; } async function stepChannels(config: OnboardConfig, env: Record): Promise { @@ -623,7 +729,8 @@ function showSummary(config: OnboardConfig): void { keep: 'Keep existing', oauth: 'OAuth login', apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key', - skip: 'None (local server)', + selfhosted: config.baseUrl ? `Self-hosted (${config.baseUrl})` : 'Self-hosted', + skip: 'None', }[config.authMethod]; lines.push(`Auth: ${authLabel}`); @@ -681,7 +788,10 @@ async function reviewLoop(config: OnboardConfig, env: Record): P if (choice === 'auth') await stepAuth(config, env); else if (choice === 'agent') { await stepAgent(config, env); - if (config.agentChoice === 'new') await stepModel(config, env); + if (config.agentChoice === 'new') { + await stepProviders(config, env); + await stepModel(config, env); + } } else if (choice === 'channels') await stepChannels(config, env); else if (choice === 'features') await stepFeatures(config); @@ -693,12 +803,23 @@ async function reviewLoop(config: OnboardConfig, env: Record): P // ============================================================================ export async function onboard(): Promise { - const env = loadEnv(); + // Temporary storage for wizard values + const env: Record = {}; + + // Load existing config if available + const { loadConfig, resolveConfigPath } = await import('./config/index.js'); + const existingConfig = loadConfig(); + const configPath = resolveConfigPath(); + const hasExistingConfig = existsSync(configPath); p.intro('🤖 LettaBot Setup'); - // Show server info - const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com'; + if (hasExistingConfig) { + p.log.info(`Loading existing config from ${configPath}`); + } + + // Pre-populate from existing config + const baseUrl = existingConfig.server.baseUrl || process.env.LETTA_BASE_URL || 'https://api.letta.com'; const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'); p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server'); @@ -724,39 +845,62 @@ export async function onboard(): Promise { } // Initialize config from existing env + // Pre-populate from existing YAML config const config: OnboardConfig = { - authMethod: 'skip', + authMethod: hasExistingConfig ? 'keep' : 'skip', + apiKey: existingConfig.server.apiKey, + baseUrl: existingConfig.server.baseUrl, telegram: { - enabled: !!env.TELEGRAM_BOT_TOKEN && !isPlaceholder(env.TELEGRAM_BOT_TOKEN), - token: isPlaceholder(env.TELEGRAM_BOT_TOKEN) ? undefined : env.TELEGRAM_BOT_TOKEN, + enabled: existingConfig.channels.telegram?.enabled || false, + token: existingConfig.channels.telegram?.token, + dmPolicy: existingConfig.channels.telegram?.dmPolicy, + allowedUsers: existingConfig.channels.telegram?.allowedUsers?.map(String), }, slack: { - enabled: !!env.SLACK_BOT_TOKEN, - appToken: env.SLACK_APP_TOKEN, - botToken: env.SLACK_BOT_TOKEN, + enabled: existingConfig.channels.slack?.enabled || false, + appToken: existingConfig.channels.slack?.appToken, + botToken: existingConfig.channels.slack?.botToken, + allowedUsers: existingConfig.channels.slack?.allowedUsers, }, whatsapp: { - enabled: env.WHATSAPP_ENABLED === 'true', - selfChat: env.WHATSAPP_SELF_CHAT_MODE === 'true', + enabled: existingConfig.channels.whatsapp?.enabled || false, + selfChat: existingConfig.channels.whatsapp?.selfChat, + dmPolicy: existingConfig.channels.whatsapp?.dmPolicy, }, signal: { - enabled: !!env.SIGNAL_PHONE_NUMBER, - phone: env.SIGNAL_PHONE_NUMBER, + enabled: existingConfig.channels.signal?.enabled || false, + phone: existingConfig.channels.signal?.phone, + dmPolicy: existingConfig.channels.signal?.dmPolicy, }, gmail: { enabled: false }, heartbeat: { - enabled: !!env.HEARTBEAT_INTERVAL_MIN, - interval: env.HEARTBEAT_INTERVAL_MIN, + enabled: existingConfig.features?.heartbeat?.enabled || false, + interval: existingConfig.features?.heartbeat?.intervalMin?.toString(), }, - cron: env.CRON_ENABLED === 'true', - agentChoice: 'skip', - agentName: env.AGENT_NAME, - model: env.MODEL, + cron: existingConfig.features?.cron || false, + agentChoice: hasExistingConfig ? 'env' : 'skip', + agentName: existingConfig.agent.name, + agentId: existingConfig.agent.id, + model: existingConfig.agent.model, + providers: existingConfig.providers?.map(p => ({ id: p.id, name: p.name, apiKey: p.apiKey })), }; // Run through all steps await stepAuth(config, env); await stepAgent(config, env); + + // Fetch billing tier for free plan detection (only for Letta Cloud) + if (config.authMethod !== 'selfhosted' && config.agentChoice === 'new') { + const { getBillingTier } = await import('./utils/model-selection.js'); + const spinner = p.spinner(); + spinner.start('Checking account...'); + const apiKey = config.apiKey || env.LETTA_API_KEY || process.env.LETTA_API_KEY; + const billingTier = await getBillingTier(apiKey, false); + config.billingTier = billingTier ?? undefined; + spinner.stop(billingTier === 'free' ? 'Free plan' : `Plan: ${billingTier || 'Pro'}`); + } + + await stepProviders(config, env); await stepModel(config, env); await stepChannels(config, env); await stepFeatures(config); @@ -865,9 +1009,87 @@ export async function onboard(): Promise { p.note(summary, 'Configuration Summary'); - // Save - saveEnv(env); - p.log.success('Configuration saved to .env'); + // Convert to YAML config + const yamlConfig: LettaBotConfig = { + server: { + mode: config.authMethod === 'selfhosted' ? 'selfhosted' : 'cloud', + ...(config.authMethod === 'selfhosted' && config.baseUrl ? { baseUrl: config.baseUrl } : {}), + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + }, + agent: { + name: config.agentName || 'LettaBot', + model: config.model || 'zai/glm-4.7', + ...(config.agentId ? { id: config.agentId } : {}), + }, + channels: { + ...(config.telegram.enabled ? { + telegram: { + enabled: true, + token: config.telegram.token, + dmPolicy: config.telegram.dmPolicy, + allowedUsers: config.telegram.allowedUsers, + } + } : {}), + ...(config.slack.enabled ? { + slack: { + enabled: true, + appToken: config.slack.appToken, + botToken: config.slack.botToken, + allowedUsers: config.slack.allowedUsers, + } + } : {}), + ...(config.whatsapp.enabled ? { + whatsapp: { + enabled: true, + selfChat: config.whatsapp.selfChat, + dmPolicy: config.whatsapp.dmPolicy, + allowedUsers: config.whatsapp.allowedUsers, + } + } : {}), + ...(config.signal.enabled ? { + signal: { + enabled: true, + phone: config.signal.phone, + dmPolicy: config.signal.dmPolicy, + allowedUsers: config.signal.allowedUsers, + } + } : {}), + }, + features: { + cron: config.cron, + heartbeat: { + enabled: config.heartbeat.enabled, + intervalMin: config.heartbeat.interval ? parseInt(config.heartbeat.interval) : undefined, + }, + }, + }; + + // Add BYOK providers if configured + if (config.providers && config.providers.length > 0) { + yamlConfig.providers = config.providers.map(p => ({ + id: p.id, + name: p.name, + type: p.id, // id is the type (anthropic, openai, etc.) + apiKey: p.apiKey, + })); + } + + // Save YAML config (use project-local path) + const savePath = resolve(process.cwd(), 'lettabot.yaml'); + saveConfig(yamlConfig, savePath); + p.log.success('Configuration saved to lettabot.yaml'); + + // Sync BYOK providers to Letta Cloud + if (yamlConfig.providers && yamlConfig.providers.length > 0 && yamlConfig.server.mode === 'cloud') { + const spinner = p.spinner(); + spinner.start('Syncing BYOK providers to Letta Cloud...'); + try { + await syncProviders(yamlConfig); + spinner.stop('BYOK providers synced'); + } catch (err) { + spinner.stop('Failed to sync providers (will retry on startup)'); + } + } // Save agent ID with server URL if (config.agentId) { diff --git a/src/utils/model-selection.ts b/src/utils/model-selection.ts index 50498f6..d44c140 100644 --- a/src/utils/model-selection.ts +++ b/src/utils/model-selection.ts @@ -1,75 +1,217 @@ /** * Shared utilities for model selection UI + * + * Follows letta-code approach: + * - Free plan users see free models (GLM, MiniMax) + BYOK options + * - Paid users see all models with featured/recommended at top */ import type * as p from '@clack/prompts'; +import modelsData from '../models.json' with { type: 'json' }; -export interface ModelOption { +export const models = modelsData as ModelInfo[]; + +export interface ModelInfo { + id: string; + handle: string; + label: string; + description: string; + isDefault?: boolean; + isFeatured?: boolean; + free?: boolean; +} + +/** + * Get billing tier from Letta API + * Uses /v1/metadata/balance endpoint (same as letta-code) + * + * @param apiKey - The API key to use + * @param isSelfHosted - If true, skip billing check (self-hosted has no tiers) + */ +export async function getBillingTier(apiKey?: string, isSelfHosted?: boolean): Promise { + try { + // Self-hosted servers don't have billing tiers + if (isSelfHosted) { + return null; + } + + if (!apiKey) { + return 'free'; + } + + // Always use Letta Cloud for billing check (not process.env.LETTA_BASE_URL) + const response = await fetch('https://api.letta.com/v1/metadata/balance', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + if (!response.ok) { + return 'free'; + } + + const data = await response.json() as { billing_tier?: string }; + const tier = data.billing_tier?.toLowerCase() ?? 'free'; + return tier; + } catch { + return 'free'; + } +} + +/** + * Get the default model for a billing tier + */ +export function getDefaultModelForTier(billingTier?: string | null): string { + // Free tier gets glm-4.7 (a free model) + if (billingTier?.toLowerCase() === 'free') { + const freeDefault = models.find(m => m.id === 'glm-4.7'); + if (freeDefault) return freeDefault.handle; + } + // Everyone else gets the standard default + const defaultModel = models.find(m => m.isDefault); + return defaultModel?.handle ?? models[0]?.handle ?? 'anthropic/claude-sonnet-4-5-20250929'; +} + +interface ByokModel { handle: string; name: string; display_name?: string; - tier?: string; + provider_name: string; + provider_type: string; } -const TIER_LABELS: Record = { - 'free': '🆓 Free', - 'premium': '⭐ Premium', - 'per-inference': '💰 Pay-per-use', -}; - -const BYOK_LABEL = '🔑 BYOK'; +/** + * Fetch BYOK models from Letta API + */ +async function fetchByokModels(apiKey?: string): Promise { + try { + const key = apiKey || process.env.LETTA_API_KEY; + if (!key) return []; + + const response = await fetch('https://api.letta.com/v1/models?provider_category=byok', { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${key}`, + }, + }); + + if (!response.ok) return []; + + const models = await response.json() as ByokModel[]; + return models; + } catch { + return []; + } +} /** - * Build model selection options + * Build model selection options based on billing tier * Returns array ready for @clack/prompts select() + * + * For free users: Show free models first, then BYOK models from API + * For paid users: Show featured models first, then all models + * For self-hosted: Fetch models from server */ -export async function buildModelOptions(): Promise> { - const { listModels } = await import('../tools/letta-api.js'); +export async function buildModelOptions(options?: { + billingTier?: string | null; + isSelfHosted?: boolean; + apiKey?: string; +}): Promise> { + const billingTier = options?.billingTier; + const isSelfHosted = options?.isSelfHosted; + const isFreeTier = billingTier?.toLowerCase() === 'free'; - // Fetch both base and BYOK models - const [baseModels, byokModels] = await Promise.all([ - listModels({ providerCategory: 'base' }), - listModels({ providerCategory: 'byok' }), - ]); - - // Sort base models: free first, then premium, then per-inference - const sortedBase = baseModels.sort((a, b) => { - const tierOrder = ['free', 'premium', 'per-inference']; - return tierOrder.indexOf(a.tier || 'free') - tierOrder.indexOf(b.tier || 'free'); - }); - - // Sort BYOK models alphabetically - const sortedByok = byokModels.sort((a, b) => - (a.display_name || a.name).localeCompare(b.display_name || b.name) - ); + // For self-hosted servers, fetch models from server + if (isSelfHosted) { + return buildServerModelOptions(); + } const result: Array<{ value: string; label: string; hint: string }> = []; - // Add base models - result.push(...sortedBase.map(m => ({ - value: m.handle, - label: m.display_name || m.name, - hint: TIER_LABELS[m.tier || 'free'] || '', - }))); - - // Add top 3 BYOK models inline - result.push(...sortedByok.map(m => ({ - value: m.handle, - label: m.display_name || m.name, - hint: BYOK_LABEL, - }))); + if (isFreeTier) { + // Free tier: Show free models first + const freeModels = models.filter(m => m.free); + result.push(...freeModels.map(m => ({ + value: m.handle, + label: m.label, + hint: `🆓 Free - ${m.description}`, + }))); + + // Fetch BYOK models from API + const byokModels = await fetchByokModels(options?.apiKey); + if (byokModels.length > 0) { + result.push({ + value: '__byok_header__', + label: '── Your Connected Providers ──', + hint: 'Models from your API keys', + }); + + result.push(...byokModels.map(m => ({ + value: m.handle, + label: m.display_name || m.name, + hint: `🔑 ${m.provider_name}`, + }))); + } + } else { + // Paid tier: Show featured models first + const featured = models.filter(m => m.isFeatured); + const nonFeatured = models.filter(m => !m.isFeatured); + + result.push(...featured.map(m => ({ + value: m.handle, + label: m.label, + hint: m.free ? `🆓 Free - ${m.description}` : `⭐ ${m.description}`, + }))); + + result.push(...nonFeatured.map(m => ({ + value: m.handle, + label: m.label, + hint: m.description, + }))); + } // Add custom option result.push({ value: '__custom__', - label: 'Custom model', - hint: 'Enter handle: provider/model-name' + label: 'Other (specify handle)', + hint: 'e.g. anthropic/claude-sonnet-4-5-20250929' }); return result; } - +/** + * Build model options from self-hosted server + */ +async function buildServerModelOptions(): Promise> { + const { listModels } = await import('../tools/letta-api.js'); + + // Fetch all models from server + const serverModels = await listModels(); + + const result: Array<{ value: string; label: string; hint: string }> = []; + + // Sort by display name + const sorted = serverModels.sort((a, b) => + (a.display_name || a.name).localeCompare(b.display_name || b.name) + ); + + result.push(...sorted.map(m => ({ + value: m.handle, + label: m.display_name || m.name, + hint: m.handle, + }))); + + // Add custom option + result.push({ + value: '__custom__', + label: 'Other (specify handle)', + hint: 'e.g. anthropic/claude-sonnet-4-5-20250929' + }); + + return result; +} /** * Handle model selection including custom input @@ -83,6 +225,9 @@ export async function handleModelSelection( const p = await import('@clack/prompts'); if (p.isCancel(selection)) return null; + // Skip header selections + if (selection === '__byok_header__') return null; + // Handle custom model input if (selection === '__custom__') { const custom = await promptFn({