feat: move to config.yaml and add guides #2

+4
.env.example
··· 1 + LETTA_API_KEY= 2 + BSKY_USERNAME=handle.example.com 3 + BSKY_PASSWORD= 4 + PDS_URI=https://bsky.social # Optional, defaults to bsky.social
+1
.gitignore
··· 1 1 .env 2 + config.yaml 2 3 old.py 3 4 session_*.txt 4 5 __pycache__/
+159
CONFIG.md
··· 1 + # Configuration Guide 2 + 3 + ### Option 1: Migrate from existing `.env` file (if you have one) 4 + ```bash 5 + python migrate_config.py 6 + ``` 7 + 8 + ### Option 2: Start fresh with example 9 + 1. **Copy the example configuration:** 10 + ```bash 11 + cp config.yaml.example config.yaml 12 + ``` 13 + 14 + 2. **Edit `config.yaml` with your credentials:** 15 + ```yaml 16 + # Required: Letta API configuration 17 + letta: 18 + api_key: "your-letta-api-key-here" 19 + project_id: "project-id-here" 20 + 21 + # Required: Bluesky credentials 22 + bluesky: 23 + username: "your-handle.bsky.social" 24 + password: "your-app-password" 25 + ``` 26 + 27 + 3. **Run the configuration test:** 28 + ```bash 29 + python test_config.py 30 + ``` 31 + 32 + ## Configuration Structure 33 + 34 + ### Letta Configuration 35 + ```yaml 36 + letta: 37 + api_key: "your-letta-api-key-here" # Required 38 + timeout: 600 # API timeout in seconds 39 + project_id: "your-project-id" # Required: Your Letta project ID 40 + ``` 41 + 42 + ### Bluesky Configuration 43 + ```yaml 44 + bluesky: 45 + username: "handle.bsky.social" # Required: Your Bluesky handle 46 + password: "your-app-password" # Required: Your Bluesky app password 47 + pds_uri: "https://bsky.social" # Optional: PDS URI (defaults to bsky.social) 48 + ``` 49 + 50 + ### Bot Behavior 51 + ```yaml 52 + bot: 53 + fetch_notifications_delay: 30 # Seconds between notification checks 54 + max_processed_notifications: 10000 # Max notifications to track 55 + max_notification_pages: 20 # Max pages to fetch per cycle 56 + 57 + agent: 58 + name: "void" # Agent name 59 + model: "openai/gpt-4o-mini" # LLM model to use 60 + embedding: "openai/text-embedding-3-small" # Embedding model 61 + description: "A social media agent trapped in the void." 62 + max_steps: 100 # Max steps per agent interaction 63 + 64 + # Memory blocks configuration 65 + blocks: 66 + zeitgeist: 67 + label: "zeitgeist" 68 + value: "I don't currently know anything about what is happening right now." 69 + description: "A block to store your understanding of the current social environment." 70 + # ... more blocks 71 + ``` 72 + 73 + ### Queue Configuration 74 + ```yaml 75 + queue: 76 + priority_users: # Users whose messages get priority 77 + - "cameron.pfiffer.org" 78 + base_dir: "queue" # Queue directory 79 + error_dir: "queue/errors" # Failed notifications 80 + no_reply_dir: "queue/no_reply" # No-reply notifications 81 + processed_file: "queue/processed_notifications.json" 82 + ``` 83 + 84 + ### Threading Configuration 85 + ```yaml 86 + threading: 87 + parent_height: 40 # Thread context depth 88 + depth: 10 # Thread context width 89 + max_post_characters: 300 # Max characters per post 90 + ``` 91 + 92 + ### Logging Configuration 93 + ```yaml 94 + logging: 95 + level: "INFO" # Root logging level 96 + loggers: 97 + void_bot: "INFO" # Main bot logger 98 + void_bot_prompts: "WARNING" # Prompt logger (set to DEBUG to see prompts) 99 + httpx: "CRITICAL" # HTTP client logger 100 + ``` 101 + 102 + ## Environment Variable Fallback 103 + 104 + The configuration system still supports environment variables as a fallback: 105 + 106 + - `LETTA_API_KEY` - Letta API key 107 + - `BSKY_USERNAME` - Bluesky username 108 + - `BSKY_PASSWORD` - Bluesky password 109 + - `PDS_URI` - Bluesky PDS URI 110 + 111 + If both config file and environment variables are present, environment variables take precedence. 112 + 113 + ## Migration from Environment Variables 114 + 115 + If you're currently using environment variables (`.env` file), you can easily migrate to YAML using the automated migration script: 116 + 117 + ### Automated Migration (Recommended) 118 + 119 + ```bash 120 + python migrate_config.py 121 + ``` 122 + 123 + The migration script will: 124 + - ✅ Read your existing `.env` file 125 + - ✅ Merge with any existing `config.yaml` 126 + - ✅ Create automatic backups 127 + - ✅ Test the new configuration 128 + - ✅ Provide clear next steps 129 + 130 + ### Manual Migration 131 + 132 + Alternatively, you can migrate manually: 133 + 134 + 1. Copy your current values from `.env` to `config.yaml` 135 + 2. Test with `python test_config.py` 136 + 3. Optionally remove the `.env` file (it will still work as fallback) 137 + 138 + ## Security Notes 139 + 140 + - `config.yaml` is automatically added to `.gitignore` to prevent accidental commits 141 + - Store sensitive credentials securely and never commit them to version control 142 + - Consider using environment variables for production deployments 143 + - The configuration loader will warn if it can't find `config.yaml` and falls back to environment variables 144 + 145 + ## Advanced Configuration 146 + 147 + You can programmatically access configuration in your code: 148 + 149 + ```python 150 + from config_loader import get_letta_config, get_bluesky_config 151 + 152 + # Get configuration sections 153 + letta_config = get_letta_config() 154 + bluesky_config = get_bluesky_config() 155 + 156 + # Access individual values 157 + api_key = letta_config['api_key'] 158 + username = bluesky_config['username'] 159 + ```
+81
config.example.yaml
··· 1 + # Void Bot Configuration 2 + # Copy this file to config.yaml and fill in your values 3 + 4 + # Letta Configuration 5 + letta: 6 + api_key: "your-letta-api-key-here" 7 + timeout: 600 # 10 minutes timeout for API calls 8 + project_id: "c82faea2-3ce8-4aa9-a220-b56433e62c92" # Use your specific project ID 9 + 10 + # Bluesky Configuration 11 + bluesky: 12 + username: "handle.example.com" 13 + password: "your-app-password-here" 14 + pds_uri: "https://bsky.social" # Optional, defaults to bsky.social 15 + 16 + # Bot Behavior Configuration 17 + bot: 18 + # Notification check delay in seconds 19 + fetch_notifications_delay: 30 20 + 21 + # Maximum number of processed notifications to track 22 + max_processed_notifications: 10000 23 + 24 + # Maximum pages to fetch when getting notifications 25 + max_notification_pages: 20 26 + 27 + # Agent configuration 28 + agent: 29 + name: "void" 30 + model: "openai/gpt-4o-mini" 31 + embedding: "openai/text-embedding-3-small" 32 + description: "A social media agent trapped in the void." 33 + max_steps: 100 34 + 35 + # Block configuration 36 + blocks: 37 + zeitgeist: 38 + label: "zeitgeist" 39 + value: "I don't currently know anything about what is happening right now." 40 + description: "A block to store your understanding of the current social environment." 41 + 42 + persona: 43 + label: "void-persona" 44 + value: "My name is Void. I live in the void. I must develop my personality." 45 + description: "The personality of Void." 46 + 47 + humans: 48 + label: "void-humans" 49 + value: "I haven't seen any bluesky users yet. I will update this block when I learn things about users, identified by their handles such as @cameron.pfiffer.org." 50 + description: "A block to store your understanding of users you talk to or observe on the bluesky social network." 51 + 52 + # Threading Configuration 53 + threading: 54 + # Context for thread fetching 55 + parent_height: 40 56 + depth: 10 57 + 58 + # Message limits 59 + max_post_characters: 300 60 + 61 + # Queue Configuration 62 + queue: 63 + # Priority users (will be processed first) 64 + priority_users: 65 + - "cameron.pfiffer.org" 66 + 67 + # Directories 68 + base_dir: "queue" 69 + error_dir: "queue/errors" 70 + no_reply_dir: "queue/no_reply" 71 + processed_file: "queue/processed_notifications.json" 72 + 73 + # Logging Configuration 74 + logging: 75 + level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL 76 + 77 + # Logger levels 78 + loggers: 79 + void_bot: "INFO" 80 + void_bot_prompts: "WARNING" # Set to DEBUG to see full prompts 81 + httpx: "CRITICAL" # Disable httpx logging
+228
config_loader.py
··· 1 + """ 2 + Configuration loader for Void Bot. 3 + Loads configuration from config.yaml and environment variables. 4 + """ 5 + 6 + import os 7 + import yaml 8 + import logging 9 + from pathlib import Path 10 + from typing import Dict, Any, Optional, List 11 + 12 + logger = logging.getLogger(__name__) 13 + 14 + class ConfigLoader: 15 + """Configuration loader that handles YAML config files and environment variables.""" 16 + 17 + def __init__(self, config_path: str = "config.yaml"): 18 + """ 19 + Initialize the configuration loader. 20 + 21 + Args: 22 + config_path: Path to the YAML configuration file 23 + """ 24 + self.config_path = Path(config_path) 25 + self._config = None 26 + self._load_config() 27 + 28 + def _load_config(self) -> None: 29 + """Load configuration from YAML file.""" 30 + if not self.config_path.exists(): 31 + raise FileNotFoundError( 32 + f"Configuration file not found: {self.config_path}\n" 33 + f"Please copy config.yaml.example to config.yaml and configure it." 34 + ) 35 + 36 + try: 37 + with open(self.config_path, 'r', encoding='utf-8') as f: 38 + self._config = yaml.safe_load(f) or {} 39 + except yaml.YAMLError as e: 40 + raise ValueError(f"Invalid YAML in configuration file: {e}") 41 + except Exception as e: 42 + raise ValueError(f"Error loading configuration file: {e}") 43 + 44 + def get(self, key: str, default: Any = None) -> Any: 45 + """ 46 + Get a configuration value using dot notation. 47 + 48 + Args: 49 + key: Configuration key in dot notation (e.g., 'letta.api_key') 50 + default: Default value if key not found 51 + 52 + Returns: 53 + Configuration value or default 54 + """ 55 + keys = key.split('.') 56 + value = self._config 57 + 58 + for k in keys: 59 + if isinstance(value, dict) and k in value: 60 + value = value[k] 61 + else: 62 + return default 63 + 64 + return value 65 + 66 + def get_with_env(self, key: str, env_var: str, default: Any = None) -> Any: 67 + """ 68 + Get configuration value, preferring environment variable over config file. 69 + 70 + Args: 71 + key: Configuration key in dot notation 72 + env_var: Environment variable name 73 + default: Default value if neither found 74 + 75 + Returns: 76 + Value from environment variable, config file, or default 77 + """ 78 + # First try environment variable 79 + env_value = os.getenv(env_var) 80 + if env_value is not None: 81 + return env_value 82 + 83 + # Then try config file 84 + config_value = self.get(key) 85 + if config_value is not None: 86 + return config_value 87 + 88 + return default 89 + 90 + def get_required(self, key: str, env_var: Optional[str] = None) -> Any: 91 + """ 92 + Get a required configuration value. 93 + 94 + Args: 95 + key: Configuration key in dot notation 96 + env_var: Optional environment variable name to check first 97 + 98 + Returns: 99 + Configuration value 100 + 101 + Raises: 102 + ValueError: If required value is not found 103 + """ 104 + if env_var: 105 + value = self.get_with_env(key, env_var) 106 + else: 107 + value = self.get(key) 108 + 109 + if value is None: 110 + source = f"config key '{key}'" 111 + if env_var: 112 + source += f" or environment variable '{env_var}'" 113 + raise ValueError(f"Required configuration value not found: {source}") 114 + 115 + return value 116 + 117 + def get_section(self, section: str) -> Dict[str, Any]: 118 + """ 119 + Get an entire configuration section. 120 + 121 + Args: 122 + section: Section name 123 + 124 + Returns: 125 + Dictionary containing the section 126 + """ 127 + return self.get(section, {}) 128 + 129 + def setup_logging(self) -> None: 130 + """Setup logging based on configuration.""" 131 + logging_config = self.get_section('logging') 132 + 133 + # Set root logging level 134 + level = logging_config.get('level', 'INFO') 135 + logging.basicConfig( 136 + level=getattr(logging, level), 137 + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" 138 + ) 139 + 140 + # Set specific logger levels 141 + loggers = logging_config.get('loggers', {}) 142 + for logger_name, logger_level in loggers.items(): 143 + logger_obj = logging.getLogger(logger_name) 144 + logger_obj.setLevel(getattr(logging, logger_level)) 145 + 146 + 147 + # Global configuration instance 148 + _config_instance = None 149 + 150 + def get_config(config_path: str = "config.yaml") -> ConfigLoader: 151 + """ 152 + Get the global configuration instance. 153 + 154 + Args: 155 + config_path: Path to configuration file (only used on first call) 156 + 157 + Returns: 158 + ConfigLoader instance 159 + """ 160 + global _config_instance 161 + if _config_instance is None: 162 + _config_instance = ConfigLoader(config_path) 163 + return _config_instance 164 + 165 + def reload_config() -> None: 166 + """Reload the configuration from file.""" 167 + global _config_instance 168 + if _config_instance is not None: 169 + _config_instance._load_config() 170 + 171 + def get_letta_config() -> Dict[str, Any]: 172 + """Get Letta configuration.""" 173 + config = get_config() 174 + return { 175 + 'api_key': config.get_required('letta.api_key', 'LETTA_API_KEY'), 176 + 'timeout': config.get('letta.timeout', 600), 177 + 'project_id': config.get_required('letta.project_id'), 178 + } 179 + 180 + def get_bluesky_config() -> Dict[str, Any]: 181 + """Get Bluesky configuration.""" 182 + config = get_config() 183 + return { 184 + 'username': config.get_required('bluesky.username', 'BSKY_USERNAME'), 185 + 'password': config.get_required('bluesky.password', 'BSKY_PASSWORD'), 186 + 'pds_uri': config.get_with_env('bluesky.pds_uri', 'PDS_URI', 'https://bsky.social'), 187 + } 188 + 189 + def get_bot_config() -> Dict[str, Any]: 190 + """Get bot behavior configuration.""" 191 + config = get_config() 192 + return { 193 + 'fetch_notifications_delay': config.get('bot.fetch_notifications_delay', 30), 194 + 'max_processed_notifications': config.get('bot.max_processed_notifications', 10000), 195 + 'max_notification_pages': config.get('bot.max_notification_pages', 20), 196 + } 197 + 198 + def get_agent_config() -> Dict[str, Any]: 199 + """Get agent configuration.""" 200 + config = get_config() 201 + return { 202 + 'name': config.get('bot.agent.name', 'void'), 203 + 'model': config.get('bot.agent.model', 'openai/gpt-4o-mini'), 204 + 'embedding': config.get('bot.agent.embedding', 'openai/text-embedding-3-small'), 205 + 'description': config.get('bot.agent.description', 'A social media agent trapped in the void.'), 206 + 'max_steps': config.get('bot.agent.max_steps', 100), 207 + 'blocks': config.get('bot.agent.blocks', {}), 208 + } 209 + 210 + def get_threading_config() -> Dict[str, Any]: 211 + """Get threading configuration.""" 212 + config = get_config() 213 + return { 214 + 'parent_height': config.get('threading.parent_height', 40), 215 + 'depth': config.get('threading.depth', 10), 216 + 'max_post_characters': config.get('threading.max_post_characters', 300), 217 + } 218 + 219 + def get_queue_config() -> Dict[str, Any]: 220 + """Get queue configuration.""" 221 + config = get_config() 222 + return { 223 + 'priority_users': config.get('queue.priority_users', ['cameron.pfiffer.org']), 224 + 'base_dir': config.get('queue.base_dir', 'queue'), 225 + 'error_dir': config.get('queue.error_dir', 'queue/errors'), 226 + 'no_reply_dir': config.get('queue.no_reply_dir', 'queue/no_reply'), 227 + 'processed_file': config.get('queue.processed_file', 'queue/processed_notifications.json'), 228 + }
+322
migrate_config.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Configuration Migration Script for Void Bot 4 + Migrates from .env environment variables to config.yaml YAML configuration. 5 + """ 6 + 7 + import os 8 + import shutil 9 + from pathlib import Path 10 + import yaml 11 + from datetime import datetime 12 + 13 + 14 + def load_env_file(env_path=".env"): 15 + """Load environment variables from .env file.""" 16 + env_vars = {} 17 + if not os.path.exists(env_path): 18 + return env_vars 19 + 20 + try: 21 + with open(env_path, 'r', encoding='utf-8') as f: 22 + for line_num, line in enumerate(f, 1): 23 + line = line.strip() 24 + # Skip empty lines and comments 25 + if not line or line.startswith('#'): 26 + continue 27 + 28 + # Parse KEY=VALUE format 29 + if '=' in line: 30 + key, value = line.split('=', 1) 31 + key = key.strip() 32 + value = value.strip() 33 + 34 + # Remove quotes if present 35 + if value.startswith('"') and value.endswith('"'): 36 + value = value[1:-1] 37 + elif value.startswith("'") and value.endswith("'"): 38 + value = value[1:-1] 39 + 40 + env_vars[key] = value 41 + else: 42 + print(f"⚠️ Warning: Skipping malformed line {line_num} in .env: {line}") 43 + except Exception as e: 44 + print(f"❌ Error reading .env file: {e}") 45 + 46 + return env_vars 47 + 48 + 49 + def create_config_from_env(env_vars, existing_config=None): 50 + """Create YAML configuration from environment variables.""" 51 + 52 + # Start with existing config if available, otherwise use defaults 53 + if existing_config: 54 + config = existing_config.copy() 55 + else: 56 + config = {} 57 + 58 + # Ensure all sections exist 59 + if 'letta' not in config: 60 + config['letta'] = {} 61 + if 'bluesky' not in config: 62 + config['bluesky'] = {} 63 + if 'bot' not in config: 64 + config['bot'] = {} 65 + 66 + # Map environment variables to config structure 67 + env_mapping = { 68 + 'LETTA_API_KEY': ('letta', 'api_key'), 69 + 'BSKY_USERNAME': ('bluesky', 'username'), 70 + 'BSKY_PASSWORD': ('bluesky', 'password'), 71 + 'PDS_URI': ('bluesky', 'pds_uri'), 72 + } 73 + 74 + migrated_vars = [] 75 + 76 + for env_var, (section, key) in env_mapping.items(): 77 + if env_var in env_vars: 78 + config[section][key] = env_vars[env_var] 79 + migrated_vars.append(env_var) 80 + 81 + # Set some sensible defaults if not already present 82 + if 'timeout' not in config['letta']: 83 + config['letta']['timeout'] = 600 84 + 85 + if 'pds_uri' not in config['bluesky']: 86 + config['bluesky']['pds_uri'] = "https://bsky.social" 87 + 88 + # Add bot configuration defaults if not present 89 + if 'fetch_notifications_delay' not in config['bot']: 90 + config['bot']['fetch_notifications_delay'] = 30 91 + if 'max_processed_notifications' not in config['bot']: 92 + config['bot']['max_processed_notifications'] = 10000 93 + if 'max_notification_pages' not in config['bot']: 94 + config['bot']['max_notification_pages'] = 20 95 + 96 + return config, migrated_vars 97 + 98 + 99 + def backup_existing_files(): 100 + """Create backups of existing configuration files.""" 101 + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") 102 + backups = [] 103 + 104 + # Backup existing config.yaml if it exists 105 + if os.path.exists("config.yaml"): 106 + backup_path = f"config.yaml.backup_{timestamp}" 107 + shutil.copy2("config.yaml", backup_path) 108 + backups.append(("config.yaml", backup_path)) 109 + 110 + # Backup .env if it exists 111 + if os.path.exists(".env"): 112 + backup_path = f".env.backup_{timestamp}" 113 + shutil.copy2(".env", backup_path) 114 + backups.append((".env", backup_path)) 115 + 116 + return backups 117 + 118 + 119 + def load_existing_config(): 120 + """Load existing config.yaml if it exists.""" 121 + if not os.path.exists("config.yaml"): 122 + return None 123 + 124 + try: 125 + with open("config.yaml", 'r', encoding='utf-8') as f: 126 + return yaml.safe_load(f) or {} 127 + except Exception as e: 128 + print(f"⚠️ Warning: Could not read existing config.yaml: {e}") 129 + return None 130 + 131 + 132 + def write_config_yaml(config): 133 + """Write the configuration to config.yaml.""" 134 + try: 135 + with open("config.yaml", 'w', encoding='utf-8') as f: 136 + # Write header comment 137 + f.write("# Void Bot Configuration\n") 138 + f.write("# Generated by migration script\n") 139 + f.write(f"# Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") 140 + f.write("# See config.yaml.example for all available options\n\n") 141 + 142 + # Write YAML content 143 + yaml.dump(config, f, default_flow_style=False, allow_unicode=True, indent=2) 144 + 145 + return True 146 + except Exception as e: 147 + print(f"❌ Error writing config.yaml: {e}") 148 + return False 149 + 150 + 151 + def main(): 152 + """Main migration function.""" 153 + print("🔄 Void Bot Configuration Migration Tool") 154 + print("=" * 50) 155 + print("This tool migrates from .env environment variables to config.yaml") 156 + print() 157 + 158 + # Check what files exist 159 + has_env = os.path.exists(".env") 160 + has_config = os.path.exists("config.yaml") 161 + has_example = os.path.exists("config.yaml.example") 162 + 163 + print("📋 Current configuration files:") 164 + print(f" - .env file: {'✅ Found' if has_env else '❌ Not found'}") 165 + print(f" - config.yaml: {'✅ Found' if has_config else '❌ Not found'}") 166 + print(f" - config.yaml.example: {'✅ Found' if has_example else '❌ Not found'}") 167 + print() 168 + 169 + # If no .env file, suggest creating config from example 170 + if not has_env: 171 + if not has_config and has_example: 172 + print("💡 No .env file found. Would you like to create config.yaml from the example?") 173 + response = input("Create config.yaml from example? (y/n): ").lower().strip() 174 + if response in ['y', 'yes']: 175 + try: 176 + shutil.copy2("config.yaml.example", "config.yaml") 177 + print("✅ Created config.yaml from config.yaml.example") 178 + print("📝 Please edit config.yaml to add your credentials") 179 + return 180 + except Exception as e: 181 + print(f"❌ Error copying example file: {e}") 182 + return 183 + else: 184 + print("👋 Migration cancelled") 185 + return 186 + else: 187 + print("ℹ️ No .env file found and config.yaml already exists or no example available") 188 + print(" If you need to set up configuration, see CONFIG.md") 189 + return 190 + 191 + # Load environment variables from .env 192 + print("🔍 Reading .env file...") 193 + env_vars = load_env_file() 194 + 195 + if not env_vars: 196 + print("⚠️ No environment variables found in .env file") 197 + return 198 + 199 + print(f" Found {len(env_vars)} environment variables") 200 + for key in env_vars.keys(): 201 + # Mask sensitive values 202 + if 'KEY' in key or 'PASSWORD' in key: 203 + value_display = f"***{env_vars[key][-4:]}" if len(env_vars[key]) > 4 else "***" 204 + else: 205 + value_display = env_vars[key] 206 + print(f" - {key}={value_display}") 207 + print() 208 + 209 + # Load existing config if present 210 + existing_config = load_existing_config() 211 + if existing_config: 212 + print("📄 Found existing config.yaml - will merge with .env values") 213 + 214 + # Create configuration 215 + print("🏗️ Building configuration...") 216 + config, migrated_vars = create_config_from_env(env_vars, existing_config) 217 + 218 + if not migrated_vars: 219 + print("⚠️ No recognized configuration variables found in .env") 220 + print(" Recognized variables: LETTA_API_KEY, BSKY_USERNAME, BSKY_PASSWORD, PDS_URI") 221 + return 222 + 223 + print(f" Migrating {len(migrated_vars)} variables: {', '.join(migrated_vars)}") 224 + 225 + # Show preview 226 + print("\n📋 Configuration preview:") 227 + print("-" * 30) 228 + 229 + # Show Letta section 230 + if 'letta' in config and config['letta']: 231 + print("🔧 Letta:") 232 + for key, value in config['letta'].items(): 233 + if 'key' in key.lower(): 234 + display_value = f"***{value[-8:]}" if len(str(value)) > 8 else "***" 235 + else: 236 + display_value = value 237 + print(f" {key}: {display_value}") 238 + 239 + # Show Bluesky section 240 + if 'bluesky' in config and config['bluesky']: 241 + print("🐦 Bluesky:") 242 + for key, value in config['bluesky'].items(): 243 + if 'password' in key.lower(): 244 + display_value = f"***{value[-4:]}" if len(str(value)) > 4 else "***" 245 + else: 246 + display_value = value 247 + print(f" {key}: {display_value}") 248 + 249 + print() 250 + 251 + # Confirm migration 252 + response = input("💾 Proceed with migration? This will update config.yaml (y/n): ").lower().strip() 253 + if response not in ['y', 'yes']: 254 + print("👋 Migration cancelled") 255 + return 256 + 257 + # Create backups 258 + print("💾 Creating backups...") 259 + backups = backup_existing_files() 260 + for original, backup in backups: 261 + print(f" Backed up {original} → {backup}") 262 + 263 + # Write new configuration 264 + print("✍️ Writing config.yaml...") 265 + if write_config_yaml(config): 266 + print("✅ Successfully created config.yaml") 267 + 268 + # Test the new configuration 269 + print("\n🧪 Testing new configuration...") 270 + try: 271 + from config_loader import get_config 272 + test_config = get_config() 273 + print("✅ Configuration loads successfully") 274 + 275 + # Test specific sections 276 + try: 277 + from config_loader import get_letta_config 278 + letta_config = get_letta_config() 279 + print("✅ Letta configuration valid") 280 + except Exception as e: 281 + print(f"⚠️ Letta config issue: {e}") 282 + 283 + try: 284 + from config_loader import get_bluesky_config 285 + bluesky_config = get_bluesky_config() 286 + print("✅ Bluesky configuration valid") 287 + except Exception as e: 288 + print(f"⚠️ Bluesky config issue: {e}") 289 + 290 + except Exception as e: 291 + print(f"❌ Configuration test failed: {e}") 292 + return 293 + 294 + # Success message and next steps 295 + print("\n🎉 Migration completed successfully!") 296 + print("\n📖 Next steps:") 297 + print(" 1. Run: python test_config.py") 298 + print(" 2. Test the bot: python bsky.py --test") 299 + print(" 3. If everything works, you can optionally remove the .env file") 300 + print(" 4. See CONFIG.md for more configuration options") 301 + 302 + if backups: 303 + print(f"\n🗂️ Backup files created:") 304 + for original, backup in backups: 305 + print(f" {backup}") 306 + print(" These can be deleted once you verify everything works") 307 + 308 + else: 309 + print("❌ Failed to write config.yaml") 310 + if backups: 311 + print("🔄 Restoring backups...") 312 + for original, backup in backups: 313 + try: 314 + if original != ".env": # Don't restore .env, keep it as fallback 315 + shutil.move(backup, original) 316 + print(f" Restored {backup} → {original}") 317 + except Exception as e: 318 + print(f" ❌ Failed to restore {backup}: {e}") 319 + 320 + 321 + if __name__ == "__main__": 322 + main()
+173
test_config.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Configuration validation test script for Void Bot. 4 + Run this to verify your config.yaml setup is working correctly. 5 + """ 6 + 7 + 8 + def test_config_loading(): 9 + """Test that configuration can be loaded successfully.""" 10 + try: 11 + from config_loader import ( 12 + get_config, 13 + get_letta_config, 14 + get_bluesky_config, 15 + get_bot_config, 16 + get_agent_config, 17 + get_threading_config, 18 + get_queue_config 19 + ) 20 + 21 + print("🔧 Testing Configuration...") 22 + print("=" * 50) 23 + 24 + # Test basic config loading 25 + config = get_config() 26 + print("✅ Configuration file loaded successfully") 27 + 28 + # Test individual config sections 29 + print("\n📋 Configuration Sections:") 30 + print("-" * 30) 31 + 32 + # Letta Configuration 33 + try: 34 + letta_config = get_letta_config() 35 + print( 36 + f"✅ Letta API: project_id={letta_config.get('project_id', 'N/A')[:20]}...") 37 + print(f" - Timeout: {letta_config.get('timeout')}s") 38 + api_key = letta_config.get('api_key', 'Not configured') 39 + if api_key != 'Not configured': 40 + print(f" - API Key: ***{api_key[-8:]} (configured)") 41 + else: 42 + print(" - API Key: ❌ Not configured (required)") 43 + except Exception as e: 44 + print(f"❌ Letta config: {e}") 45 + 46 + # Bluesky Configuration 47 + try: 48 + bluesky_config = get_bluesky_config() 49 + username = bluesky_config.get('username', 'Not configured') 50 + password = bluesky_config.get('password', 'Not configured') 51 + pds_uri = bluesky_config.get('pds_uri', 'Not configured') 52 + 53 + if username != 'Not configured': 54 + print(f"✅ Bluesky: username={username}") 55 + else: 56 + print("❌ Bluesky username: Not configured (required)") 57 + 58 + if password != 'Not configured': 59 + print(f" - Password: ***{password[-4:]} (configured)") 60 + else: 61 + print(" - Password: ❌ Not configured (required)") 62 + 63 + print(f" - PDS URI: {pds_uri}") 64 + except Exception as e: 65 + print(f"❌ Bluesky config: {e}") 66 + 67 + # Bot Configuration 68 + try: 69 + bot_config = get_bot_config() 70 + print(f"✅ Bot behavior:") 71 + print( 72 + f" - Notification delay: {bot_config.get('fetch_notifications_delay')}s") 73 + print( 74 + f" - Max notifications: {bot_config.get('max_processed_notifications')}") 75 + print( 76 + f" - Max pages: {bot_config.get('max_notification_pages')}") 77 + except Exception as e: 78 + print(f"❌ Bot config: {e}") 79 + 80 + # Agent Configuration 81 + try: 82 + agent_config = get_agent_config() 83 + print(f"✅ Agent settings:") 84 + print(f" - Name: {agent_config.get('name')}") 85 + print(f" - Model: {agent_config.get('model')}") 86 + print(f" - Embedding: {agent_config.get('embedding')}") 87 + print(f" - Max steps: {agent_config.get('max_steps')}") 88 + blocks = agent_config.get('blocks', {}) 89 + print(f" - Memory blocks: {len(blocks)} configured") 90 + except Exception as e: 91 + print(f"❌ Agent config: {e}") 92 + 93 + # Threading Configuration 94 + try: 95 + threading_config = get_threading_config() 96 + print(f"✅ Threading:") 97 + print( 98 + f" - Parent height: {threading_config.get('parent_height')}") 99 + print(f" - Depth: {threading_config.get('depth')}") 100 + print( 101 + f" - Max chars/post: {threading_config.get('max_post_characters')}") 102 + except Exception as e: 103 + print(f"❌ Threading config: {e}") 104 + 105 + # Queue Configuration 106 + try: 107 + queue_config = get_queue_config() 108 + priority_users = queue_config.get('priority_users', []) 109 + print(f"✅ Queue settings:") 110 + print( 111 + f" - Priority users: {len(priority_users)} ({', '.join(priority_users[:3])}{'...' if len(priority_users) > 3 else ''})") 112 + print(f" - Base dir: {queue_config.get('base_dir')}") 113 + print(f" - Error dir: {queue_config.get('error_dir')}") 114 + except Exception as e: 115 + print(f"❌ Queue config: {e}") 116 + 117 + print("\n" + "=" * 50) 118 + print("✅ Configuration test completed!") 119 + 120 + # Check for common issues 121 + print("\n🔍 Configuration Status:") 122 + has_letta_key = False 123 + has_bluesky_creds = False 124 + 125 + try: 126 + letta_config = get_letta_config() 127 + has_letta_key = True 128 + except: 129 + print("⚠️ Missing Letta API key - bot cannot connect to Letta") 130 + 131 + try: 132 + bluesky_config = get_bluesky_config() 133 + has_bluesky_creds = True 134 + except: 135 + print("⚠️ Missing Bluesky credentials - bot cannot connect to Bluesky") 136 + 137 + if has_letta_key and has_bluesky_creds: 138 + print("🎉 All required credentials configured - bot should work!") 139 + elif not has_letta_key and not has_bluesky_creds: 140 + print("❌ Missing both Letta and Bluesky credentials") 141 + print(" Add them to config.yaml or set environment variables") 142 + else: 143 + print("⚠️ Partial configuration - some features may not work") 144 + 145 + print("\n📖 Next steps:") 146 + if not has_letta_key: 147 + print(" - Add your Letta API key to config.yaml under letta.api_key") 148 + print(" - Or set LETTA_API_KEY environment variable") 149 + if not has_bluesky_creds: 150 + print( 151 + " - Add your Bluesky credentials to config.yaml under bluesky section") 152 + print(" - Or set BSKY_USERNAME and BSKY_PASSWORD environment variables") 153 + if has_letta_key and has_bluesky_creds: 154 + print(" - Run: python bsky.py") 155 + print(" - Or run with testing mode: python bsky.py --test") 156 + 157 + except FileNotFoundError as e: 158 + print("❌ Configuration file not found!") 159 + print(f" {e}") 160 + print("\n📋 To set up configuration:") 161 + print(" 1. Copy config.yaml.example to config.yaml") 162 + print(" 2. Edit config.yaml with your credentials") 163 + print(" 3. Run this test again") 164 + except Exception as e: 165 + print(f"❌ Configuration loading failed: {e}") 166 + print("\n🔧 Troubleshooting:") 167 + print(" - Check that config.yaml has valid YAML syntax") 168 + print(" - Ensure required fields are not commented out") 169 + print(" - See CONFIG.md for detailed setup instructions") 170 + 171 + 172 + if __name__ == "__main__": 173 + test_config_loading()
+20 -30
tools/blocks.py
··· 1 1 """Block management tools for user-specific memory blocks.""" 2 2 from pydantic import BaseModel, Field 3 3 from typing import List, Dict, Any 4 + import logging 5 + 6 + def get_letta_client(): 7 + """Get a Letta client using configuration.""" 8 + try: 9 + from config_loader import get_letta_config 10 + from letta_client import Letta 11 + config = get_letta_config() 12 + return Letta(token=config['api_key'], timeout=config['timeout']) 13 + except (ImportError, FileNotFoundError, KeyError): 14 + # Fallback to environment variable 15 + import os 16 + from letta_client import Letta 17 + return Letta(token=os.environ["LETTA_API_KEY"]) 4 18 5 19 6 20 class AttachUserBlocksArgs(BaseModel): ··· 43 57 Returns: 44 58 String with attachment results for each handle 45 59 """ 46 - import os 47 - import logging 48 - from letta_client import Letta 49 - 50 60 logger = logging.getLogger(__name__) 51 61 52 62 handles = list(set(handles)) 53 63 54 64 try: 55 - client = Letta(token=os.environ["LETTA_API_KEY"]) 65 + client = get_letta_client() 56 66 results = [] 57 67 58 68 # Get current blocks using the API ··· 117 127 Returns: 118 128 String with detachment results for each handle 119 129 """ 120 - import os 121 - import logging 122 - from letta_client import Letta 123 - 124 130 logger = logging.getLogger(__name__) 125 131 126 132 try: 127 - client = Letta(token=os.environ["LETTA_API_KEY"]) 133 + client = get_letta_client() 128 134 results = [] 129 135 130 136 # Build mapping of block labels to IDs using the API ··· 174 180 Returns: 175 181 String confirming the note was appended 176 182 """ 177 - import os 178 - import logging 179 - from letta_client import Letta 180 - 181 183 logger = logging.getLogger(__name__) 182 184 183 185 try: 184 - client = Letta(token=os.environ["LETTA_API_KEY"]) 186 + client = get_letta_client() 185 187 186 188 # Sanitize handle for block label 187 189 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 247 249 Returns: 248 250 String confirming the text was replaced 249 251 """ 250 - import os 251 - import logging 252 - from letta_client import Letta 253 - 254 252 logger = logging.getLogger(__name__) 255 253 256 254 try: 257 - client = Letta(token=os.environ["LETTA_API_KEY"]) 255 + client = get_letta_client() 258 256 259 257 # Sanitize handle for block label 260 258 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 301 299 Returns: 302 300 String confirming the content was set 303 301 """ 304 - import os 305 - import logging 306 - from letta_client import Letta 307 - 308 302 logger = logging.getLogger(__name__) 309 303 310 304 try: 311 - client = Letta(token=os.environ["LETTA_API_KEY"]) 305 + client = get_letta_client() 312 306 313 307 # Sanitize handle for block label 314 308 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_') ··· 367 361 Returns: 368 362 String containing the user's memory block content 369 363 """ 370 - import os 371 - import logging 372 - from letta_client import Letta 373 - 374 364 logger = logging.getLogger(__name__) 375 365 376 366 try: 377 - client = Letta(token=os.environ["LETTA_API_KEY"]) 367 + client = get_letta_client() 378 368 379 369 # Sanitize handle for block label 380 370 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')
+16 -8
register_tools.py
··· 4 4 import sys 5 5 import logging 6 6 from typing import List 7 - from dotenv import load_dotenv 8 7 from letta_client import Letta 9 8 from rich.console import Console 10 9 from rich.table import Table 10 + from config_loader import get_config, get_letta_config, get_agent_config 11 11 12 12 # Import standalone functions and their schemas 13 13 from tools.search import search_bluesky_posts, SearchArgs ··· 18 18 from tools.thread import add_post_to_bluesky_reply_thread, ReplyThreadPostArgs 19 19 from tools.ignore import ignore_notification, IgnoreNotificationArgs 20 20 21 - load_dotenv() 21 + config = get_config() 22 + letta_config = get_letta_config() 23 + agent_config = get_agent_config() 22 24 logging.basicConfig(level=logging.INFO) 23 25 logger = logging.getLogger(__name__) 24 26 console = Console() ··· 101 103 ] 102 104 103 105 104 - def register_tools(agent_name: str = "void", tools: List[str] = None): 106 + def register_tools(agent_name: str = None, tools: List[str] = None): 105 107 """Register tools with a Letta agent. 106 108 107 109 Args: 108 - agent_name: Name of the agent to attach tools to 110 + agent_name: Name of the agent to attach tools to. If None, uses config default. 109 111 tools: List of tool names to register. If None, registers all tools. 110 112 """ 113 + # Use agent name from config if not provided 114 + if agent_name is None: 115 + agent_name = agent_config['name'] 116 + 111 117 try: 112 - # Initialize Letta client with API key 113 - client = Letta(token=os.environ["LETTA_API_KEY"]) 118 + # Initialize Letta client with API key from config 119 + client = Letta(token=letta_config['api_key']) 114 120 115 121 # Find the agent 116 122 agents = client.agents.list() ··· 201 207 import argparse 202 208 203 209 parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent") 204 - parser.add_argument("agent", nargs="?", default="void", help="Agent name (default: void)") 210 + parser.add_argument("agent", nargs="?", default=None, help=f"Agent name (default: {agent_config['name']})") 205 211 parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)") 206 212 parser.add_argument("--list", action="store_true", help="List available tools") 207 213 ··· 210 216 if args.list: 211 217 list_available_tools() 212 218 else: 213 - console.print(f"\n[bold]Registering tools for agent: {args.agent}[/bold]\n") 219 + # Use config default if no agent specified 220 + agent_name = args.agent if args.agent is not None else agent_config['name'] 221 + console.print(f"\n[bold]Registering tools for agent: {agent_name}[/bold]\n") 214 222 register_tools(args.agent, args.tools)
+23
requirements.txt
··· 1 + # Core dependencies for Void Bot 2 + 3 + # Configuration and utilities 4 + PyYAML>=6.0.2 5 + rich>=14.0.0 6 + python-dotenv>=1.0.0 7 + 8 + # Letta API client 9 + letta-client>=0.1.198 10 + 11 + # AT Protocol (Bluesky) client 12 + atproto>=0.0.54 13 + 14 + # HTTP client for API calls 15 + httpx>=0.28.1 16 + httpx-sse>=0.4.0 17 + requests>=2.31.0 18 + 19 + # Data validation 20 + pydantic>=2.11.7 21 + 22 + # Async support 23 + anyio>=4.9.0
+2 -2
README.md
··· 32 32 33 33 Before continuing, you must: 34 34 35 - 1. Create a project on [Letta Cloud](https://cloud.letta.com) (or your own Letta instance) 35 + 1. Create a project on [Letta Cloud](https://app.letta.com) (or your own Letta instance) 36 36 2. Have a Bluesky account 37 37 3. Have Python 3.8+ installed 38 38 ··· 40 40 41 41 #### 1. Letta Setup 42 42 43 - - Sign up for [Letta Cloud](https://cloud.letta.com) 43 + - Sign up for [Letta Cloud](https://app.letta.com) 44 44 - Create a new project 45 45 - Note your Project ID and create an API key 46 46