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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
52
lettabot.example.yaml
Normal 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
18
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
190
src/cli.ts
190
src/cli.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
2
src/config/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './types.js';
|
||||||
|
export * from './io.js';
|
||||||
219
src/config/io.ts
Normal file
219
src/config/io.ts
Normal 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
93
src/config/types.ts
Normal 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: {},
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/main.ts
23
src/main.ts
@@ -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
61
src/models.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
422
src/onboard.ts
422
src/onboard.ts
@@ -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) {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user