From b72150c1932c1a3345b0eda63aced5ed58beb706 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Wed, 28 Jan 2026 22:50:49 -0800 Subject: [PATCH] Add YAML config system (types and io) --- package-lock.json | 18 +++- package.json | 3 +- src/config/index.ts | 2 + src/config/io.ts | 220 ++++++++++++++++++++++++++++++++++++++++++++ src/config/types.ts | 93 +++++++++++++++++++ 5 files changed, 334 insertions(+), 2 deletions(-) create mode 100644 src/config/index.ts create mode 100644 src/config/io.ts create mode 100644 src/config/types.ts diff --git a/package-lock.json b/package-lock.json index 65a9f5c..ca73b42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "bin": { "lettabot": "dist/cli.js", @@ -6384,6 +6385,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index 7fd4665..d92d556 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,8 @@ "qrcode-terminal": "^0.12.0", "telegram-markdown-v2": "^0.0.4", "tsx": "^4.21.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.8.2" }, "optionalDependencies": { "@slack/bolt": "^4.6.0", diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..47bd215 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './types.js'; +export * from './io.js'; diff --git a/src/config/io.ts b/src/config/io.ts new file mode 100644 index 0000000..1b6fe79 --- /dev/null +++ b/src/config/io.ts @@ -0,0 +1,220 @@ +/** + * LettaBot Configuration I/O + * + * Config file location: ~/.lettabot/config.yaml (or ./lettabot.yaml in project) + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import YAML from 'yaml'; +import type { LettaBotConfig, ProviderConfig } from './types.js'; +import { DEFAULT_CONFIG } from './types.js'; + +// Config file locations (checked in order) +const CONFIG_PATHS = [ + resolve(process.cwd(), 'lettabot.yaml'), // Project-local + resolve(process.cwd(), 'lettabot.yml'), // Project-local alt + join(homedir(), '.lettabot', 'config.yaml'), // User global + join(homedir(), '.lettabot', 'config.yml'), // User global alt +]; + +const DEFAULT_CONFIG_PATH = join(homedir(), '.lettabot', 'config.yaml'); + +/** + * Find the config file path (first existing, or default) + */ +export function resolveConfigPath(): string { + for (const p of CONFIG_PATHS) { + if (existsSync(p)) { + return p; + } + } + return DEFAULT_CONFIG_PATH; +} + +/** + * Load config from YAML file + */ +export function loadConfig(): LettaBotConfig { + const configPath = resolveConfigPath(); + + if (!existsSync(configPath)) { + return { ...DEFAULT_CONFIG }; + } + + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = YAML.parse(content) as Partial; + + // Merge with defaults + return { + ...DEFAULT_CONFIG, + ...parsed, + server: { ...DEFAULT_CONFIG.server, ...parsed.server }, + agent: { ...DEFAULT_CONFIG.agent, ...parsed.agent }, + channels: { ...DEFAULT_CONFIG.channels, ...parsed.channels }, + }; + } catch (err) { + console.error(`[Config] Failed to load ${configPath}:`, err); + return { ...DEFAULT_CONFIG }; + } +} + +/** + * Save config to YAML file + */ +export function saveConfig(config: LettaBotConfig, path?: string): void { + const configPath = path || resolveConfigPath(); + + // Ensure directory exists + const dir = dirname(configPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + // Convert to YAML with comments + const content = YAML.stringify(config, { + indent: 2, + lineWidth: 0, // Don't wrap lines + }); + + writeFileSync(configPath, content, 'utf-8'); + console.log(`[Config] Saved to ${configPath}`); +} + +/** + * Get environment variables from config (for backwards compatibility) + */ +export function configToEnv(config: LettaBotConfig): Record { + const env: Record = {}; + + // Server + if (config.server.mode === 'selfhosted' && config.server.baseUrl) { + env.LETTA_BASE_URL = config.server.baseUrl; + } + if (config.server.apiKey) { + env.LETTA_API_KEY = config.server.apiKey; + } + + // Agent + if (config.agent.id) { + env.LETTA_AGENT_ID = config.agent.id; + } + if (config.agent.name) { + env.AGENT_NAME = config.agent.name; + } + if (config.agent.model) { + env.MODEL = config.agent.model; + } + + // Channels + if (config.channels.telegram?.token) { + env.TELEGRAM_BOT_TOKEN = config.channels.telegram.token; + if (config.channels.telegram.dmPolicy) { + env.TELEGRAM_DM_POLICY = config.channels.telegram.dmPolicy; + } + } + if (config.channels.slack?.appToken) { + env.SLACK_APP_TOKEN = config.channels.slack.appToken; + } + if (config.channels.slack?.botToken) { + env.SLACK_BOT_TOKEN = config.channels.slack.botToken; + } + if (config.channels.whatsapp?.enabled) { + env.WHATSAPP_ENABLED = 'true'; + if (config.channels.whatsapp.selfChat) { + env.WHATSAPP_SELF_CHAT_MODE = 'true'; + } + } + if (config.channels.signal?.phone) { + env.SIGNAL_PHONE_NUMBER = config.channels.signal.phone; + } + + // Features + if (config.features?.cron) { + env.CRON_ENABLED = 'true'; + } + if (config.features?.heartbeat?.enabled) { + env.HEARTBEAT_INTERVAL_MIN = String(config.features.heartbeat.intervalMin || 30); + } + + return env; +} + +/** + * Apply config to process.env + */ +export function applyConfigToEnv(config: LettaBotConfig): void { + const env = configToEnv(config); + for (const [key, value] of Object.entries(env)) { + if (!process.env[key]) { + process.env[key] = value; + } + } +} + +/** + * Create BYOK providers on Letta Cloud + */ +export async function syncProviders(config: LettaBotConfig): Promise { + if (config.server.mode !== 'cloud' || !config.server.apiKey) { + return; + } + + if (!config.providers || config.providers.length === 0) { + return; + } + + const apiKey = config.server.apiKey; + const baseUrl = 'https://api.letta.com'; + + // List existing providers + const listResponse = await fetch(`${baseUrl}/v1/providers`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + }); + + const existingProviders = listResponse.ok + ? await listResponse.json() as Array<{ id: string; name: string }> + : []; + + // Create or update each provider + for (const provider of config.providers) { + const existing = existingProviders.find(p => p.name === provider.name); + + try { + if (existing) { + // Update existing + await fetch(`${baseUrl}/v1/providers/${existing.id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ api_key: provider.apiKey }), + }); + console.log(`[Config] Updated provider: ${provider.name}`); + } else { + // Create new + await fetch(`${baseUrl}/v1/providers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + name: provider.name, + provider_type: provider.type, + api_key: provider.apiKey, + }), + }); + console.log(`[Config] Created provider: ${provider.name}`); + } + } catch (err) { + console.error(`[Config] Failed to sync provider ${provider.name}:`, err); + } + } +} diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 0000000..bf8c786 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,93 @@ +/** + * LettaBot Configuration Types + * + * Two modes: + * 1. Self-hosted: Uses baseUrl (e.g., http://localhost:8283), no API key + * 2. Letta Cloud: Uses apiKey, optional BYOK providers + */ + +export interface LettaBotConfig { + // Server connection + server: { + // 'cloud' (api.letta.com) or 'selfhosted' + mode: 'cloud' | 'selfhosted'; + // Only for selfhosted mode + baseUrl?: string; + // Only for cloud mode + apiKey?: string; + }; + + // Agent configuration + agent: { + id?: string; + name: string; + model: string; + }; + + // BYOK providers (cloud mode only) + providers?: ProviderConfig[]; + + // Channel configurations + channels: { + telegram?: TelegramConfig; + slack?: SlackConfig; + whatsapp?: WhatsAppConfig; + signal?: SignalConfig; + }; + + // Features + features?: { + cron?: boolean; + heartbeat?: { + enabled: boolean; + intervalMin?: number; + }; + }; +} + +export interface ProviderConfig { + id: string; // e.g., 'anthropic', 'openai' + name: string; // e.g., 'lc-anthropic' + type: string; // e.g., 'anthropic', 'openai' + apiKey: string; +} + +export interface TelegramConfig { + enabled: boolean; + token?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SlackConfig { + enabled: boolean; + appToken?: string; + botToken?: string; + allowedUsers?: string[]; +} + +export interface WhatsAppConfig { + enabled: boolean; + selfChat?: boolean; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +export interface SignalConfig { + enabled: boolean; + phone?: string; + dmPolicy?: 'pairing' | 'allowlist' | 'open'; + allowedUsers?: string[]; +} + +// Default config +export const DEFAULT_CONFIG: LettaBotConfig = { + server: { + mode: 'cloud', + }, + agent: { + name: 'LettaBot', + model: 'zai/glm-4.7', // Free model default + }, + channels: {}, +};