Merge pull request #5 from letta-ai/feat/selfhosted-onboard-option

Add self-hosted server option to onboard wizard along  and refactor to use config YAML
This commit is contained in:
Sarah Wooders
2026-01-28 23:50:21 -08:00
committed by GitHub
14 changed files with 1080 additions and 318 deletions

4
.gitignore vendored
View File

@@ -41,3 +41,7 @@ letta-code-sdk/
# WhatsApp session (contains credentials) # WhatsApp session (contains credentials)
data/whatsapp-session/ data/whatsapp-session/
# Config with secrets
lettabot.yaml
lettabot.yml

52
lettabot.example.yaml Normal file
View File

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

18
package-lock.json generated
View File

@@ -26,7 +26,8 @@
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"telegram-markdown-v2": "^0.0.4", "telegram-markdown-v2": "^0.0.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"yaml": "^2.8.2"
}, },
"bin": { "bin": {
"lettabot": "dist/cli.js", "lettabot": "dist/cli.js",
@@ -6384,6 +6385,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/yauzl": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",

View File

@@ -52,7 +52,8 @@
"qrcode-terminal": "^0.12.0", "qrcode-terminal": "^0.12.0",
"telegram-markdown-v2": "^0.0.4", "telegram-markdown-v2": "^0.0.4",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"yaml": "^2.8.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"@slack/bolt": "^4.6.0", "@slack/bolt": "^4.6.0",

View File

@@ -8,7 +8,10 @@
* lettabot configure - Configure settings * 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 { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { spawn, spawnSync } from 'node:child_process'; import { spawn, spawnSync } from 'node:child_process';
@@ -18,90 +21,30 @@ const args = process.argv.slice(2);
const command = args[0]; const command = args[0];
const subCommand = args[1]; 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 // Check if value is a placeholder
const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val); const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
// Simple prompt helper
function prompt(question: string): Promise<string> {
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<string, string> {
const env: Record<string, string> = {};
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<string, string>): 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 separate module
import { onboard } from './onboard.js'; import { onboard } from './onboard.js';
async function configure() { async function configure() {
const p = await import('@clack/prompts'); const p = await import('@clack/prompts');
const { resolveConfigPath } = await import('./config/index.js');
p.intro('🤖 LettaBot Configuration'); p.intro('🤖 LettaBot Configuration');
const env = loadEnv(); // Show current config from YAML
// 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;
};
const configRows = [ const configRows = [
['LETTA_API_KEY', checkVar('LETTA_API_KEY') ? '✓ Set' : '✗ Not set'], ['Server Mode', config.server.mode],
['TELEGRAM_BOT_TOKEN', checkVar('TELEGRAM_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], ['API Key', config.server.apiKey ? '✓ Set' : '✗ Not set'],
['SLACK_BOT_TOKEN', checkVar('SLACK_BOT_TOKEN') ? '✓ Set' : '✗ Not set'], ['Agent Name', config.agent.name],
['SLACK_APP_TOKEN', checkVar('SLACK_APP_TOKEN') ? '✓ Set' : '✗ Not set'], ['Model', config.agent.model],
['HEARTBEAT_INTERVAL_MIN', checkVar('HEARTBEAT_INTERVAL_MIN') || 'Not set'], ['Telegram', config.channels.telegram?.enabled ? '✓ Enabled' : '✗ Disabled'],
['CRON_ENABLED', checkVar('CRON_ENABLED') || 'false'], ['Slack', config.channels.slack?.enabled ? '✓ Enabled' : '✗ Disabled'],
['WORKING_DIR', checkVar('WORKING_DIR') || '/tmp/lettabot'], ['Cron', config.features?.cron ? '✓ Enabled' : '✗ Disabled'],
['AGENT_NAME', checkVar('AGENT_NAME') || 'LettaBot'], ['Heartbeat', config.features?.heartbeat?.enabled ? `${config.features.heartbeat.intervalMin}min` : '✗ Disabled'],
['MODEL', checkVar('MODEL') || '(default)'], ['BYOK Providers', config.providers?.length ? config.providers.map(p => p.name).join(', ') : 'None'],
]; ];
const maxKeyLength = Math.max(...configRows.map(([key]) => key.length)); const maxKeyLength = Math.max(...configRows.map(([key]) => key.length));
@@ -109,20 +52,14 @@ async function configure() {
.map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`) .map(([key, value]) => `${(key + ':').padEnd(maxKeyLength + 1)} ${value}`)
.join('\n'); .join('\n');
p.note(summary, 'Current Configuration'); p.note(summary, `Current Configuration (${resolveConfigPath()})`);
const choice = await p.select({ const choice = await p.select({
message: 'What would you like to configure?', message: 'What would you like to do?',
options: [ options: [
{ value: '1', label: 'Letta API Key', hint: '' }, { value: 'onboard', label: 'Run setup wizard', hint: 'lettabot onboard' },
{ value: '2', label: 'Telegram', hint: '' }, { value: 'edit', label: 'Edit config file', hint: resolveConfigPath() },
{ value: '3', label: 'Slack', hint: '' }, { value: 'exit', label: 'Exit', 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: '' },
], ],
}); });
@@ -132,89 +69,28 @@ async function configure() {
} }
switch (choice) { switch (choice) {
case '1': case 'onboard':
env.LETTA_API_KEY = await prompt('Enter Letta API Key: '); await onboard();
saveEnv(env);
console.log('✓ Saved');
break; break;
case '2': case 'edit': {
env.TELEGRAM_BOT_TOKEN = await prompt('Enter Telegram Bot Token: '); const configPath = resolveConfigPath();
saveEnv(env); const editor = process.env.EDITOR || 'nano';
console.log('✓ Saved'); console.log(`Opening ${configPath} in ${editor}...`);
break; spawnSync(editor, [configPath], { stdio: 'inherit' });
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');
break; break;
} }
case '8': case 'exit':
const editor = process.env.EDITOR || 'nano';
spawnSync(editor, [ENV_PATH], { stdio: 'inherit' });
break; break;
case '9':
return;
default:
console.log('Invalid choice');
} }
} }
async function server() { async function server() {
const { resolveConfigPath } = await import('./config/index.js');
const configPath = resolveConfigPath();
// Check if configured // Check if configured
if (!existsSync(ENV_PATH)) { if (!existsSync(configPath)) {
console.log('No .env found. Run "lettabot onboard" first.\n'); console.log(`No config found at ${configPath}. Run "lettabot onboard" first.\n`);
process.exit(1); process.exit(1);
} }

View File

@@ -10,7 +10,10 @@
* (heartbeats, cron jobs) or to send to different channels during conversations. * (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 { resolve } from 'node:path';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';

2
src/config/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './types.js';
export * from './io.js';

219
src/config/io.ts Normal file
View File

@@ -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<LettaBotConfig>;
// 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<string, string> {
const env: Record<string, string> = {};
// 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<void> {
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);
}
}
}

93
src/config/types.ts Normal file
View File

@@ -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: {},
};

View File

@@ -190,19 +190,64 @@ export class LettaBot {
console.log(`[Bot] Session _agentId:`, (session as any)._agentId); console.log(`[Bot] Session _agentId:`, (session as any)._agentId);
console.log(`[Bot] Session options.permissionMode:`, (session as any).options?.permissionMode); 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; const transport = (session as any).transport;
if (transport?.process) { 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) => { transport.process.stderr?.on('data', (data: Buffer) => {
console.error('[Bot] CLI stderr:', data.toString()); 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 <T>(promise: Promise<T>, label: string): Promise<T> => {
let timeoutId: NodeJS.Timeout;
const timeoutPromise = new Promise<T>((_, 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 // Send message to agent with metadata envelope
const formattedMessage = formatMessageEnvelope(msg); const formattedMessage = formatMessageEnvelope(msg);
console.log('[Bot] Sending message...'); console.log('[Bot] Formatted message:', formattedMessage.slice(0, 200));
await session.send(formattedMessage); console.log('[Bot] Target server:', process.env.LETTA_BASE_URL || 'https://api.letta.com (default)');
console.log('[Bot] Message sent, starting stream...'); 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 // Stream response
let response = ''; let response = '';
@@ -214,8 +259,12 @@ export class LettaBot {
adapter.sendTypingIndicator(msg.chatId).catch(() => {}); adapter.sendTypingIndicator(msg.chatId).catch(() => {});
}, 4000); }, 4000);
let streamCount = 0;
try { try {
console.log('[Bot] Entering stream loop...');
for await (const streamMsg of session.stream()) { 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') { if (streamMsg.type === 'assistant') {
response += streamMsg.content; response += streamMsg.content;
@@ -260,32 +309,44 @@ export class LettaBot {
clearInterval(typingInterval); 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 // Send final response
if (response) { if (response) {
console.log(`[Bot] Sending final response (messageId=${messageId})`);
try { try {
if (messageId) { if (messageId) {
await adapter.editMessage(msg.chatId, messageId, response); await adapter.editMessage(msg.chatId, messageId, response);
console.log('[Bot] Edited existing message');
} else { } else {
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); 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 we already sent a streamed message, don't duplicate — the user already saw it.
if (!messageId) { if (!messageId) {
await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId }); await adapter.sendMessage({ chatId: msg.chatId, text: response, threadId: msg.threadId });
} }
} }
} else { } else {
console.log('[Bot] No response from agent, sending placeholder');
await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId }); await adapter.sendMessage({ chatId: msg.chatId, text: '(No response from agent)', threadId: msg.threadId });
} }
} catch (error) { } 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({ await adapter.sendMessage({
chatId: msg.chatId, chatId: msg.chatId,
text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
threadId: msg.threadId, threadId: msg.threadId,
}); });
} finally { } finally {
console.log('[Bot] Closing session');
session!?.close(); session!?.close();
} }
} }

View File

@@ -5,12 +5,21 @@
* Chat continues seamlessly between Telegram, Slack, and WhatsApp. * Chat continues seamlessly between Telegram, Slack, and WhatsApp.
*/ */
import 'dotenv/config';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs'; import { existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { spawn } from 'node:child_process'; 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 and set as env var (SDK needs this)
// Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback // Load agent ID from store file, or use LETTA_AGENT_ID env var as fallback
const STORE_PATH = resolve(process.cwd(), 'lettabot-agent.json'); 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 { agentExists } from './tools/letta-api.js';
import { installSkillsToWorkingDir } from './skills/loader.js'; import { installSkillsToWorkingDir } from './skills/loader.js';
// Check if setup is needed // Check if config exists
const ENV_PATH = resolve(process.cwd(), '.env'); const configPath = resolveConfigPath();
if (!existsSync(ENV_PATH)) { if (!existsSync(configPath)) {
console.log('\n No .env file found. Running setup wizard...\n'); console.log(`\n No config found at ${configPath}. Run "lettabot onboard" first.\n`);
const setupPath = new URL('./setup.ts', import.meta.url).pathname; process.exit(1);
spawn('npx', ['tsx', setupPath], { stdio: 'inherit', cwd: process.cwd() });
process.exit(0);
} }
// Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890") // Parse heartbeat target (format: "telegram:123456789" or "slack:C1234567890")

61
src/models.json Normal file
View File

@@ -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
}
]

View File

@@ -6,9 +6,8 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import * as p from '@clack/prompts'; import * as p from '@clack/prompts';
import { saveConfig, syncProviders } from './config/index.js';
const ENV_PATH = resolve(process.cwd(), '.env'); import type { LettaBotConfig, ProviderConfig } from './config/types.js';
const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
// ============================================================================ // ============================================================================
// Config Types // Config Types
@@ -16,8 +15,10 @@ const ENV_EXAMPLE_PATH = resolve(process.cwd(), '.env.example');
interface OnboardConfig { interface OnboardConfig {
// Auth // Auth
authMethod: 'keep' | 'oauth' | 'apikey' | 'skip'; authMethod: 'keep' | 'oauth' | 'apikey' | 'selfhosted' | 'skip';
apiKey?: string; apiKey?: string;
baseUrl?: string;
billingTier?: string;
// Agent // Agent
agentChoice: 'new' | 'existing' | 'env' | 'skip'; agentChoice: 'new' | 'existing' | 'env' | 'skip';
@@ -27,6 +28,9 @@ interface OnboardConfig {
// Model (only for new agents) // Model (only for new agents)
model?: string; model?: string;
// BYOK Providers (for free tier)
providers?: Array<{ id: string; name: string; apiKey: string }>;
// Channels (with access control) // Channels (with access control)
telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] }; telegram: { enabled: boolean; token?: string; dmPolicy?: 'pairing' | 'allowlist' | 'open'; allowedUsers?: string[] };
slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] }; slack: { enabled: boolean; appToken?: string; botToken?: string; allowedUsers?: string[] };
@@ -39,60 +43,6 @@ interface OnboardConfig {
cron: boolean; cron: boolean;
} }
// ============================================================================
// Env Helpers
// ============================================================================
function loadEnv(): Record<string, string> {
const env: Record<string, string> = {};
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<string, string>): 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<string>();
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); const isPlaceholder = (val?: string) => !val || /^(your_|sk-\.\.\.|placeholder|example)/i.test(val);
// ============================================================================ // ============================================================================
@@ -103,11 +53,12 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js'); const { requestDeviceCode, pollForToken, LETTA_CLOUD_API_URL } = await import('./auth/oauth.js');
const { saveTokens, loadTokens, getOrCreateDeviceId, getDeviceName } = await import('./auth/tokens.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 isLettaCloud = !baseUrl || baseUrl === LETTA_CLOUD_API_URL || baseUrl === 'https://api.letta.com';
const existingTokens = loadTokens(); 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 validOAuthToken = isLettaCloud ? existingTokens?.accessToken : undefined;
const hasExistingAuth = !!realApiKey || !!validOAuthToken; const hasExistingAuth = !!realApiKey || !!validOAuthToken;
const displayKey = realApiKey || validOAuthToken; const displayKey = realApiKey || validOAuthToken;
@@ -123,7 +74,8 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []), ...(hasExistingAuth ? [{ value: 'keep', label: getAuthLabel(), hint: displayKey?.slice(0, 20) + '...' }] : []),
...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []), ...(isLettaCloud ? [{ value: 'oauth', label: 'Login to Letta Platform', hint: 'Opens browser' }] : []),
{ value: 'apikey', label: 'Enter API Key manually', hint: 'Paste your key' }, { 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({ const authMethod = await p.select({
@@ -194,6 +146,22 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
config.apiKey = apiKey; config.apiKey = apiKey;
env.LETTA_API_KEY = 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') { } else if (authMethod === 'keep') {
// For OAuth tokens, refresh if needed // For OAuth tokens, refresh if needed
if (existingTokens?.refreshToken) { if (existingTokens?.refreshToken) {
@@ -238,7 +206,7 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
} }
} }
// Validate connection (only if not skipping auth) // Validate connection (skip if 'skip' was chosen)
if (config.authMethod !== 'skip') { if (config.authMethod !== 'skip') {
const keyToValidate = config.apiKey || env.LETTA_API_KEY; const keyToValidate = config.apiKey || env.LETTA_API_KEY;
if (keyToValidate) { if (keyToValidate) {
@@ -246,11 +214,16 @@ async function stepAuth(config: OnboardConfig, env: Record<string, string>): Pro
} }
const spinner = p.spinner(); const spinner = p.spinner();
spinner.start('Checking connection...'); const serverLabel = config.baseUrl || 'Letta Cloud';
spinner.start(`Checking connection to ${serverLabel}...`);
try { try {
const { testConnection } = await import('./tools/letta-api.js'); const { testConnection } = await import('./tools/letta-api.js');
const ok = await testConnection(); 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 { } catch {
spinner.stop('Connection check skipped'); spinner.stop('Connection check skipped');
} }
@@ -333,28 +306,161 @@ async function stepAgent(config: OnboardConfig, env: Record<string, string>): 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<string, string>): Promise<void> {
// 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<string, string>): Promise<void> { async function stepModel(config: OnboardConfig, env: Record<string, string>): Promise<void> {
// Only for new agents // Only for new agents
if (config.agentChoice !== 'new') return; 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(); 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...'); 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'); spinner.stop('Models loaded');
const modelChoice = await p.select({ // Show appropriate message for free tier
message: 'Select model', if (billingTier === 'free') {
options: modelOptions, p.log.info('Free plan: GLM and MiniMax models are free. Other models require BYOK (Bring Your Own Key).');
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;
} }
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<string, string>): Promise<void> { async function stepChannels(config: OnboardConfig, env: Record<string, string>): Promise<void> {
@@ -623,7 +729,8 @@ function showSummary(config: OnboardConfig): void {
keep: 'Keep existing', keep: 'Keep existing',
oauth: 'OAuth login', oauth: 'OAuth login',
apikey: config.apiKey ? `API Key (${config.apiKey.slice(0, 10)}...)` : 'API Key', 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]; }[config.authMethod];
lines.push(`Auth: ${authLabel}`); lines.push(`Auth: ${authLabel}`);
@@ -681,7 +788,10 @@ async function reviewLoop(config: OnboardConfig, env: Record<string, string>): P
if (choice === 'auth') await stepAuth(config, env); if (choice === 'auth') await stepAuth(config, env);
else if (choice === 'agent') { else if (choice === 'agent') {
await stepAgent(config, env); 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 === 'channels') await stepChannels(config, env);
else if (choice === 'features') await stepFeatures(config); else if (choice === 'features') await stepFeatures(config);
@@ -693,12 +803,23 @@ async function reviewLoop(config: OnboardConfig, env: Record<string, string>): P
// ============================================================================ // ============================================================================
export async function onboard(): Promise<void> { export async function onboard(): Promise<void> {
const env = loadEnv(); // Temporary storage for wizard values
const env: Record<string, string> = {};
// 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'); p.intro('🤖 LettaBot Setup');
// Show server info if (hasExistingConfig) {
const baseUrl = env.LETTA_BASE_URL || process.env.LETTA_BASE_URL || 'https://api.letta.com'; 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'); const isLocal = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server'); p.note(`${baseUrl}\n${isLocal ? 'Local Docker' : 'Letta Cloud'}`, 'Server');
@@ -724,39 +845,62 @@ export async function onboard(): Promise<void> {
} }
// Initialize config from existing env // Initialize config from existing env
// Pre-populate from existing YAML config
const config: OnboardConfig = { const config: OnboardConfig = {
authMethod: 'skip', authMethod: hasExistingConfig ? 'keep' : 'skip',
apiKey: existingConfig.server.apiKey,
baseUrl: existingConfig.server.baseUrl,
telegram: { telegram: {
enabled: !!env.TELEGRAM_BOT_TOKEN && !isPlaceholder(env.TELEGRAM_BOT_TOKEN), enabled: existingConfig.channels.telegram?.enabled || false,
token: isPlaceholder(env.TELEGRAM_BOT_TOKEN) ? undefined : env.TELEGRAM_BOT_TOKEN, token: existingConfig.channels.telegram?.token,
dmPolicy: existingConfig.channels.telegram?.dmPolicy,
allowedUsers: existingConfig.channels.telegram?.allowedUsers?.map(String),
}, },
slack: { slack: {
enabled: !!env.SLACK_BOT_TOKEN, enabled: existingConfig.channels.slack?.enabled || false,
appToken: env.SLACK_APP_TOKEN, appToken: existingConfig.channels.slack?.appToken,
botToken: env.SLACK_BOT_TOKEN, botToken: existingConfig.channels.slack?.botToken,
allowedUsers: existingConfig.channels.slack?.allowedUsers,
}, },
whatsapp: { whatsapp: {
enabled: env.WHATSAPP_ENABLED === 'true', enabled: existingConfig.channels.whatsapp?.enabled || false,
selfChat: env.WHATSAPP_SELF_CHAT_MODE === 'true', selfChat: existingConfig.channels.whatsapp?.selfChat,
dmPolicy: existingConfig.channels.whatsapp?.dmPolicy,
}, },
signal: { signal: {
enabled: !!env.SIGNAL_PHONE_NUMBER, enabled: existingConfig.channels.signal?.enabled || false,
phone: env.SIGNAL_PHONE_NUMBER, phone: existingConfig.channels.signal?.phone,
dmPolicy: existingConfig.channels.signal?.dmPolicy,
}, },
gmail: { enabled: false }, gmail: { enabled: false },
heartbeat: { heartbeat: {
enabled: !!env.HEARTBEAT_INTERVAL_MIN, enabled: existingConfig.features?.heartbeat?.enabled || false,
interval: env.HEARTBEAT_INTERVAL_MIN, interval: existingConfig.features?.heartbeat?.intervalMin?.toString(),
}, },
cron: env.CRON_ENABLED === 'true', cron: existingConfig.features?.cron || false,
agentChoice: 'skip', agentChoice: hasExistingConfig ? 'env' : 'skip',
agentName: env.AGENT_NAME, agentName: existingConfig.agent.name,
model: env.MODEL, 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 // Run through all steps
await stepAuth(config, env); await stepAuth(config, env);
await stepAgent(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 stepModel(config, env);
await stepChannels(config, env); await stepChannels(config, env);
await stepFeatures(config); await stepFeatures(config);
@@ -865,9 +1009,87 @@ export async function onboard(): Promise<void> {
p.note(summary, 'Configuration Summary'); p.note(summary, 'Configuration Summary');
// Save // Convert to YAML config
saveEnv(env); const yamlConfig: LettaBotConfig = {
p.log.success('Configuration saved to .env'); 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 // Save agent ID with server URL
if (config.agentId) { if (config.agentId) {

View File

@@ -1,75 +1,217 @@
/** /**
* Shared utilities for model selection UI * 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 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<string | null> {
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; handle: string;
name: string; name: string;
display_name?: string; display_name?: string;
tier?: string; provider_name: string;
provider_type: string;
} }
const TIER_LABELS: Record<string, string> = { /**
'free': '🆓 Free', * Fetch BYOK models from Letta API
'premium': '⭐ Premium', */
'per-inference': '💰 Pay-per-use', async function fetchByokModels(apiKey?: string): Promise<ByokModel[]> {
}; try {
const key = apiKey || process.env.LETTA_API_KEY;
const BYOK_LABEL = '🔑 BYOK'; 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() * 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<Array<{ value: string; label: string; hint: string }>> { export async function buildModelOptions(options?: {
const { listModels } = await import('../tools/letta-api.js'); billingTier?: string | null;
isSelfHosted?: boolean;
apiKey?: string;
}): Promise<Array<{ value: string; label: string; hint: string }>> {
const billingTier = options?.billingTier;
const isSelfHosted = options?.isSelfHosted;
const isFreeTier = billingTier?.toLowerCase() === 'free';
// Fetch both base and BYOK models // For self-hosted servers, fetch models from server
const [baseModels, byokModels] = await Promise.all([ if (isSelfHosted) {
listModels({ providerCategory: 'base' }), return buildServerModelOptions();
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)
);
const result: Array<{ value: string; label: string; hint: string }> = []; const result: Array<{ value: string; label: string; hint: string }> = [];
// Add base models if (isFreeTier) {
result.push(...sortedBase.map(m => ({ // Free tier: Show free models first
value: m.handle, const freeModels = models.filter(m => m.free);
label: m.display_name || m.name, result.push(...freeModels.map(m => ({
hint: TIER_LABELS[m.tier || 'free'] || '', value: m.handle,
}))); label: m.label,
hint: `🆓 Free - ${m.description}`,
// Add top 3 BYOK models inline })));
result.push(...sortedByok.map(m => ({
value: m.handle, // Fetch BYOK models from API
label: m.display_name || m.name, const byokModels = await fetchByokModels(options?.apiKey);
hint: BYOK_LABEL, 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 // Add custom option
result.push({ result.push({
value: '__custom__', value: '__custom__',
label: 'Custom model', label: 'Other (specify handle)',
hint: 'Enter handle: provider/model-name' hint: 'e.g. anthropic/claude-sonnet-4-5-20250929'
}); });
return result; return result;
} }
/**
* Build model options from self-hosted server
*/
async function buildServerModelOptions(): Promise<Array<{ value: string; label: string; hint: string }>> {
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 * Handle model selection including custom input
@@ -83,6 +225,9 @@ export async function handleModelSelection(
const p = await import('@clack/prompts'); const p = await import('@clack/prompts');
if (p.isCancel(selection)) return null; if (p.isCancel(selection)) return null;
// Skip header selections
if (selection === '__byok_header__') return null;
// Handle custom model input // Handle custom model input
if (selection === '__custom__') { if (selection === '__custom__') {
const custom = await promptFn({ const custom = await promptFn({