The model field in lettabot.yaml was redundant and misleading -- the model is configured on the Letta agent server-side, and lettabot shouldn't override it. Users were confused seeing a model in their startup log that didn't match the actual model being used. Changes: - Remove `model` from `LettaBotConfig.agent` (made optional for backward compat) - Remove `model` from `BotConfig` interface and `bot.ts` createAgent() calls - Remove `model` from `main.ts` config construction and startup log - Stop saving `model` to lettabot.yaml during onboarding - Stop mapping `agent.model` to `MODEL` env var in config/io.ts - Add `getAgentModel()` and `updateAgentModel()` to letta-api.ts - Add new `src/commands/model.ts` with three subcommands: - `lettabot model` - interactive model selector - `lettabot model show` - show current agent model - `lettabot model set <handle>` - set model directly - Wire up model command in cli.ts with help text - Update docs/configuration.md, lettabot.example.yaml, SKILL.md Model selection during `lettabot onboard` is preserved for new agent creation -- the selected model is passed to createAgent() but is NOT saved to the config file. Fixes #169 Co-authored-by: Cameron <cpfiffer@users.noreply.github.com>
264 lines
7.7 KiB
TypeScript
264 lines
7.7 KiB
TypeScript
/**
|
|
* 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)
|
|
*
|
|
* Priority:
|
|
* 1. LETTABOT_CONFIG env var (explicit override)
|
|
* 2. ./lettabot.yaml (project-local)
|
|
* 3. ./lettabot.yml (project-local alt)
|
|
* 4. ~/.lettabot/config.yaml (user global)
|
|
* 5. ~/.lettabot/config.yml (user global alt)
|
|
*/
|
|
export function resolveConfigPath(): string {
|
|
// Environment variable takes priority
|
|
if (process.env.LETTABOT_CONFIG) {
|
|
return resolve(process.env.LETTABOT_CONFIG);
|
|
}
|
|
|
|
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;
|
|
}
|
|
// Note: agent.model is intentionally NOT mapped to env.
|
|
// The model is configured on the Letta agent server-side.
|
|
|
|
// 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';
|
|
} else {
|
|
env.WHATSAPP_SELF_CHAT_MODE = 'false';
|
|
}
|
|
}
|
|
if (config.channels.signal?.phone) {
|
|
env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone;
|
|
// Signal selfChat defaults to true, so only set env if explicitly false
|
|
if (config.channels.signal.selfChat === false) {
|
|
env.SIGNAL_SELF_CHAT_MODE = 'false';
|
|
}
|
|
}
|
|
if (config.channels.discord?.token) {
|
|
env.DISCORD_BOT_TOKEN = config.channels.discord.token;
|
|
if (config.channels.discord.dmPolicy) {
|
|
env.DISCORD_DM_POLICY = config.channels.discord.dmPolicy;
|
|
}
|
|
if (config.channels.discord.allowedUsers?.length) {
|
|
env.DISCORD_ALLOWED_USERS = config.channels.discord.allowedUsers.join(',');
|
|
}
|
|
}
|
|
|
|
// Features
|
|
if (config.features?.cron) {
|
|
env.CRON_ENABLED = 'true';
|
|
}
|
|
if (config.features?.heartbeat?.enabled) {
|
|
env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30);
|
|
}
|
|
if (config.features?.maxToolCalls !== undefined) {
|
|
env.MAX_TOOL_CALLS = String(config.features.maxToolCalls);
|
|
}
|
|
|
|
// Integrations - Google (Gmail polling)
|
|
if (config.integrations?.google?.enabled && config.integrations.google.account) {
|
|
env.GMAIL_ACCOUNT = config.integrations.google.account;
|
|
}
|
|
if (config.integrations?.google?.pollIntervalSec) {
|
|
env.POLLING_INTERVAL_MS = String(config.integrations.google.pollIntervalSec * 1000);
|
|
}
|
|
|
|
if (config.attachments?.maxMB !== undefined) {
|
|
env.ATTACHMENTS_MAX_MB = String(config.attachments.maxMB);
|
|
}
|
|
if (config.attachments?.maxAgeDays !== undefined) {
|
|
env.ATTACHMENTS_MAX_AGE_DAYS = String(config.attachments.maxAgeDays);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|