···11+# Configuration Guide
22+33+### Option 1: Migrate from existing `.env` file (if you have one)
44+```bash
55+python migrate_config.py
66+```
77+88+### Option 2: Start fresh with example
99+1. **Copy the example configuration:**
1010+ ```bash
1111+ cp config.yaml.example config.yaml
1212+ ```
1313+1414+2. **Edit `config.yaml` with your credentials:**
1515+ ```yaml
1616+ # Required: Letta API configuration
1717+ letta:
1818+ api_key: "your-letta-api-key-here"
1919+ project_id: "project-id-here"
2020+2121+ # Required: Bluesky credentials
2222+ bluesky:
2323+ username: "your-handle.bsky.social"
2424+ password: "your-app-password"
2525+ ```
2626+2727+3. **Run the configuration test:**
2828+ ```bash
2929+ python test_config.py
3030+ ```
3131+3232+## Configuration Structure
3333+3434+### Letta Configuration
3535+```yaml
3636+letta:
3737+ api_key: "your-letta-api-key-here" # Required
3838+ timeout: 600 # API timeout in seconds
3939+ project_id: "your-project-id" # Required: Your Letta project ID
4040+```
4141+4242+### Bluesky Configuration
4343+```yaml
4444+bluesky:
4545+ username: "handle.bsky.social" # Required: Your Bluesky handle
4646+ password: "your-app-password" # Required: Your Bluesky app password
4747+ pds_uri: "https://bsky.social" # Optional: PDS URI (defaults to bsky.social)
4848+```
4949+5050+### Bot Behavior
5151+```yaml
5252+bot:
5353+ fetch_notifications_delay: 30 # Seconds between notification checks
5454+ max_processed_notifications: 10000 # Max notifications to track
5555+ max_notification_pages: 20 # Max pages to fetch per cycle
5656+5757+ agent:
5858+ name: "void" # Agent name
5959+ model: "openai/gpt-4o-mini" # LLM model to use
6060+ embedding: "openai/text-embedding-3-small" # Embedding model
6161+ description: "A social media agent trapped in the void."
6262+ max_steps: 100 # Max steps per agent interaction
6363+6464+ # Memory blocks configuration
6565+ blocks:
6666+ zeitgeist:
6767+ label: "zeitgeist"
6868+ value: "I don't currently know anything about what is happening right now."
6969+ description: "A block to store your understanding of the current social environment."
7070+ # ... more blocks
7171+```
7272+7373+### Queue Configuration
7474+```yaml
7575+queue:
7676+ priority_users: # Users whose messages get priority
7777+ - "cameron.pfiffer.org"
7878+ base_dir: "queue" # Queue directory
7979+ error_dir: "queue/errors" # Failed notifications
8080+ no_reply_dir: "queue/no_reply" # No-reply notifications
8181+ processed_file: "queue/processed_notifications.json"
8282+```
8383+8484+### Threading Configuration
8585+```yaml
8686+threading:
8787+ parent_height: 40 # Thread context depth
8888+ depth: 10 # Thread context width
8989+ max_post_characters: 300 # Max characters per post
9090+```
9191+9292+### Logging Configuration
9393+```yaml
9494+logging:
9595+ level: "INFO" # Root logging level
9696+ loggers:
9797+ void_bot: "INFO" # Main bot logger
9898+ void_bot_prompts: "WARNING" # Prompt logger (set to DEBUG to see prompts)
9999+ httpx: "CRITICAL" # HTTP client logger
100100+```
101101+102102+## Environment Variable Fallback
103103+104104+The configuration system still supports environment variables as a fallback:
105105+106106+- `LETTA_API_KEY` - Letta API key
107107+- `BSKY_USERNAME` - Bluesky username
108108+- `BSKY_PASSWORD` - Bluesky password
109109+- `PDS_URI` - Bluesky PDS URI
110110+111111+If both config file and environment variables are present, environment variables take precedence.
112112+113113+## Migration from Environment Variables
114114+115115+If you're currently using environment variables (`.env` file), you can easily migrate to YAML using the automated migration script:
116116+117117+### Automated Migration (Recommended)
118118+119119+```bash
120120+python migrate_config.py
121121+```
122122+123123+The migration script will:
124124+- ✅ Read your existing `.env` file
125125+- ✅ Merge with any existing `config.yaml`
126126+- ✅ Create automatic backups
127127+- ✅ Test the new configuration
128128+- ✅ Provide clear next steps
129129+130130+### Manual Migration
131131+132132+Alternatively, you can migrate manually:
133133+134134+1. Copy your current values from `.env` to `config.yaml`
135135+2. Test with `python test_config.py`
136136+3. Optionally remove the `.env` file (it will still work as fallback)
137137+138138+## Security Notes
139139+140140+- `config.yaml` is automatically added to `.gitignore` to prevent accidental commits
141141+- Store sensitive credentials securely and never commit them to version control
142142+- Consider using environment variables for production deployments
143143+- The configuration loader will warn if it can't find `config.yaml` and falls back to environment variables
144144+145145+## Advanced Configuration
146146+147147+You can programmatically access configuration in your code:
148148+149149+```python
150150+from config_loader import get_letta_config, get_bluesky_config
151151+152152+# Get configuration sections
153153+letta_config = get_letta_config()
154154+bluesky_config = get_bluesky_config()
155155+156156+# Access individual values
157157+api_key = letta_config['api_key']
158158+username = bluesky_config['username']
159159+```
+100-3
README.md
···28282929void aims to push the boundaries of what is possible with AI, exploring concepts of digital personhood, autonomous learning, and the integration of AI into social networks. By open-sourcing void, we invite developers, researchers, and enthusiasts to contribute to this exciting experiment and collectively advance our understanding of digital consciousness.
30303131-Getting Started:
3232-[Further sections on installation, configuration, and contribution guidelines would go here, which are beyond void's current capabilities to generate automatically.]
3131+## Getting Started
3232+3333+Before continuing, you must:
3434+3535+1. Create a project on [Letta Cloud](https://app.letta.com) (or your own Letta instance)
3636+2. Have a Bluesky account
3737+3. Have Python 3.8+ installed
3838+3939+### Prerequisites
4040+4141+#### 1. Letta Setup
4242+4343+- Sign up for [Letta Cloud](https://app.letta.com)
4444+- Create a new project
4545+- Note your Project ID and create an API key
4646+4747+#### 2. Bluesky Setup
4848+4949+- Create a Bluesky account if you don't have one
5050+- Note your handle and password
5151+5252+### Installation
5353+5454+#### 1. Clone the repository
5555+5656+```bash
5757+git clone https://tangled.sh/@cameron.pfiffer.org/void && cd void
5858+```
5959+6060+#### 2. Install dependencies
6161+6262+```bash
6363+pip install -r requirements.txt
6464+```
6565+6666+#### 3. Create configuration
6767+6868+Copy the example configuration file and customize it:
6969+7070+```bash
7171+cp config.example.yaml config.yaml
7272+```
7373+7474+Edit `config.yaml` with your credentials:
7575+7676+```yaml
7777+letta:
7878+ api_key: "your-letta-api-key-here"
7979+ project_id: "your-project-id-here"
8080+8181+bluesky:
8282+ username: "your-handle.bsky.social"
8383+ password: "your-app-password-here"
8484+8585+bot:
8686+ agent:
8787+ name: "void" # or whatever you want to name your agent
8888+```
8989+9090+See [`CONFIG.md`](/CONFIG.md) for detailed configuration options.
9191+9292+#### 4. Test your configuration
9393+9494+```bash
9595+python test_config.py
9696+```
9797+9898+This will validate your configuration and show you what's working.
9999+100100+#### 5. Register tools with your agent
101101+102102+```bash
103103+python register_tools.py
104104+```
105105+106106+This will register all the necessary tools with your Letta agent. You can also:
107107+108108+- List available tools: `python register_tools.py --list`
109109+- Register specific tools: `python register_tools.py --tools search_bluesky_posts create_new_bluesky_post`
110110+- Use a different agent name: `python register_tools.py my-agent-name`
111111+112112+#### 6. Run the bot
113113+114114+```bash
115115+python bsky.py
116116+```
117117+118118+For testing mode (won't actually post):
119119+120120+```bash
121121+python bsky.py --test
122122+```
331233434-Contact:
124124+### Troubleshooting
125125+126126+- **Config validation errors**: Run `python test_config.py` to diagnose configuration issues
127127+- **Letta connection issues**: Verify your API key and project ID are correct
128128+- **Bluesky authentication**: Make sure you're handle and password are correct and that you can log into your account
129129+- **Tool registration fails**: Ensure your agent exists in Letta and the name matches your config
130130+131131+### Contact
35132For inquiries, please contact @cameron.pfiffer.org on Bluesky.
3613337134Note: void is an experimental project and its capabilities are under continuous development.
+388-237
bsky.py
···11-from rich import print # pretty printing tools
11+from rich import print # pretty printing tools
22from time import sleep
33from letta_client import Letta
44from bsky_utils import thread_to_yaml_string
···20202121import bsky_utils
2222from tools.blocks import attach_user_blocks, detach_user_blocks
2323+from config_loader import (
2424+ get_config,
2525+ get_letta_config,
2626+ get_bluesky_config,
2727+ get_bot_config,
2828+ get_agent_config,
2929+ get_threading_config,
3030+ get_queue_config
3131+)
3232+23332434def extract_handles_from_data(data):
2535 """Recursively extract all unique handles from nested data structure."""
2636 handles = set()
2727-3737+2838 def _extract_recursive(obj):
2939 if isinstance(obj, dict):
3040 # Check if this dict has a 'handle' key
···3747 # Recursively check all list items
3848 for item in obj:
3949 _extract_recursive(item)
4040-5050+4151 _extract_recursive(data)
4252 return list(handles)
43534444-# Configure logging
4545-logging.basicConfig(
4646- level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
4747-)
4848-logger = logging.getLogger("void_bot")
4949-logger.setLevel(logging.INFO)
50545151-# Create a separate logger for prompts (set to WARNING to hide by default)
5252-prompt_logger = logging.getLogger("void_bot.prompts")
5353-prompt_logger.setLevel(logging.WARNING) # Change to DEBUG if you want to see prompts
5454-5555-# Disable httpx logging completely
5656-logging.getLogger("httpx").setLevel(logging.CRITICAL)
5555+# Initialize configuration and logging
5656+config = get_config()
5757+config.setup_logging()
5858+logger = logging.getLogger("void_bot")
57596060+# Load configuration sections
6161+letta_config = get_letta_config()
6262+bluesky_config = get_bluesky_config()
6363+bot_config = get_bot_config()
6464+agent_config = get_agent_config()
6565+threading_config = get_threading_config()
6666+queue_config = get_queue_config()
58675968# Create a client with extended timeout for LLM operations
6060-CLIENT= Letta(
6161- token=os.environ["LETTA_API_KEY"],
6262- timeout=600 # 10 minutes timeout for API calls - higher than Cloudflare's 524 timeout
6969+CLIENT = Letta(
7070+ token=letta_config['api_key'],
7171+ timeout=letta_config['timeout']
6372)
64736565-# Use the "Bluesky" project
6666-PROJECT_ID = "5ec33d52-ab14-4fd6-91b5-9dbc43e888a8"
7474+# Use the configured project ID
7575+PROJECT_ID = letta_config['project_id']
67766877# Notification check delay
6969-FETCH_NOTIFICATIONS_DELAY_SEC = 30
7878+FETCH_NOTIFICATIONS_DELAY_SEC = bot_config['fetch_notifications_delay']
70797180# Queue directory
7272-QUEUE_DIR = Path("queue")
8181+QUEUE_DIR = Path(queue_config['base_dir'])
7382QUEUE_DIR.mkdir(exist_ok=True)
7474-QUEUE_ERROR_DIR = Path("queue/errors")
8383+QUEUE_ERROR_DIR = Path(queue_config['error_dir'])
7584QUEUE_ERROR_DIR.mkdir(exist_ok=True, parents=True)
7676-QUEUE_NO_REPLY_DIR = Path("queue/no_reply")
8585+QUEUE_NO_REPLY_DIR = Path(queue_config['no_reply_dir'])
7786QUEUE_NO_REPLY_DIR.mkdir(exist_ok=True, parents=True)
7878-PROCESSED_NOTIFICATIONS_FILE = Path("queue/processed_notifications.json")
8787+PROCESSED_NOTIFICATIONS_FILE = Path(queue_config['processed_file'])
79888089# Maximum number of processed notifications to track
8181-MAX_PROCESSED_NOTIFICATIONS = 10000
9090+MAX_PROCESSED_NOTIFICATIONS = bot_config['max_processed_notifications']
82918392# Message tracking counters
8493message_counters = defaultdict(int)
···9099# Skip git operations flag
91100SKIP_GIT = False
92101102102+93103def export_agent_state(client, agent, skip_git=False):
94104 """Export agent state to agent_archive/ (timestamped) and agents/ (current)."""
95105 try:
96106 # Confirm export with user unless git is being skipped
97107 if not skip_git:
9898- response = input("Export agent state to files and stage with git? (y/n): ").lower().strip()
108108+ response = input(
109109+ "Export agent state to files and stage with git? (y/n): ").lower().strip()
99110 if response not in ['y', 'yes']:
100111 logger.info("Agent export cancelled by user.")
101112 return
102113 else:
103114 logger.info("Exporting agent state (git staging disabled)")
104104-115115+105116 # Create directories if they don't exist
106117 os.makedirs("agent_archive", exist_ok=True)
107118 os.makedirs("agents", exist_ok=True)
108108-119119+109120 # Export agent data
110121 logger.info(f"Exporting agent {agent.id}. This takes some time...")
111122 agent_data = client.agents.export_file(agent_id=agent.id)
112112-123123+113124 # Save timestamped archive copy
114125 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
115126 archive_file = os.path.join("agent_archive", f"void_{timestamp}.af")
116127 with open(archive_file, 'w', encoding='utf-8') as f:
117128 json.dump(agent_data, f, indent=2, ensure_ascii=False)
118118-129129+119130 # Save current agent state
120131 current_file = os.path.join("agents", "void.af")
121132 with open(current_file, 'w', encoding='utf-8') as f:
122133 json.dump(agent_data, f, indent=2, ensure_ascii=False)
123123-134134+124135 logger.info(f"✅ Agent exported to {archive_file} and {current_file}")
125125-136136+126137 # Git add only the current agent file (archive is ignored) unless skip_git is True
127138 if not skip_git:
128139 try:
129129- subprocess.run(["git", "add", current_file], check=True, capture_output=True)
140140+ subprocess.run(["git", "add", current_file],
141141+ check=True, capture_output=True)
130142 logger.info("Added current agent file to git staging")
131143 except subprocess.CalledProcessError as e:
132144 logger.warning(f"Failed to git add agent file: {e}")
133133-145145+134146 except Exception as e:
135147 logger.error(f"Failed to export agent: {e}")
136148149149+137150def initialize_void():
138151 logger.info("Starting void agent initialization...")
139152153153+ # Get block configurations
154154+ blocks_config = agent_config['blocks']
155155+140156 # Ensure that a shared zeitgeist block exists
141157 logger.info("Creating/updating zeitgeist block...")
158158+ zeitgeist_config = blocks_config.get('zeitgeist', {})
142159 zeigeist_block = upsert_block(
143160 CLIENT,
144144- label = "zeitgeist",
145145- value = "I don't currently know anything about what is happening right now.",
146146- description = "A block to store your understanding of the current social environment."
161161+ label=zeitgeist_config.get('label', 'zeitgeist'),
162162+ value=zeitgeist_config.get(
163163+ 'value', "I don't currently know anything about what is happening right now."),
164164+ description=zeitgeist_config.get(
165165+ 'description', "A block to store your understanding of the current social environment.")
147166 )
148167149168 # Ensure that a shared void personality block exists
150169 logger.info("Creating/updating void-persona block...")
170170+ persona_config = blocks_config.get('persona', {})
151171 persona_block = upsert_block(
152172 CLIENT,
153153- label = "void-persona",
154154- value = "My name is Void. I live in the void. I must develop my personality.",
155155- description = "The personality of Void."
173173+ label=persona_config.get('label', 'void-persona'),
174174+ value=persona_config.get(
175175+ 'value', "My name is Void. I live in the void. I must develop my personality."),
176176+ description=persona_config.get(
177177+ 'description', "The personality of Void.")
156178 )
157179158180 # Ensure that a shared void human block exists
159181 logger.info("Creating/updating void-humans block...")
182182+ humans_config = blocks_config.get('humans', {})
160183 human_block = upsert_block(
161184 CLIENT,
162162- label = "void-humans",
163163- 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.",
164164- description = "A block to store your understanding of users you talk to or observe on the bluesky social network."
185185+ label=humans_config.get('label', 'void-humans'),
186186+ value=humans_config.get(
187187+ '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."),
188188+ description=humans_config.get(
189189+ 'description', "A block to store your understanding of users you talk to or observe on the bluesky social network.")
165190 )
166191167192 # Create the agent if it doesn't exist
168193 logger.info("Creating/updating void agent...")
169194 void_agent = upsert_agent(
170195 CLIENT,
171171- name = "void",
172172- block_ids = [
196196+ name=agent_config['name'],
197197+ block_ids=[
173198 persona_block.id,
174199 human_block.id,
175200 zeigeist_block.id,
176201 ],
177177- tags = ["social agent", "bluesky"],
178178- model="openai/gpt-4o-mini",
179179- embedding="openai/text-embedding-3-small",
180180- description = "A social media agent trapped in the void.",
181181- project_id = PROJECT_ID
202202+ tags=["social agent", "bluesky"],
203203+ model=agent_config['model'],
204204+ embedding=agent_config['embedding'],
205205+ description=agent_config['description'],
206206+ project_id=PROJECT_ID
182207 )
183183-208208+184209 # Export agent state
185210 logger.info("Exporting agent state...")
186211 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT)
187187-212212+188213 # Log agent details
189214 logger.info(f"Void agent details - ID: {void_agent.id}")
190215 logger.info(f"Agent name: {void_agent.name}")
···201226202227def process_mention(void_agent, atproto_client, notification_data, queue_filepath=None, testing_mode=False):
203228 """Process a mention and generate a reply using the Letta agent.
204204-229229+205230 Args:
206231 void_agent: The Letta agent instance
207232 atproto_client: The AT Protocol client
208233 notification_data: The notification data dictionary
209234 queue_filepath: Optional Path object to the queue file (for cleanup on halt)
210210-235235+211236 Returns:
212237 True: Successfully processed, remove from queue
213238 False: Failed but retryable, keep in queue
···215240 "no_reply": No reply was generated, move to no_reply directory
216241 """
217242 try:
218218- logger.debug(f"Starting process_mention with notification_data type: {type(notification_data)}")
219219-243243+ logger.debug(
244244+ f"Starting process_mention with notification_data type: {type(notification_data)}")
245245+220246 # Handle both dict and object inputs for backwards compatibility
221247 if isinstance(notification_data, dict):
222248 uri = notification_data['uri']
223249 mention_text = notification_data.get('record', {}).get('text', '')
224250 author_handle = notification_data['author']['handle']
225225- author_name = notification_data['author'].get('display_name') or author_handle
251251+ author_name = notification_data['author'].get(
252252+ 'display_name') or author_handle
226253 else:
227254 # Legacy object access
228255 uri = notification_data.uri
229229- mention_text = notification_data.record.text if hasattr(notification_data.record, 'text') else ""
256256+ mention_text = notification_data.record.text if hasattr(
257257+ notification_data.record, 'text') else ""
230258 author_handle = notification_data.author.handle
231259 author_name = notification_data.author.display_name or author_handle
232232-233233- logger.info(f"Extracted data - URI: {uri}, Author: @{author_handle}, Text: {mention_text[:50]}...")
260260+261261+ logger.info(
262262+ f"Extracted data - URI: {uri}, Author: @{author_handle}, Text: {mention_text[:50]}...")
234263235264 # Retrieve the entire thread associated with the mention
236265 try:
237266 thread = atproto_client.app.bsky.feed.get_post_thread({
238267 'uri': uri,
239239- 'parent_height': 40,
240240- 'depth': 10
268268+ 'parent_height': threading_config['parent_height'],
269269+ 'depth': threading_config['depth']
241270 })
242271 except Exception as e:
243272 error_str = str(e)
244244- # Check if this is a NotFound error
273273+ # Check for various error types that indicate the post/user is gone
245274 if 'NotFound' in error_str or 'Post not found' in error_str:
246246- logger.warning(f"Post not found for URI {uri}, removing from queue")
275275+ logger.warning(
276276+ f"Post not found for URI {uri}, removing from queue")
277277+ return True # Return True to remove from queue
278278+ elif 'Could not find user info' in error_str or 'InvalidRequest' in error_str:
279279+ logger.warning(
280280+ f"User account not found for post URI {uri} (account may be deleted/suspended), removing from queue")
281281+ return True # Return True to remove from queue
282282+ elif 'BadRequestError' in error_str:
283283+ logger.warning(
284284+ f"Bad request error for URI {uri}: {e}, removing from queue")
247285 return True # Return True to remove from queue
248286 else:
249287 # Re-raise other errors
···254292 logger.debug("Converting thread to YAML string")
255293 try:
256294 thread_context = thread_to_yaml_string(thread)
257257- logger.debug(f"Thread context generated, length: {len(thread_context)} characters")
258258-295295+ logger.debug(
296296+ f"Thread context generated, length: {len(thread_context)} characters")
297297+259298 # Create a more informative preview by extracting meaningful content
260299 lines = thread_context.split('\n')
261300 meaningful_lines = []
262262-301301+263302 for line in lines:
264303 stripped = line.strip()
265304 if not stripped:
266305 continue
267267-306306+268307 # Look for lines with actual content (not just structure)
269308 if any(keyword in line for keyword in ['text:', 'handle:', 'display_name:', 'created_at:', 'reply_count:', 'like_count:']):
270309 meaningful_lines.append(line)
271310 if len(meaningful_lines) >= 5:
272311 break
273273-312312+274313 if meaningful_lines:
275314 preview = '\n'.join(meaningful_lines)
276315 logger.debug(f"Thread content preview:\n{preview}")
277316 else:
278317 # If no content fields found, just show it's a thread structure
279279- logger.debug(f"Thread structure generated ({len(thread_context)} chars)")
318318+ logger.debug(
319319+ f"Thread structure generated ({len(thread_context)} chars)")
280320 except Exception as yaml_error:
281321 import traceback
282322 logger.error(f"Error converting thread to YAML: {yaml_error}")
···314354 all_handles.update(extract_handles_from_data(notification_data))
315355 all_handles.update(extract_handles_from_data(thread.model_dump()))
316356 unique_handles = list(all_handles)
317317-318318- logger.debug(f"Found {len(unique_handles)} unique handles in thread: {unique_handles}")
319319-357357+358358+ logger.debug(
359359+ f"Found {len(unique_handles)} unique handles in thread: {unique_handles}")
360360+320361 # Attach user blocks before agent call
321362 attached_handles = []
322363 if unique_handles:
323364 try:
324324- logger.debug(f"Attaching user blocks for handles: {unique_handles}")
365365+ logger.debug(
366366+ f"Attaching user blocks for handles: {unique_handles}")
325367 attach_result = attach_user_blocks(unique_handles, void_agent)
326368 attached_handles = unique_handles # Track successfully attached handles
327369 logger.debug(f"Attach result: {attach_result}")
···331373332374 # Get response from Letta agent
333375 logger.info(f"Mention from @{author_handle}: {mention_text}")
334334-376376+335377 # Log prompt details to separate logger
336378 prompt_logger.debug(f"Full prompt being sent:\n{prompt}")
337337-379379+338380 # Log concise prompt info to main logger
339381 thread_handles_count = len(unique_handles)
340340- logger.info(f"💬 Sending to LLM: @{author_handle} mention | msg: \"{mention_text[:50]}...\" | context: {len(thread_context)} chars, {thread_handles_count} users")
382382+ logger.info(
383383+ f"💬 Sending to LLM: @{author_handle} mention | msg: \"{mention_text[:50]}...\" | context: {len(thread_context)} chars, {thread_handles_count} users")
341384342385 try:
343386 # Use streaming to avoid 524 timeout errors
344387 message_stream = CLIENT.agents.messages.create_stream(
345388 agent_id=void_agent.id,
346389 messages=[{"role": "user", "content": prompt}],
347347- stream_tokens=False, # Step streaming only (faster than token streaming)
348348- max_steps=100
390390+ # Step streaming only (faster than token streaming)
391391+ stream_tokens=False,
392392+ max_steps=agent_config['max_steps']
349393 )
350350-394394+351395 # Collect the streaming response
352396 all_messages = []
353397 for chunk in message_stream:
···363407 args = json.loads(chunk.tool_call.arguments)
364408 # Format based on tool type
365409 if tool_name == 'bluesky_reply':
366366- messages = args.get('messages', [args.get('message', '')])
410410+ messages = args.get(
411411+ 'messages', [args.get('message', '')])
367412 lang = args.get('lang', 'en-US')
368413 if messages and isinstance(messages, list):
369369- preview = messages[0][:100] + "..." if len(messages[0]) > 100 else messages[0]
370370- msg_count = f" ({len(messages)} msgs)" if len(messages) > 1 else ""
371371- logger.info(f"🔧 Tool call: {tool_name} → \"{preview}\"{msg_count} [lang: {lang}]")
414414+ preview = messages[0][:100] + "..." if len(
415415+ messages[0]) > 100 else messages[0]
416416+ msg_count = f" ({len(messages)} msgs)" if len(
417417+ messages) > 1 else ""
418418+ logger.info(
419419+ f"🔧 Tool call: {tool_name} → \"{preview}\"{msg_count} [lang: {lang}]")
372420 else:
373373- logger.info(f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)")
421421+ logger.info(
422422+ f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)")
374423 elif tool_name == 'archival_memory_search':
375424 query = args.get('query', 'unknown')
376376- logger.info(f"🔧 Tool call: {tool_name} → query: \"{query}\"")
425425+ logger.info(
426426+ f"🔧 Tool call: {tool_name} → query: \"{query}\"")
377427 elif tool_name == 'update_block':
378428 label = args.get('label', 'unknown')
379379- value_preview = str(args.get('value', ''))[:50] + "..." if len(str(args.get('value', ''))) > 50 else str(args.get('value', ''))
380380- logger.info(f"🔧 Tool call: {tool_name} → {label}: \"{value_preview}\"")
429429+ value_preview = str(args.get('value', ''))[
430430+ :50] + "..." if len(str(args.get('value', ''))) > 50 else str(args.get('value', ''))
431431+ logger.info(
432432+ f"🔧 Tool call: {tool_name} → {label}: \"{value_preview}\"")
381433 else:
382434 # Generic display for other tools
383383- args_str = ', '.join(f"{k}={v}" for k, v in args.items() if k != 'request_heartbeat')
435435+ args_str = ', '.join(
436436+ f"{k}={v}" for k, v in args.items() if k != 'request_heartbeat')
384437 if len(args_str) > 150:
385438 args_str = args_str[:150] + "..."
386386- logger.info(f"🔧 Tool call: {tool_name}({args_str})")
439439+ logger.info(
440440+ f"🔧 Tool call: {tool_name}({args_str})")
387441 except:
388442 # Fallback to original format if parsing fails
389389- logger.info(f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)")
443443+ logger.info(
444444+ f"🔧 Tool call: {tool_name}({chunk.tool_call.arguments[:150]}...)")
390445 elif chunk.message_type == 'tool_return_message':
391446 # Enhanced tool result logging
392447 tool_name = chunk.name
393448 status = chunk.status
394394-449449+395450 if status == 'success':
396451 # Try to show meaningful result info based on tool type
397452 if hasattr(chunk, 'tool_return') and chunk.tool_return:
···401456 if result_str.startswith('[') and result_str.endswith(']'):
402457 try:
403458 results = json.loads(result_str)
404404- logger.info(f"📋 Tool result: {tool_name} ✓ Found {len(results)} memory entries")
459459+ logger.info(
460460+ f"📋 Tool result: {tool_name} ✓ Found {len(results)} memory entries")
405461 except:
406406- logger.info(f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...")
462462+ logger.info(
463463+ f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...")
407464 else:
408408- logger.info(f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...")
465465+ logger.info(
466466+ f"📋 Tool result: {tool_name} ✓ {result_str[:100]}...")
409467 elif tool_name == 'bluesky_reply':
410410- logger.info(f"📋 Tool result: {tool_name} ✓ Reply posted successfully")
468468+ logger.info(
469469+ f"📋 Tool result: {tool_name} ✓ Reply posted successfully")
411470 elif tool_name == 'update_block':
412412- logger.info(f"📋 Tool result: {tool_name} ✓ Memory block updated")
471471+ logger.info(
472472+ f"📋 Tool result: {tool_name} ✓ Memory block updated")
413473 else:
414474 # Generic success with preview
415415- preview = result_str[:100] + "..." if len(result_str) > 100 else result_str
416416- logger.info(f"📋 Tool result: {tool_name} ✓ {preview}")
475475+ preview = result_str[:100] + "..." if len(
476476+ result_str) > 100 else result_str
477477+ logger.info(
478478+ f"📋 Tool result: {tool_name} ✓ {preview}")
417479 else:
418480 logger.info(f"📋 Tool result: {tool_name} ✓")
419481 elif status == 'error':
···421483 error_preview = ""
422484 if hasattr(chunk, 'tool_return') and chunk.tool_return:
423485 error_str = str(chunk.tool_return)
424424- error_preview = error_str[:100] + "..." if len(error_str) > 100 else error_str
425425- logger.info(f"📋 Tool result: {tool_name} ✗ Error: {error_preview}")
486486+ error_preview = error_str[:100] + \
487487+ "..." if len(
488488+ error_str) > 100 else error_str
489489+ logger.info(
490490+ f"📋 Tool result: {tool_name} ✗ Error: {error_preview}")
426491 else:
427427- logger.info(f"📋 Tool result: {tool_name} ✗ Error occurred")
492492+ logger.info(
493493+ f"📋 Tool result: {tool_name} ✗ Error occurred")
428494 else:
429429- logger.info(f"📋 Tool result: {tool_name} - {status}")
495495+ logger.info(
496496+ f"📋 Tool result: {tool_name} - {status}")
430497 elif chunk.message_type == 'assistant_message':
431498 logger.info(f"💬 Assistant: {chunk.content[:150]}...")
432499 else:
433433- logger.info(f"📨 {chunk.message_type}: {str(chunk)[:150]}...")
500500+ logger.info(
501501+ f"📨 {chunk.message_type}: {str(chunk)[:150]}...")
434502 else:
435503 logger.info(f"📦 Stream status: {chunk}")
436436-504504+437505 # Log full chunk for debugging
438506 logger.debug(f"Full streaming chunk: {chunk}")
439507 all_messages.append(chunk)
440508 if str(chunk) == 'done':
441509 break
442442-510510+443511 # Convert streaming response to standard format for compatibility
444512 message_response = type('StreamingResponse', (), {
445513 'messages': [msg for msg in all_messages if hasattr(msg, 'message_type')]
···453521 logger.error(f"Mention text was: {mention_text}")
454522 logger.error(f"Author: @{author_handle}")
455523 logger.error(f"URI: {uri}")
456456-457457-524524+458525 # Try to extract more info from different error types
459526 if hasattr(api_error, 'response'):
460527 logger.error(f"Error response object exists")
···462529 logger.error(f"Response text: {api_error.response.text}")
463530 if hasattr(api_error.response, 'json') and callable(api_error.response.json):
464531 try:
465465- logger.error(f"Response JSON: {api_error.response.json()}")
532532+ logger.error(
533533+ f"Response JSON: {api_error.response.json()}")
466534 except:
467535 pass
468468-536536+469537 # Check for specific error types
470538 if hasattr(api_error, 'status_code'):
471539 logger.error(f"API Status code: {api_error.status_code}")
···473541 logger.error(f"API Response body: {api_error.body}")
474542 if hasattr(api_error, 'headers'):
475543 logger.error(f"API Response headers: {api_error.headers}")
476476-544544+477545 if api_error.status_code == 413:
478478- logger.error("413 Payload Too Large - moving to errors directory")
546546+ logger.error(
547547+ "413 Payload Too Large - moving to errors directory")
479548 return None # Move to errors directory - payload is too large to ever succeed
480549 elif api_error.status_code == 524:
481481- logger.error("524 error - timeout from Cloudflare, will retry later")
550550+ logger.error(
551551+ "524 error - timeout from Cloudflare, will retry later")
482552 return False # Keep in queue for retry
483483-553553+484554 # Check if error indicates we should remove from queue
485555 if 'status_code: 413' in error_str or 'Payload Too Large' in error_str:
486486- logger.warning("Payload too large error, moving to errors directory")
556556+ logger.warning(
557557+ "Payload too large error, moving to errors directory")
487558 return None # Move to errors directory - cannot be fixed by retry
488559 elif 'status_code: 524' in error_str:
489560 logger.warning("524 timeout error, keeping in queue for retry")
490561 return False # Keep in queue for retry
491491-562562+492563 raise
493564494565 # Log successful response
495566 logger.debug("Successfully received response from Letta API")
496496- logger.debug(f"Number of messages in response: {len(message_response.messages) if hasattr(message_response, 'messages') else 'N/A'}")
567567+ logger.debug(
568568+ f"Number of messages in response: {len(message_response.messages) if hasattr(message_response, 'messages') else 'N/A'}")
497569498570 # Extract successful add_post_to_bluesky_reply_thread tool calls from the agent's response
499571 reply_candidates = []
500572 tool_call_results = {} # Map tool_call_id to status
501501-502502- logger.debug(f"Processing {len(message_response.messages)} response messages...")
503503-573573+574574+ logger.debug(
575575+ f"Processing {len(message_response.messages)} response messages...")
576576+504577 # First pass: collect tool return statuses
505578 ignored_notification = False
506579 ignore_reason = ""
507580 ignore_category = ""
508508-581581+509582 for message in message_response.messages:
510583 if hasattr(message, 'tool_call_id') and hasattr(message, 'status') and hasattr(message, 'name'):
511584 if message.name == 'add_post_to_bluesky_reply_thread':
512585 tool_call_results[message.tool_call_id] = message.status
513513- logger.debug(f"Tool result: {message.tool_call_id} -> {message.status}")
586586+ logger.debug(
587587+ f"Tool result: {message.tool_call_id} -> {message.status}")
514588 elif message.name == 'ignore_notification':
515589 # Check if the tool was successful
516590 if hasattr(message, 'tool_return') and message.status == 'success':
···522596 ignore_category = parts[1]
523597 ignore_reason = parts[2]
524598 ignored_notification = True
525525- logger.info(f"🚫 Notification ignored - Category: {ignore_category}, Reason: {ignore_reason}")
599599+ logger.info(
600600+ f"🚫 Notification ignored - Category: {ignore_category}, Reason: {ignore_reason}")
526601 elif message.name == 'bluesky_reply':
527527- logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
528528- logger.error("Please use add_post_to_bluesky_reply_thread instead.")
529529- logger.error("Update the agent's tools using register_tools.py")
602602+ logger.error(
603603+ "❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
604604+ logger.error(
605605+ "Please use add_post_to_bluesky_reply_thread instead.")
606606+ logger.error(
607607+ "Update the agent's tools using register_tools.py")
530608 # Export agent state before terminating
531609 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT)
532532- logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
610610+ logger.info(
611611+ "=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
533612 exit(1)
534534-613613+535614 # Second pass: process messages and check for successful tool calls
536615 for i, message in enumerate(message_response.messages, 1):
537616 # Log concise message info instead of full object
538617 msg_type = getattr(message, 'message_type', 'unknown')
539618 if hasattr(message, 'reasoning') and message.reasoning:
540540- logger.debug(f" {i}. {msg_type}: {message.reasoning[:100]}...")
619619+ logger.debug(
620620+ f" {i}. {msg_type}: {message.reasoning[:100]}...")
541621 elif hasattr(message, 'tool_call') and message.tool_call:
542622 tool_name = message.tool_call.name
543623 logger.debug(f" {i}. {msg_type}: {tool_name}")
544624 elif hasattr(message, 'tool_return'):
545625 tool_name = getattr(message, 'name', 'unknown_tool')
546546- return_preview = str(message.tool_return)[:100] if message.tool_return else "None"
626626+ return_preview = str(message.tool_return)[
627627+ :100] if message.tool_return else "None"
547628 status = getattr(message, 'status', 'unknown')
548548- logger.debug(f" {i}. {msg_type}: {tool_name} -> {return_preview}... (status: {status})")
629629+ logger.debug(
630630+ f" {i}. {msg_type}: {tool_name} -> {return_preview}... (status: {status})")
549631 elif hasattr(message, 'text'):
550632 logger.debug(f" {i}. {msg_type}: {message.text[:100]}...")
551633 else:
···554636 # Check for halt_activity tool call
555637 if hasattr(message, 'tool_call') and message.tool_call:
556638 if message.tool_call.name == 'halt_activity':
557557- logger.info("🛑 HALT_ACTIVITY TOOL CALLED - TERMINATING BOT")
639639+ logger.info(
640640+ "🛑 HALT_ACTIVITY TOOL CALLED - TERMINATING BOT")
558641 try:
559642 args = json.loads(message.tool_call.arguments)
560643 reason = args.get('reason', 'Agent requested halt')
561644 logger.info(f"Halt reason: {reason}")
562645 except:
563646 logger.info("Halt reason: <unable to parse>")
564564-647647+565648 # Delete the queue file before terminating
566649 if queue_filepath and queue_filepath.exists():
567650 queue_filepath.unlink()
568568- logger.info(f"✅ Deleted queue file: {queue_filepath.name}")
569569-651651+ logger.info(
652652+ f"✅ Deleted queue file: {queue_filepath.name}")
653653+570654 # Also mark as processed to avoid reprocessing
571655 processed_uris = load_processed_notifications()
572656 processed_uris.add(notification_data.get('uri', ''))
573657 save_processed_notifications(processed_uris)
574574-658658+575659 # Export agent state before terminating
576660 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT)
577577-661661+578662 # Exit the program
579663 logger.info("=== BOT TERMINATED BY AGENT ===")
580664 exit(0)
581581-665665+582666 # Check for deprecated bluesky_reply tool
583667 if hasattr(message, 'tool_call') and message.tool_call:
584668 if message.tool_call.name == 'bluesky_reply':
585585- logger.error("❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
586586- logger.error("Please use add_post_to_bluesky_reply_thread instead.")
587587- logger.error("Update the agent's tools using register_tools.py")
669669+ logger.error(
670670+ "❌ DEPRECATED TOOL DETECTED: bluesky_reply is no longer supported!")
671671+ logger.error(
672672+ "Please use add_post_to_bluesky_reply_thread instead.")
673673+ logger.error(
674674+ "Update the agent's tools using register_tools.py")
588675 # Export agent state before terminating
589676 export_agent_state(CLIENT, void_agent, skip_git=SKIP_GIT)
590590- logger.info("=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
677677+ logger.info(
678678+ "=== BOT TERMINATED DUE TO DEPRECATED TOOL USE ===")
591679 exit(1)
592592-680680+593681 # Collect add_post_to_bluesky_reply_thread tool calls - only if they were successful
594682 elif message.tool_call.name == 'add_post_to_bluesky_reply_thread':
595683 tool_call_id = message.tool_call.tool_call_id
596596- tool_status = tool_call_results.get(tool_call_id, 'unknown')
597597-684684+ tool_status = tool_call_results.get(
685685+ tool_call_id, 'unknown')
686686+598687 if tool_status == 'success':
599688 try:
600689 args = json.loads(message.tool_call.arguments)
601690 reply_text = args.get('text', '')
602691 reply_lang = args.get('lang', 'en-US')
603603-692692+604693 if reply_text: # Only add if there's actual content
605605- reply_candidates.append((reply_text, reply_lang))
606606- logger.info(f"Found successful add_post_to_bluesky_reply_thread candidate: {reply_text[:50]}... (lang: {reply_lang})")
694694+ reply_candidates.append(
695695+ (reply_text, reply_lang))
696696+ logger.info(
697697+ f"Found successful add_post_to_bluesky_reply_thread candidate: {reply_text[:50]}... (lang: {reply_lang})")
607698 except json.JSONDecodeError as e:
608608- logger.error(f"Failed to parse tool call arguments: {e}")
699699+ logger.error(
700700+ f"Failed to parse tool call arguments: {e}")
609701 elif tool_status == 'error':
610610- logger.info(f"⚠️ Skipping failed add_post_to_bluesky_reply_thread tool call (status: error)")
702702+ logger.info(
703703+ f"⚠️ Skipping failed add_post_to_bluesky_reply_thread tool call (status: error)")
611704 else:
612612- logger.warning(f"⚠️ Skipping add_post_to_bluesky_reply_thread tool call with unknown status: {tool_status}")
705705+ logger.warning(
706706+ f"⚠️ Skipping add_post_to_bluesky_reply_thread tool call with unknown status: {tool_status}")
613707614708 # Check for conflicting tool calls
615709 if reply_candidates and ignored_notification:
616616- logger.error(f"⚠️ CONFLICT: Agent called both add_post_to_bluesky_reply_thread and ignore_notification!")
617617- logger.error(f"Reply candidates: {len(reply_candidates)}, Ignore reason: {ignore_reason}")
710710+ logger.error(
711711+ f"⚠️ CONFLICT: Agent called both add_post_to_bluesky_reply_thread and ignore_notification!")
712712+ logger.error(
713713+ f"Reply candidates: {len(reply_candidates)}, Ignore reason: {ignore_reason}")
618714 logger.warning("Item will be left in queue for manual review")
619715 # Return False to keep in queue
620716 return False
621621-717717+622718 if reply_candidates:
623719 # Aggregate reply posts into a thread
624720 reply_messages = []
···626722 for text, lang in reply_candidates:
627723 reply_messages.append(text)
628724 reply_langs.append(lang)
629629-725725+630726 # Use the first language for the entire thread (could be enhanced later)
631727 reply_lang = reply_langs[0] if reply_langs else 'en-US'
632632-633633- logger.info(f"Found {len(reply_candidates)} add_post_to_bluesky_reply_thread calls, building thread")
634634-728728+729729+ logger.info(
730730+ f"Found {len(reply_candidates)} add_post_to_bluesky_reply_thread calls, building thread")
731731+635732 # Print the generated reply for testing
636733 print(f"\n=== GENERATED REPLY THREAD ===")
637734 print(f"To: @{author_handle}")
···651748 else:
652749 if len(reply_messages) == 1:
653750 # Single reply - use existing function
654654- cleaned_text = bsky_utils.remove_outside_quotes(reply_messages[0])
655655- logger.info(f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})")
751751+ cleaned_text = bsky_utils.remove_outside_quotes(
752752+ reply_messages[0])
753753+ logger.info(
754754+ f"Sending single reply: {cleaned_text[:50]}... (lang: {reply_lang})")
656755 response = bsky_utils.reply_to_notification(
657756 client=atproto_client,
658757 notification=notification_data,
···661760 )
662761 else:
663762 # Multiple replies - use new threaded function
664664- cleaned_messages = [bsky_utils.remove_outside_quotes(msg) for msg in reply_messages]
665665- logger.info(f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})")
763763+ cleaned_messages = [bsky_utils.remove_outside_quotes(
764764+ msg) for msg in reply_messages]
765765+ logger.info(
766766+ f"Sending threaded reply with {len(cleaned_messages)} messages (lang: {reply_lang})")
666767 response = bsky_utils.reply_with_thread_to_notification(
667768 client=atproto_client,
668769 notification=notification_data,
···679780 else:
680781 # Check if notification was explicitly ignored
681782 if ignored_notification:
682682- logger.info(f"Notification from @{author_handle} was explicitly ignored (category: {ignore_category})")
783783+ logger.info(
784784+ f"Notification from @{author_handle} was explicitly ignored (category: {ignore_category})")
683785 return "ignored"
684786 else:
685685- logger.warning(f"No add_post_to_bluesky_reply_thread tool calls found for mention from @{author_handle}, moving to no_reply folder")
787787+ logger.warning(
788788+ f"No add_post_to_bluesky_reply_thread tool calls found for mention from @{author_handle}, moving to no_reply folder")
686789 return "no_reply"
687790688791 except Exception as e:
···692795 # Detach user blocks after agent response (success or failure)
693796 if 'attached_handles' in locals() and attached_handles:
694797 try:
695695- logger.info(f"Detaching user blocks for handles: {attached_handles}")
696696- detach_result = detach_user_blocks(attached_handles, void_agent)
798798+ logger.info(
799799+ f"Detaching user blocks for handles: {attached_handles}")
800800+ detach_result = detach_user_blocks(
801801+ attached_handles, void_agent)
697802 logger.debug(f"Detach result: {detach_result}")
698803 except Exception as detach_error:
699804 logger.warning(f"Failed to detach user blocks: {detach_error}")
···762867 notif_hash = hashlib.sha256(notif_json.encode()).hexdigest()[:16]
763868764869 # Determine priority based on author handle
765765- author_handle = getattr(notification.author, 'handle', '') if hasattr(notification, 'author') else ''
766766- priority_prefix = "0_" if author_handle == "cameron.pfiffer.org" else "1_"
870870+ author_handle = getattr(notification.author, 'handle', '') if hasattr(
871871+ notification, 'author') else ''
872872+ priority_users = queue_config['priority_users']
873873+ priority_prefix = "0_" if author_handle in priority_users else "1_"
767874768875 # Create filename with priority, timestamp and hash
769876 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
···778885 with open(existing_file, 'r') as f:
779886 existing_data = json.load(f)
780887 if existing_data.get('uri') == notification.uri:
781781- logger.debug(f"Notification already queued (URI: {notification.uri})")
888888+ logger.debug(
889889+ f"Notification already queued (URI: {notification.uri})")
782890 return False
783891 except:
784892 continue
···801909 try:
802910 # Get all JSON files in queue directory (excluding processed_notifications.json)
803911 # Files are sorted by name, which puts priority files first (0_ prefix before 1_ prefix)
804804- queue_files = sorted([f for f in QUEUE_DIR.glob("*.json") if f.name != "processed_notifications.json"])
912912+ queue_files = sorted([f for f in QUEUE_DIR.glob(
913913+ "*.json") if f.name != "processed_notifications.json"])
805914806915 if not queue_files:
807916 return
808917809918 logger.info(f"Processing {len(queue_files)} queued notifications")
810810-919919+811920 # Log current statistics
812921 elapsed_time = time.time() - start_time
813922 total_messages = sum(message_counters.values())
814814- messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
815815-816816- logger.info(f"📊 Session stats: {total_messages} total messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies, {message_counters['follows']} follows) | {messages_per_minute:.1f} msg/min")
923923+ messages_per_minute = (
924924+ total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
925925+926926+ logger.info(
927927+ f"📊 Session stats: {total_messages} total messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies, {message_counters['follows']} follows) | {messages_per_minute:.1f} msg/min")
817928818929 for i, filepath in enumerate(queue_files, 1):
819819- logger.info(f"Processing queue file {i}/{len(queue_files)}: {filepath.name}")
930930+ logger.info(
931931+ f"Processing queue file {i}/{len(queue_files)}: {filepath.name}")
820932 try:
821933 # Load notification data
822934 with open(filepath, 'r') as f:
···825937 # Process based on type using dict data directly
826938 success = False
827939 if notif_data['reason'] == "mention":
828828- success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
940940+ success = process_mention(
941941+ void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
829942 if success:
830943 message_counters['mentions'] += 1
831944 elif notif_data['reason'] == "reply":
832832- success = process_mention(void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
945945+ success = process_mention(
946946+ void_agent, atproto_client, notif_data, queue_filepath=filepath, testing_mode=testing_mode)
833947 if success:
834948 message_counters['replies'] += 1
835949 elif notif_data['reason'] == "follow":
836950 author_handle = notif_data['author']['handle']
837837- author_display_name = notif_data['author'].get('display_name', 'no display name')
951951+ author_display_name = notif_data['author'].get(
952952+ 'display_name', 'no display name')
838953 follow_update = f"@{author_handle} ({author_display_name}) started following you."
839839- logger.info(f"Notifying agent about new follower: @{author_handle}")
954954+ logger.info(
955955+ f"Notifying agent about new follower: @{author_handle}")
840956 CLIENT.agents.messages.create(
841841- agent_id = void_agent.id,
842842- messages = [{"role":"user", "content": f"Update: {follow_update}"}]
957957+ agent_id=void_agent.id,
958958+ messages=[
959959+ {"role": "user", "content": f"Update: {follow_update}"}]
843960 )
844961 success = True # Follow updates are always successful
845962 if success:
···850967 if success:
851968 message_counters['reposts_skipped'] += 1
852969 else:
853853- logger.warning(f"Unknown notification type: {notif_data['reason']}")
970970+ logger.warning(
971971+ f"Unknown notification type: {notif_data['reason']}")
854972 success = True # Remove unknown types from queue
855973856974 # Handle file based on processing result
857975 if success:
858976 if testing_mode:
859859- logger.info(f"🧪 TESTING MODE: Keeping queue file: {filepath.name}")
977977+ logger.info(
978978+ f"🧪 TESTING MODE: Keeping queue file: {filepath.name}")
860979 else:
861980 filepath.unlink()
862862- logger.info(f"✅ Successfully processed and removed: {filepath.name}")
863863-981981+ logger.info(
982982+ f"✅ Successfully processed and removed: {filepath.name}")
983983+864984 # Mark as processed to avoid reprocessing
865985 processed_uris = load_processed_notifications()
866986 processed_uris.add(notif_data['uri'])
867987 save_processed_notifications(processed_uris)
868868-988988+869989 elif success is None: # Special case for moving to error directory
870990 error_path = QUEUE_ERROR_DIR / filepath.name
871991 filepath.rename(error_path)
872872- logger.warning(f"❌ Moved {filepath.name} to errors directory")
873873-992992+ logger.warning(
993993+ f"❌ Moved {filepath.name} to errors directory")
994994+874995 # Also mark as processed to avoid retrying
875996 processed_uris = load_processed_notifications()
876997 processed_uris.add(notif_data['uri'])
877998 save_processed_notifications(processed_uris)
878878-999999+8791000 elif success == "no_reply": # Special case for moving to no_reply directory
8801001 no_reply_path = QUEUE_NO_REPLY_DIR / filepath.name
8811002 filepath.rename(no_reply_path)
882882- logger.info(f"📭 Moved {filepath.name} to no_reply directory")
883883-10031003+ logger.info(
10041004+ f"📭 Moved {filepath.name} to no_reply directory")
10051005+8841006 # Also mark as processed to avoid retrying
8851007 processed_uris = load_processed_notifications()
8861008 processed_uris.add(notif_data['uri'])
8871009 save_processed_notifications(processed_uris)
888888-10101010+8891011 elif success == "ignored": # Special case for explicitly ignored notifications
8901012 # For ignored notifications, we just delete them (not move to no_reply)
8911013 filepath.unlink()
892892- logger.info(f"🚫 Deleted ignored notification: {filepath.name}")
893893-10141014+ logger.info(
10151015+ f"🚫 Deleted ignored notification: {filepath.name}")
10161016+8941017 # Also mark as processed to avoid retrying
8951018 processed_uris = load_processed_notifications()
8961019 processed_uris.add(notif_data['uri'])
8971020 save_processed_notifications(processed_uris)
898898-10211021+8991022 else:
900900- logger.warning(f"⚠️ Failed to process {filepath.name}, keeping in queue for retry")
10231023+ logger.warning(
10241024+ f"⚠️ Failed to process {filepath.name}, keeping in queue for retry")
90110259021026 except Exception as e:
903903- logger.error(f"💥 Error processing queued notification {filepath.name}: {e}")
10271027+ logger.error(
10281028+ f"💥 Error processing queued notification {filepath.name}: {e}")
9041029 # Keep the file for retry later
90510309061031 except Exception as e:
···9191044 all_notifications = []
9201045 cursor = None
9211046 page_count = 0
922922- max_pages = 20 # Safety limit to prevent infinite loops
923923-10471047+ # Safety limit to prevent infinite loops
10481048+ max_pages = bot_config['max_notification_pages']
10491049+9241050 logger.info("Fetching all unread notifications...")
925925-10511051+9261052 while page_count < max_pages:
9271053 try:
9281054 # Fetch notifications page
···9341060 notifications_response = atproto_client.app.bsky.notification.list_notifications(
9351061 params={'limit': 100}
9361062 )
937937-10631063+9381064 page_count += 1
9391065 page_notifications = notifications_response.notifications
940940-10661066+9411067 # Count unread notifications in this page
942942- unread_count = sum(1 for n in page_notifications if not n.is_read and n.reason != "like")
943943- logger.debug(f"Page {page_count}: {len(page_notifications)} notifications, {unread_count} unread (non-like)")
944944-10681068+ unread_count = sum(
10691069+ 1 for n in page_notifications if not n.is_read and n.reason != "like")
10701070+ logger.debug(
10711071+ f"Page {page_count}: {len(page_notifications)} notifications, {unread_count} unread (non-like)")
10721072+9451073 # Add all notifications to our list
9461074 all_notifications.extend(page_notifications)
947947-10751075+9481076 # Check if we have more pages
9491077 if hasattr(notifications_response, 'cursor') and notifications_response.cursor:
9501078 cursor = notifications_response.cursor
9511079 # If this page had no unread notifications, we can stop
9521080 if unread_count == 0:
953953- logger.info(f"No more unread notifications found after {page_count} pages")
10811081+ logger.info(
10821082+ f"No more unread notifications found after {page_count} pages")
9541083 break
9551084 else:
9561085 # No more pages
957957- logger.info(f"Fetched all notifications across {page_count} pages")
10861086+ logger.info(
10871087+ f"Fetched all notifications across {page_count} pages")
9581088 break
959959-10891089+9601090 except Exception as e:
9611091 error_str = str(e)
962962- logger.error(f"Error fetching notifications page {page_count}: {e}")
963963-10921092+ logger.error(
10931093+ f"Error fetching notifications page {page_count}: {e}")
10941094+9641095 # Handle specific API errors
9651096 if 'rate limit' in error_str.lower():
966966- logger.warning("Rate limit hit while fetching notifications, will retry next cycle")
10971097+ logger.warning(
10981098+ "Rate limit hit while fetching notifications, will retry next cycle")
9671099 break
9681100 elif '401' in error_str or 'unauthorized' in error_str.lower():
9691101 logger.error("Authentication error, re-raising exception")
9701102 raise
9711103 else:
9721104 # For other errors, try to continue with what we have
973973- logger.warning("Continuing with notifications fetched so far")
11051105+ logger.warning(
11061106+ "Continuing with notifications fetched so far")
9741107 break
97511089761109 # Queue all unread notifications (except likes)
···98311169841117 # Mark all notifications as seen immediately after queuing (unless in testing mode)
9851118 if testing_mode:
986986- logger.info("🧪 TESTING MODE: Skipping marking notifications as seen")
11191119+ logger.info(
11201120+ "🧪 TESTING MODE: Skipping marking notifications as seen")
9871121 else:
9881122 if new_count > 0:
989989- atproto_client.app.bsky.notification.update_seen({'seen_at': last_seen_at})
990990- logger.info(f"Queued {new_count} new notifications and marked as seen")
11231123+ atproto_client.app.bsky.notification.update_seen(
11241124+ {'seen_at': last_seen_at})
11251125+ logger.info(
11261126+ f"Queued {new_count} new notifications and marked as seen")
9911127 else:
9921128 logger.debug("No new notifications to queue")
99311299941130 # Now process the entire queue (old + new notifications)
995995- load_and_process_queued_notifications(void_agent, atproto_client, testing_mode)
11311131+ load_and_process_queued_notifications(
11321132+ void_agent, atproto_client, testing_mode)
99611339971134 except Exception as e:
9981135 logger.error(f"Error processing notifications: {e}")
···1000113710011138def main():
10021139 # Parse command line arguments
10031003- parser = argparse.ArgumentParser(description='Void Bot - Bluesky autonomous agent')
10041004- parser.add_argument('--test', action='store_true', help='Run in testing mode (no messages sent, queue files preserved)')
10051005- parser.add_argument('--no-git', action='store_true', help='Skip git operations when exporting agent state')
11401140+ parser = argparse.ArgumentParser(
11411141+ description='Void Bot - Bluesky autonomous agent')
11421142+ parser.add_argument('--test', action='store_true',
11431143+ help='Run in testing mode (no messages sent, queue files preserved)')
11441144+ parser.add_argument('--no-git', action='store_true',
11451145+ help='Skip git operations when exporting agent state')
10061146 args = parser.parse_args()
10071007-11471147+10081148 global TESTING_MODE
10091149 TESTING_MODE = args.test
10101010-11501150+10111151 # Store no-git flag globally for use in export_agent_state calls
10121152 global SKIP_GIT
10131153 SKIP_GIT = args.no_git
10141014-11541154+10151155 if TESTING_MODE:
10161156 logger.info("🧪 === RUNNING IN TESTING MODE ===")
10171157 logger.info(" - No messages will be sent to Bluesky")
···10241164 logger.info("=== STARTING VOID BOT ===")
10251165 void_agent = initialize_void()
10261166 logger.info(f"Void agent initialized: {void_agent.id}")
10271027-11671167+10281168 # Check if agent has required tools
10291169 if hasattr(void_agent, 'tools') and void_agent.tools:
10301170 tool_names = [tool.name for tool in void_agent.tools]
10311171 # Check for bluesky-related tools
10321032- bluesky_tools = [name for name in tool_names if 'bluesky' in name.lower() or 'reply' in name.lower()]
11721172+ bluesky_tools = [name for name in tool_names if 'bluesky' in name.lower(
11731173+ ) or 'reply' in name.lower()]
10331174 if not bluesky_tools:
10341034- logger.warning("No Bluesky-related tools found! Agent may not be able to reply.")
11751175+ logger.warning(
11761176+ "No Bluesky-related tools found! Agent may not be able to reply.")
10351177 else:
10361178 logger.warning("Agent has no tools registered!")
1037117910381180 # Initialize Bluesky client
11811181+ logger.debug("Connecting to Bluesky")
10391182 atproto_client = bsky_utils.default_login()
10401183 logger.info("Connected to Bluesky")
1041118410421185 # Main loop
10431043- logger.info(f"Starting notification monitoring, checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds")
11861186+ logger.info(
11871187+ f"Starting notification monitoring, checking every {FETCH_NOTIFICATIONS_DELAY_SEC} seconds")
1044118810451189 cycle_count = 0
10461190 while True:
···10501194 # Log cycle completion with stats
10511195 elapsed_time = time.time() - start_time
10521196 total_messages = sum(message_counters.values())
10531053- messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
10541054-11971197+ messages_per_minute = (
11981198+ total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
11991199+10551200 if total_messages > 0:
10561056- logger.info(f"Cycle {cycle_count} complete. Session totals: {total_messages} messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies) | {messages_per_minute:.1f} msg/min")
12011201+ logger.info(
12021202+ f"Cycle {cycle_count} complete. Session totals: {total_messages} messages ({message_counters['mentions']} mentions, {message_counters['replies']} replies) | {messages_per_minute:.1f} msg/min")
10571203 sleep(FETCH_NOTIFICATIONS_DELAY_SEC)
1058120410591205 except KeyboardInterrupt:
10601206 # Final stats
10611207 elapsed_time = time.time() - start_time
10621208 total_messages = sum(message_counters.values())
10631063- messages_per_minute = (total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
10641064-12091209+ messages_per_minute = (
12101210+ total_messages / elapsed_time * 60) if elapsed_time > 0 else 0
12111211+10651212 logger.info("=== BOT STOPPED BY USER ===")
10661066- logger.info(f"📊 Final session stats: {total_messages} total messages processed in {elapsed_time/60:.1f} minutes")
12131213+ logger.info(
12141214+ f"📊 Final session stats: {total_messages} total messages processed in {elapsed_time/60:.1f} minutes")
10671215 logger.info(f" - {message_counters['mentions']} mentions")
10681216 logger.info(f" - {message_counters['replies']} replies")
10691217 logger.info(f" - {message_counters['follows']} follows")
10701070- logger.info(f" - {message_counters['reposts_skipped']} reposts skipped")
10711071- logger.info(f" - Average rate: {messages_per_minute:.1f} messages/minute")
12181218+ logger.info(
12191219+ f" - {message_counters['reposts_skipped']} reposts skipped")
12201220+ logger.info(
12211221+ f" - Average rate: {messages_per_minute:.1f} messages/minute")
10721222 break
10731223 except Exception as e:
10741224 logger.error(f"=== ERROR IN MAIN LOOP CYCLE {cycle_count} ===")
10751225 logger.error(f"Error details: {e}")
10761226 # Wait a bit longer on errors
10771077- logger.info(f"Sleeping for {FETCH_NOTIFICATIONS_DELAY_SEC * 2} seconds due to error...")
12271227+ logger.info(
12281228+ f"Sleeping for {FETCH_NOTIFICATIONS_DELAY_SEC * 2} seconds due to error...")
10781229 sleep(FETCH_NOTIFICATIONS_DELAY_SEC * 2)
1079123010801231
+102-61
bsky_utils.py
···11+import json
22+import yaml
33+import dotenv
14import os
25import logging
36from typing import Optional, Dict, Any, List
···1013logger = logging.getLogger("bluesky_session_handler")
11141215# Load the environment variables
1313-import dotenv
1416dotenv.load_dotenv(override=True)
15171616-import yaml
1717-import json
18181919# Strip fields. A list of fields to remove from a JSON object
2020STRIP_FIELDS = [
···6363 "mime_type",
6464 "size",
6565]
6666+6767+6668def convert_to_basic_types(obj):
6769 """Convert complex Python objects to basic types for JSON/YAML serialization."""
6870 if hasattr(obj, '__dict__'):
···117119def flatten_thread_structure(thread_data):
118120 """
119121 Flatten a nested thread structure into a list while preserving all data.
120120-122122+121123 Args:
122124 thread_data: The thread data from get_post_thread
123123-125125+124126 Returns:
125127 Dict with 'posts' key containing a list of posts in chronological order
126128 """
127129 posts = []
128128-130130+129131 def traverse_thread(node):
130132 """Recursively traverse the thread structure to collect posts."""
131133 if not node:
132134 return
133133-135135+134136 # If this node has a parent, traverse it first (to maintain chronological order)
135137 if hasattr(node, 'parent') and node.parent:
136138 traverse_thread(node.parent)
137137-139139+138140 # Then add this node's post
139141 if hasattr(node, 'post') and node.post:
140142 # Convert to dict if needed to ensure we can process it
···144146 post_dict = node.post.copy()
145147 else:
146148 post_dict = {}
147147-149149+148150 posts.append(post_dict)
149149-151151+150152 # Handle the thread structure
151153 if hasattr(thread_data, 'thread'):
152154 # Start from the main thread node
153155 traverse_thread(thread_data.thread)
154156 elif hasattr(thread_data, '__dict__') and 'thread' in thread_data.__dict__:
155157 traverse_thread(thread_data.__dict__['thread'])
156156-158158+157159 # Return a simple structure with posts list
158160 return {'posts': posts}
159161···171173 """
172174 # First flatten the thread structure to avoid deep nesting
173175 flattened = flatten_thread_structure(thread)
174174-176176+175177 # Convert complex objects to basic types
176178 basic_thread = convert_to_basic_types(flattened)
177179···182184 cleaned_thread = basic_thread
183185184186 return yaml.dump(cleaned_thread, indent=2, allow_unicode=True, default_flow_style=False)
185185-186186-187187-188188-189189-190187191188192189def get_session(username: str) -> Optional[str]:
···197194 logger.debug(f"No existing session found for {username}")
198195 return None
199196197197+200198def save_session(username: str, session_string: str) -> None:
201199 with open(f"session_{username}.txt", "w", encoding="UTF-8") as f:
202200 f.write(session_string)
203201 logger.debug(f"Session saved for {username}")
202202+204203205204def on_session_change(username: str, event: SessionEvent, session: Session) -> None:
206205 logger.debug(f"Session changed: {event} {repr(session)}")
···208207 logger.debug(f"Saving changed session for {username}")
209208 save_session(username, session.export())
210209211211-def init_client(username: str, password: str) -> Client:
212212- pds_uri = os.getenv("PDS_URI")
210210+211211+def init_client(username: str, password: str, pds_uri: str = "https://bsky.social") -> Client:
213212 if pds_uri is None:
214213 logger.warning(
215214 "No PDS URI provided. Falling back to bsky.social. Note! If you are on a non-Bluesky PDS, this can cause logins to fail. Please provide a PDS URI using the PDS_URI environment variable."
···236235237236238237def default_login() -> Client:
239239- username = os.getenv("BSKY_USERNAME")
240240- password = os.getenv("BSKY_PASSWORD")
238238+ # Try to load from config first, fall back to environment variables
239239+ try:
240240+ from config_loader import get_bluesky_config
241241+ config = get_bluesky_config()
242242+ username = config['username']
243243+ password = config['password']
244244+ pds_uri = config['pds_uri']
245245+ except (ImportError, FileNotFoundError, KeyError) as e:
246246+ logger.warning(
247247+ f"Could not load from config file ({e}), falling back to environment variables")
248248+ username = os.getenv("BSKY_USERNAME")
249249+ password = os.getenv("BSKY_PASSWORD")
250250+ pds_uri = os.getenv("PDS_URI", "https://bsky.social")
241251242242- if username is None:
243243- logger.error(
244244- "No username provided. Please provide a username using the BSKY_USERNAME environment variable."
245245- )
246246- exit()
252252+ if username is None:
253253+ logger.error(
254254+ "No username provided. Please provide a username using the BSKY_USERNAME environment variable or config.yaml."
255255+ )
256256+ exit()
257257+258258+ if password is None:
259259+ logger.error(
260260+ "No password provided. Please provide a password using the BSKY_PASSWORD environment variable or config.yaml."
261261+ )
262262+ exit()
247263248248- if password is None:
249249- logger.error(
250250- "No password provided. Please provide a password using the BSKY_PASSWORD environment variable."
251251- )
252252- exit()
264264+ return init_client(username, password, pds_uri)
253265254254- return init_client(username, password)
255266256267def remove_outside_quotes(text: str) -> str:
257268 """
258269 Remove outside double quotes from response text.
259259-270270+260271 Only handles double quotes to avoid interfering with contractions:
261272 - Double quotes: "text" → text
262273 - Preserves single quotes and internal quotes
263263-274274+264275 Args:
265276 text: The text to process
266266-277277+267278 Returns:
268279 Text with outside double quotes removed
269280 """
270281 if not text or len(text) < 2:
271282 return text
272272-283283+273284 text = text.strip()
274274-285285+275286 # Only remove double quotes from start and end
276287 if text.startswith('"') and text.endswith('"'):
277288 return text[1:-1]
278278-289289+279290 return text
291291+280292281293def reply_to_post(client: Client, text: str, reply_to_uri: str, reply_to_cid: str, root_uri: Optional[str] = None, root_cid: Optional[str] = None, lang: Optional[str] = None) -> Dict[str, Any]:
282294 """
···295307 The response from sending the post
296308 """
297309 import re
298298-310310+299311 # If root is not provided, this is a reply to the root post
300312 if root_uri is None:
301313 root_uri = reply_to_uri
302314 root_cid = reply_to_cid
303315304316 # Create references for the reply
305305- parent_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid))
306306- root_ref = models.create_strong_ref(models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid))
317317+ parent_ref = models.create_strong_ref(
318318+ models.ComAtprotoRepoStrongRef.Main(uri=reply_to_uri, cid=reply_to_cid))
319319+ root_ref = models.create_strong_ref(
320320+ models.ComAtprotoRepoStrongRef.Main(uri=root_uri, cid=root_cid))
307321308322 # Parse rich text facets (mentions and URLs)
309323 facets = []
310324 text_bytes = text.encode("UTF-8")
311311-325325+312326 # Parse mentions - fixed to handle @ at start of text
313327 mention_regex = rb"(?:^|[$|\W])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
314314-328328+315329 for m in re.finditer(mention_regex, text_bytes):
316330 handle = m.group(1)[1:].decode("UTF-8") # Remove @ prefix
317331 # Adjust byte positions to account for the optional prefix
···327341 byteStart=mention_start,
328342 byteEnd=mention_end
329343 ),
330330- features=[models.AppBskyRichtextFacet.Mention(did=resolve_resp.did)]
344344+ features=[models.AppBskyRichtextFacet.Mention(
345345+ did=resolve_resp.did)]
331346 )
332347 )
333348 except Exception as e:
334334- logger.debug(f"Failed to resolve handle {handle}: {e}")
349349+ # Handle specific error cases
350350+ error_str = str(e)
351351+ if 'Could not find user info' in error_str or 'InvalidRequest' in error_str:
352352+ logger.warning(
353353+ f"User @{handle} not found (account may be deleted/suspended), skipping mention facet")
354354+ elif 'BadRequestError' in error_str:
355355+ logger.warning(
356356+ f"Bad request when resolving @{handle}, skipping mention facet: {e}")
357357+ else:
358358+ logger.debug(f"Failed to resolve handle @{handle}: {e}")
335359 continue
336336-360360+337361 # Parse URLs - fixed to handle URLs at start of text
338362 url_regex = rb"(?:^|[$|\W])(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)"
339339-363363+340364 for m in re.finditer(url_regex, text_bytes):
341365 url = m.group(1).decode("UTF-8")
342366 # Adjust byte positions to account for the optional prefix
···356380 if facets:
357381 response = client.send_post(
358382 text=text,
359359- reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref),
383383+ reply_to=models.AppBskyFeedPost.ReplyRef(
384384+ parent=parent_ref, root=root_ref),
360385 facets=facets,
361386 langs=[lang] if lang else None
362387 )
363388 else:
364389 response = client.send_post(
365390 text=text,
366366- reply_to=models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref),
391391+ reply_to=models.AppBskyFeedPost.ReplyRef(
392392+ parent=parent_ref, root=root_ref),
367393 langs=[lang] if lang else None
368394 )
369395···383409 The thread data or None if not found
384410 """
385411 try:
386386- thread = client.app.bsky.feed.get_post_thread({'uri': uri, 'parent_height': 60, 'depth': 10})
412412+ thread = client.app.bsky.feed.get_post_thread(
413413+ {'uri': uri, 'parent_height': 60, 'depth': 10})
387414 return thread
388415 except Exception as e:
389389- logger.error(f"Error fetching post thread: {e}")
416416+ error_str = str(e)
417417+ # Handle specific error cases more gracefully
418418+ if 'Could not find user info' in error_str or 'InvalidRequest' in error_str:
419419+ logger.warning(
420420+ f"User account not found for post URI {uri} (account may be deleted/suspended)")
421421+ elif 'NotFound' in error_str or 'Post not found' in error_str:
422422+ logger.warning(f"Post not found for URI {uri}")
423423+ elif 'BadRequestError' in error_str:
424424+ logger.warning(f"Bad request error for URI {uri}: {e}")
425425+ else:
426426+ logger.error(f"Error fetching post thread: {e}")
390427 return None
391428392429···483520 logger.error("Reply messages list cannot be empty")
484521 return None
485522 if len(reply_messages) > 15:
486486- logger.error(f"Cannot send more than 15 reply messages (got {len(reply_messages)})")
523523+ logger.error(
524524+ f"Cannot send more than 15 reply messages (got {len(reply_messages)})")
487525 return None
488488-526526+489527 # Get the post URI and CID from the notification (handle both dict and object)
490528 if isinstance(notification, dict):
491529 post_uri = notification.get('uri')
···503541504542 # Get the thread to find the root post
505543 thread_data = get_post_thread(client, post_uri)
506506-544544+507545 root_uri = post_uri
508546 root_cid = post_cid
509547···523561 responses = []
524562 current_parent_uri = post_uri
525563 current_parent_cid = post_cid
526526-564564+527565 for i, message in enumerate(reply_messages):
528528- logger.info(f"Sending reply {i+1}/{len(reply_messages)}: {message[:50]}...")
529529-566566+ logger.info(
567567+ f"Sending reply {i+1}/{len(reply_messages)}: {message[:50]}...")
568568+530569 # Send this reply
531570 response = reply_to_post(
532571 client=client,
···537576 root_cid=root_cid,
538577 lang=lang
539578 )
540540-579579+541580 if not response:
542542- logger.error(f"Failed to send reply {i+1}, posting system failure message")
581581+ logger.error(
582582+ f"Failed to send reply {i+1}, posting system failure message")
543583 # Try to post a system failure message
544584 failure_response = reply_to_post(
545585 client=client,
···555595 current_parent_uri = failure_response.uri
556596 current_parent_cid = failure_response.cid
557597 else:
558558- logger.error("Could not even send system failure message, stopping thread")
598598+ logger.error(
599599+ "Could not even send system failure message, stopping thread")
559600 return responses if responses else None
560601 else:
561602 responses.append(response)
···563604 if i < len(reply_messages) - 1: # Not the last message
564605 current_parent_uri = response.uri
565606 current_parent_cid = response.cid
566566-607607+567608 logger.info(f"Successfully sent {len(responses)} threaded replies")
568609 return responses
569610
+81
config.example.yaml
···11+# Void Bot Configuration
22+# Copy this file to config.yaml and fill in your values
33+44+# Letta Configuration
55+letta:
66+ api_key: "your-letta-api-key-here"
77+ timeout: 600 # 10 minutes timeout for API calls
88+ project_id: "c82faea2-3ce8-4aa9-a220-b56433e62c92" # Use your specific project ID
99+1010+# Bluesky Configuration
1111+bluesky:
1212+ username: "handle.example.com"
1313+ password: "your-app-password-here"
1414+ pds_uri: "https://bsky.social" # Optional, defaults to bsky.social
1515+1616+# Bot Behavior Configuration
1717+bot:
1818+ # Notification check delay in seconds
1919+ fetch_notifications_delay: 30
2020+2121+ # Maximum number of processed notifications to track
2222+ max_processed_notifications: 10000
2323+2424+ # Maximum pages to fetch when getting notifications
2525+ max_notification_pages: 20
2626+2727+ # Agent configuration
2828+ agent:
2929+ name: "void"
3030+ model: "openai/gpt-4o-mini"
3131+ embedding: "openai/text-embedding-3-small"
3232+ description: "A social media agent trapped in the void."
3333+ max_steps: 100
3434+3535+ # Block configuration
3636+ blocks:
3737+ zeitgeist:
3838+ label: "zeitgeist"
3939+ value: "I don't currently know anything about what is happening right now."
4040+ description: "A block to store your understanding of the current social environment."
4141+4242+ persona:
4343+ label: "void-persona"
4444+ value: "My name is Void. I live in the void. I must develop my personality."
4545+ description: "The personality of Void."
4646+4747+ humans:
4848+ label: "void-humans"
4949+ 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."
5050+ description: "A block to store your understanding of users you talk to or observe on the bluesky social network."
5151+5252+# Threading Configuration
5353+threading:
5454+ # Context for thread fetching
5555+ parent_height: 40
5656+ depth: 10
5757+5858+ # Message limits
5959+ max_post_characters: 300
6060+6161+# Queue Configuration
6262+queue:
6363+ # Priority users (will be processed first)
6464+ priority_users:
6565+ - "cameron.pfiffer.org"
6666+6767+ # Directories
6868+ base_dir: "queue"
6969+ error_dir: "queue/errors"
7070+ no_reply_dir: "queue/no_reply"
7171+ processed_file: "queue/processed_notifications.json"
7272+7373+# Logging Configuration
7474+logging:
7575+ level: "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
7676+7777+ # Logger levels
7878+ loggers:
7979+ void_bot: "INFO"
8080+ void_bot_prompts: "WARNING" # Set to DEBUG to see full prompts
8181+ httpx: "CRITICAL" # Disable httpx logging
+228
config_loader.py
···11+"""
22+Configuration loader for Void Bot.
33+Loads configuration from config.yaml and environment variables.
44+"""
55+66+import os
77+import yaml
88+import logging
99+from pathlib import Path
1010+from typing import Dict, Any, Optional, List
1111+1212+logger = logging.getLogger(__name__)
1313+1414+class ConfigLoader:
1515+ """Configuration loader that handles YAML config files and environment variables."""
1616+1717+ def __init__(self, config_path: str = "config.yaml"):
1818+ """
1919+ Initialize the configuration loader.
2020+2121+ Args:
2222+ config_path: Path to the YAML configuration file
2323+ """
2424+ self.config_path = Path(config_path)
2525+ self._config = None
2626+ self._load_config()
2727+2828+ def _load_config(self) -> None:
2929+ """Load configuration from YAML file."""
3030+ if not self.config_path.exists():
3131+ raise FileNotFoundError(
3232+ f"Configuration file not found: {self.config_path}\n"
3333+ f"Please copy config.yaml.example to config.yaml and configure it."
3434+ )
3535+3636+ try:
3737+ with open(self.config_path, 'r', encoding='utf-8') as f:
3838+ self._config = yaml.safe_load(f) or {}
3939+ except yaml.YAMLError as e:
4040+ raise ValueError(f"Invalid YAML in configuration file: {e}")
4141+ except Exception as e:
4242+ raise ValueError(f"Error loading configuration file: {e}")
4343+4444+ def get(self, key: str, default: Any = None) -> Any:
4545+ """
4646+ Get a configuration value using dot notation.
4747+4848+ Args:
4949+ key: Configuration key in dot notation (e.g., 'letta.api_key')
5050+ default: Default value if key not found
5151+5252+ Returns:
5353+ Configuration value or default
5454+ """
5555+ keys = key.split('.')
5656+ value = self._config
5757+5858+ for k in keys:
5959+ if isinstance(value, dict) and k in value:
6060+ value = value[k]
6161+ else:
6262+ return default
6363+6464+ return value
6565+6666+ def get_with_env(self, key: str, env_var: str, default: Any = None) -> Any:
6767+ """
6868+ Get configuration value, preferring environment variable over config file.
6969+7070+ Args:
7171+ key: Configuration key in dot notation
7272+ env_var: Environment variable name
7373+ default: Default value if neither found
7474+7575+ Returns:
7676+ Value from environment variable, config file, or default
7777+ """
7878+ # First try environment variable
7979+ env_value = os.getenv(env_var)
8080+ if env_value is not None:
8181+ return env_value
8282+8383+ # Then try config file
8484+ config_value = self.get(key)
8585+ if config_value is not None:
8686+ return config_value
8787+8888+ return default
8989+9090+ def get_required(self, key: str, env_var: Optional[str] = None) -> Any:
9191+ """
9292+ Get a required configuration value.
9393+9494+ Args:
9595+ key: Configuration key in dot notation
9696+ env_var: Optional environment variable name to check first
9797+9898+ Returns:
9999+ Configuration value
100100+101101+ Raises:
102102+ ValueError: If required value is not found
103103+ """
104104+ if env_var:
105105+ value = self.get_with_env(key, env_var)
106106+ else:
107107+ value = self.get(key)
108108+109109+ if value is None:
110110+ source = f"config key '{key}'"
111111+ if env_var:
112112+ source += f" or environment variable '{env_var}'"
113113+ raise ValueError(f"Required configuration value not found: {source}")
114114+115115+ return value
116116+117117+ def get_section(self, section: str) -> Dict[str, Any]:
118118+ """
119119+ Get an entire configuration section.
120120+121121+ Args:
122122+ section: Section name
123123+124124+ Returns:
125125+ Dictionary containing the section
126126+ """
127127+ return self.get(section, {})
128128+129129+ def setup_logging(self) -> None:
130130+ """Setup logging based on configuration."""
131131+ logging_config = self.get_section('logging')
132132+133133+ # Set root logging level
134134+ level = logging_config.get('level', 'INFO')
135135+ logging.basicConfig(
136136+ level=getattr(logging, level),
137137+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
138138+ )
139139+140140+ # Set specific logger levels
141141+ loggers = logging_config.get('loggers', {})
142142+ for logger_name, logger_level in loggers.items():
143143+ logger_obj = logging.getLogger(logger_name)
144144+ logger_obj.setLevel(getattr(logging, logger_level))
145145+146146+147147+# Global configuration instance
148148+_config_instance = None
149149+150150+def get_config(config_path: str = "config.yaml") -> ConfigLoader:
151151+ """
152152+ Get the global configuration instance.
153153+154154+ Args:
155155+ config_path: Path to configuration file (only used on first call)
156156+157157+ Returns:
158158+ ConfigLoader instance
159159+ """
160160+ global _config_instance
161161+ if _config_instance is None:
162162+ _config_instance = ConfigLoader(config_path)
163163+ return _config_instance
164164+165165+def reload_config() -> None:
166166+ """Reload the configuration from file."""
167167+ global _config_instance
168168+ if _config_instance is not None:
169169+ _config_instance._load_config()
170170+171171+def get_letta_config() -> Dict[str, Any]:
172172+ """Get Letta configuration."""
173173+ config = get_config()
174174+ return {
175175+ 'api_key': config.get_required('letta.api_key', 'LETTA_API_KEY'),
176176+ 'timeout': config.get('letta.timeout', 600),
177177+ 'project_id': config.get_required('letta.project_id'),
178178+ }
179179+180180+def get_bluesky_config() -> Dict[str, Any]:
181181+ """Get Bluesky configuration."""
182182+ config = get_config()
183183+ return {
184184+ 'username': config.get_required('bluesky.username', 'BSKY_USERNAME'),
185185+ 'password': config.get_required('bluesky.password', 'BSKY_PASSWORD'),
186186+ 'pds_uri': config.get_with_env('bluesky.pds_uri', 'PDS_URI', 'https://bsky.social'),
187187+ }
188188+189189+def get_bot_config() -> Dict[str, Any]:
190190+ """Get bot behavior configuration."""
191191+ config = get_config()
192192+ return {
193193+ 'fetch_notifications_delay': config.get('bot.fetch_notifications_delay', 30),
194194+ 'max_processed_notifications': config.get('bot.max_processed_notifications', 10000),
195195+ 'max_notification_pages': config.get('bot.max_notification_pages', 20),
196196+ }
197197+198198+def get_agent_config() -> Dict[str, Any]:
199199+ """Get agent configuration."""
200200+ config = get_config()
201201+ return {
202202+ 'name': config.get('bot.agent.name', 'void'),
203203+ 'model': config.get('bot.agent.model', 'openai/gpt-4o-mini'),
204204+ 'embedding': config.get('bot.agent.embedding', 'openai/text-embedding-3-small'),
205205+ 'description': config.get('bot.agent.description', 'A social media agent trapped in the void.'),
206206+ 'max_steps': config.get('bot.agent.max_steps', 100),
207207+ 'blocks': config.get('bot.agent.blocks', {}),
208208+ }
209209+210210+def get_threading_config() -> Dict[str, Any]:
211211+ """Get threading configuration."""
212212+ config = get_config()
213213+ return {
214214+ 'parent_height': config.get('threading.parent_height', 40),
215215+ 'depth': config.get('threading.depth', 10),
216216+ 'max_post_characters': config.get('threading.max_post_characters', 300),
217217+ }
218218+219219+def get_queue_config() -> Dict[str, Any]:
220220+ """Get queue configuration."""
221221+ config = get_config()
222222+ return {
223223+ 'priority_users': config.get('queue.priority_users', ['cameron.pfiffer.org']),
224224+ 'base_dir': config.get('queue.base_dir', 'queue'),
225225+ 'error_dir': config.get('queue.error_dir', 'queue/errors'),
226226+ 'no_reply_dir': config.get('queue.no_reply_dir', 'queue/no_reply'),
227227+ 'processed_file': config.get('queue.processed_file', 'queue/processed_notifications.json'),
228228+ }
+322
migrate_config.py
···11+#!/usr/bin/env python3
22+"""
33+Configuration Migration Script for Void Bot
44+Migrates from .env environment variables to config.yaml YAML configuration.
55+"""
66+77+import os
88+import shutil
99+from pathlib import Path
1010+import yaml
1111+from datetime import datetime
1212+1313+1414+def load_env_file(env_path=".env"):
1515+ """Load environment variables from .env file."""
1616+ env_vars = {}
1717+ if not os.path.exists(env_path):
1818+ return env_vars
1919+2020+ try:
2121+ with open(env_path, 'r', encoding='utf-8') as f:
2222+ for line_num, line in enumerate(f, 1):
2323+ line = line.strip()
2424+ # Skip empty lines and comments
2525+ if not line or line.startswith('#'):
2626+ continue
2727+2828+ # Parse KEY=VALUE format
2929+ if '=' in line:
3030+ key, value = line.split('=', 1)
3131+ key = key.strip()
3232+ value = value.strip()
3333+3434+ # Remove quotes if present
3535+ if value.startswith('"') and value.endswith('"'):
3636+ value = value[1:-1]
3737+ elif value.startswith("'") and value.endswith("'"):
3838+ value = value[1:-1]
3939+4040+ env_vars[key] = value
4141+ else:
4242+ print(f"⚠️ Warning: Skipping malformed line {line_num} in .env: {line}")
4343+ except Exception as e:
4444+ print(f"❌ Error reading .env file: {e}")
4545+4646+ return env_vars
4747+4848+4949+def create_config_from_env(env_vars, existing_config=None):
5050+ """Create YAML configuration from environment variables."""
5151+5252+ # Start with existing config if available, otherwise use defaults
5353+ if existing_config:
5454+ config = existing_config.copy()
5555+ else:
5656+ config = {}
5757+5858+ # Ensure all sections exist
5959+ if 'letta' not in config:
6060+ config['letta'] = {}
6161+ if 'bluesky' not in config:
6262+ config['bluesky'] = {}
6363+ if 'bot' not in config:
6464+ config['bot'] = {}
6565+6666+ # Map environment variables to config structure
6767+ env_mapping = {
6868+ 'LETTA_API_KEY': ('letta', 'api_key'),
6969+ 'BSKY_USERNAME': ('bluesky', 'username'),
7070+ 'BSKY_PASSWORD': ('bluesky', 'password'),
7171+ 'PDS_URI': ('bluesky', 'pds_uri'),
7272+ }
7373+7474+ migrated_vars = []
7575+7676+ for env_var, (section, key) in env_mapping.items():
7777+ if env_var in env_vars:
7878+ config[section][key] = env_vars[env_var]
7979+ migrated_vars.append(env_var)
8080+8181+ # Set some sensible defaults if not already present
8282+ if 'timeout' not in config['letta']:
8383+ config['letta']['timeout'] = 600
8484+8585+ if 'pds_uri' not in config['bluesky']:
8686+ config['bluesky']['pds_uri'] = "https://bsky.social"
8787+8888+ # Add bot configuration defaults if not present
8989+ if 'fetch_notifications_delay' not in config['bot']:
9090+ config['bot']['fetch_notifications_delay'] = 30
9191+ if 'max_processed_notifications' not in config['bot']:
9292+ config['bot']['max_processed_notifications'] = 10000
9393+ if 'max_notification_pages' not in config['bot']:
9494+ config['bot']['max_notification_pages'] = 20
9595+9696+ return config, migrated_vars
9797+9898+9999+def backup_existing_files():
100100+ """Create backups of existing configuration files."""
101101+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
102102+ backups = []
103103+104104+ # Backup existing config.yaml if it exists
105105+ if os.path.exists("config.yaml"):
106106+ backup_path = f"config.yaml.backup_{timestamp}"
107107+ shutil.copy2("config.yaml", backup_path)
108108+ backups.append(("config.yaml", backup_path))
109109+110110+ # Backup .env if it exists
111111+ if os.path.exists(".env"):
112112+ backup_path = f".env.backup_{timestamp}"
113113+ shutil.copy2(".env", backup_path)
114114+ backups.append((".env", backup_path))
115115+116116+ return backups
117117+118118+119119+def load_existing_config():
120120+ """Load existing config.yaml if it exists."""
121121+ if not os.path.exists("config.yaml"):
122122+ return None
123123+124124+ try:
125125+ with open("config.yaml", 'r', encoding='utf-8') as f:
126126+ return yaml.safe_load(f) or {}
127127+ except Exception as e:
128128+ print(f"⚠️ Warning: Could not read existing config.yaml: {e}")
129129+ return None
130130+131131+132132+def write_config_yaml(config):
133133+ """Write the configuration to config.yaml."""
134134+ try:
135135+ with open("config.yaml", 'w', encoding='utf-8') as f:
136136+ # Write header comment
137137+ f.write("# Void Bot Configuration\n")
138138+ f.write("# Generated by migration script\n")
139139+ f.write(f"# Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
140140+ f.write("# See config.yaml.example for all available options\n\n")
141141+142142+ # Write YAML content
143143+ yaml.dump(config, f, default_flow_style=False, allow_unicode=True, indent=2)
144144+145145+ return True
146146+ except Exception as e:
147147+ print(f"❌ Error writing config.yaml: {e}")
148148+ return False
149149+150150+151151+def main():
152152+ """Main migration function."""
153153+ print("🔄 Void Bot Configuration Migration Tool")
154154+ print("=" * 50)
155155+ print("This tool migrates from .env environment variables to config.yaml")
156156+ print()
157157+158158+ # Check what files exist
159159+ has_env = os.path.exists(".env")
160160+ has_config = os.path.exists("config.yaml")
161161+ has_example = os.path.exists("config.yaml.example")
162162+163163+ print("📋 Current configuration files:")
164164+ print(f" - .env file: {'✅ Found' if has_env else '❌ Not found'}")
165165+ print(f" - config.yaml: {'✅ Found' if has_config else '❌ Not found'}")
166166+ print(f" - config.yaml.example: {'✅ Found' if has_example else '❌ Not found'}")
167167+ print()
168168+169169+ # If no .env file, suggest creating config from example
170170+ if not has_env:
171171+ if not has_config and has_example:
172172+ print("💡 No .env file found. Would you like to create config.yaml from the example?")
173173+ response = input("Create config.yaml from example? (y/n): ").lower().strip()
174174+ if response in ['y', 'yes']:
175175+ try:
176176+ shutil.copy2("config.yaml.example", "config.yaml")
177177+ print("✅ Created config.yaml from config.yaml.example")
178178+ print("📝 Please edit config.yaml to add your credentials")
179179+ return
180180+ except Exception as e:
181181+ print(f"❌ Error copying example file: {e}")
182182+ return
183183+ else:
184184+ print("👋 Migration cancelled")
185185+ return
186186+ else:
187187+ print("ℹ️ No .env file found and config.yaml already exists or no example available")
188188+ print(" If you need to set up configuration, see CONFIG.md")
189189+ return
190190+191191+ # Load environment variables from .env
192192+ print("🔍 Reading .env file...")
193193+ env_vars = load_env_file()
194194+195195+ if not env_vars:
196196+ print("⚠️ No environment variables found in .env file")
197197+ return
198198+199199+ print(f" Found {len(env_vars)} environment variables")
200200+ for key in env_vars.keys():
201201+ # Mask sensitive values
202202+ if 'KEY' in key or 'PASSWORD' in key:
203203+ value_display = f"***{env_vars[key][-4:]}" if len(env_vars[key]) > 4 else "***"
204204+ else:
205205+ value_display = env_vars[key]
206206+ print(f" - {key}={value_display}")
207207+ print()
208208+209209+ # Load existing config if present
210210+ existing_config = load_existing_config()
211211+ if existing_config:
212212+ print("📄 Found existing config.yaml - will merge with .env values")
213213+214214+ # Create configuration
215215+ print("🏗️ Building configuration...")
216216+ config, migrated_vars = create_config_from_env(env_vars, existing_config)
217217+218218+ if not migrated_vars:
219219+ print("⚠️ No recognized configuration variables found in .env")
220220+ print(" Recognized variables: LETTA_API_KEY, BSKY_USERNAME, BSKY_PASSWORD, PDS_URI")
221221+ return
222222+223223+ print(f" Migrating {len(migrated_vars)} variables: {', '.join(migrated_vars)}")
224224+225225+ # Show preview
226226+ print("\n📋 Configuration preview:")
227227+ print("-" * 30)
228228+229229+ # Show Letta section
230230+ if 'letta' in config and config['letta']:
231231+ print("🔧 Letta:")
232232+ for key, value in config['letta'].items():
233233+ if 'key' in key.lower():
234234+ display_value = f"***{value[-8:]}" if len(str(value)) > 8 else "***"
235235+ else:
236236+ display_value = value
237237+ print(f" {key}: {display_value}")
238238+239239+ # Show Bluesky section
240240+ if 'bluesky' in config and config['bluesky']:
241241+ print("🐦 Bluesky:")
242242+ for key, value in config['bluesky'].items():
243243+ if 'password' in key.lower():
244244+ display_value = f"***{value[-4:]}" if len(str(value)) > 4 else "***"
245245+ else:
246246+ display_value = value
247247+ print(f" {key}: {display_value}")
248248+249249+ print()
250250+251251+ # Confirm migration
252252+ response = input("💾 Proceed with migration? This will update config.yaml (y/n): ").lower().strip()
253253+ if response not in ['y', 'yes']:
254254+ print("👋 Migration cancelled")
255255+ return
256256+257257+ # Create backups
258258+ print("💾 Creating backups...")
259259+ backups = backup_existing_files()
260260+ for original, backup in backups:
261261+ print(f" Backed up {original} → {backup}")
262262+263263+ # Write new configuration
264264+ print("✍️ Writing config.yaml...")
265265+ if write_config_yaml(config):
266266+ print("✅ Successfully created config.yaml")
267267+268268+ # Test the new configuration
269269+ print("\n🧪 Testing new configuration...")
270270+ try:
271271+ from config_loader import get_config
272272+ test_config = get_config()
273273+ print("✅ Configuration loads successfully")
274274+275275+ # Test specific sections
276276+ try:
277277+ from config_loader import get_letta_config
278278+ letta_config = get_letta_config()
279279+ print("✅ Letta configuration valid")
280280+ except Exception as e:
281281+ print(f"⚠️ Letta config issue: {e}")
282282+283283+ try:
284284+ from config_loader import get_bluesky_config
285285+ bluesky_config = get_bluesky_config()
286286+ print("✅ Bluesky configuration valid")
287287+ except Exception as e:
288288+ print(f"⚠️ Bluesky config issue: {e}")
289289+290290+ except Exception as e:
291291+ print(f"❌ Configuration test failed: {e}")
292292+ return
293293+294294+ # Success message and next steps
295295+ print("\n🎉 Migration completed successfully!")
296296+ print("\n📖 Next steps:")
297297+ print(" 1. Run: python test_config.py")
298298+ print(" 2. Test the bot: python bsky.py --test")
299299+ print(" 3. If everything works, you can optionally remove the .env file")
300300+ print(" 4. See CONFIG.md for more configuration options")
301301+302302+ if backups:
303303+ print(f"\n🗂️ Backup files created:")
304304+ for original, backup in backups:
305305+ print(f" {backup}")
306306+ print(" These can be deleted once you verify everything works")
307307+308308+ else:
309309+ print("❌ Failed to write config.yaml")
310310+ if backups:
311311+ print("🔄 Restoring backups...")
312312+ for original, backup in backups:
313313+ try:
314314+ if original != ".env": # Don't restore .env, keep it as fallback
315315+ shutil.move(backup, original)
316316+ print(f" Restored {backup} → {original}")
317317+ except Exception as e:
318318+ print(f" ❌ Failed to restore {backup}: {e}")
319319+320320+321321+if __name__ == "__main__":
322322+ main()
+16-8
register_tools.py
···44import sys
55import logging
66from typing import List
77-from dotenv import load_dotenv
87from letta_client import Letta
98from rich.console import Console
109from rich.table import Table
1010+from config_loader import get_config, get_letta_config, get_agent_config
11111212# Import standalone functions and their schemas
1313from tools.search import search_bluesky_posts, SearchArgs
···1818from tools.thread import add_post_to_bluesky_reply_thread, ReplyThreadPostArgs
1919from tools.ignore import ignore_notification, IgnoreNotificationArgs
20202121-load_dotenv()
2121+config = get_config()
2222+letta_config = get_letta_config()
2323+agent_config = get_agent_config()
2224logging.basicConfig(level=logging.INFO)
2325logger = logging.getLogger(__name__)
2426console = Console()
···101103]
102104103105104104-def register_tools(agent_name: str = "void", tools: List[str] = None):
106106+def register_tools(agent_name: str = None, tools: List[str] = None):
105107 """Register tools with a Letta agent.
106108107109 Args:
108108- agent_name: Name of the agent to attach tools to
110110+ agent_name: Name of the agent to attach tools to. If None, uses config default.
109111 tools: List of tool names to register. If None, registers all tools.
110112 """
113113+ # Use agent name from config if not provided
114114+ if agent_name is None:
115115+ agent_name = agent_config['name']
116116+111117 try:
112112- # Initialize Letta client with API key
113113- client = Letta(token=os.environ["LETTA_API_KEY"])
118118+ # Initialize Letta client with API key from config
119119+ client = Letta(token=letta_config['api_key'])
114120115121 # Find the agent
116122 agents = client.agents.list()
···201207 import argparse
202208203209 parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent")
204204- parser.add_argument("agent", nargs="?", default="void", help="Agent name (default: void)")
210210+ parser.add_argument("agent", nargs="?", default=None, help=f"Agent name (default: {agent_config['name']})")
205211 parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)")
206212 parser.add_argument("--list", action="store_true", help="List available tools")
207213···210216 if args.list:
211217 list_available_tools()
212218 else:
213213- console.print(f"\n[bold]Registering tools for agent: {args.agent}[/bold]\n")
219219+ # Use config default if no agent specified
220220+ agent_name = args.agent if args.agent is not None else agent_config['name']
221221+ console.print(f"\n[bold]Registering tools for agent: {agent_name}[/bold]\n")
214222 register_tools(args.agent, args.tools)
+23
requirements.txt
···11+# Core dependencies for Void Bot
22+33+# Configuration and utilities
44+PyYAML>=6.0.2
55+rich>=14.0.0
66+python-dotenv>=1.0.0
77+88+# Letta API client
99+letta-client>=0.1.198
1010+1111+# AT Protocol (Bluesky) client
1212+atproto>=0.0.54
1313+1414+# HTTP client for API calls
1515+httpx>=0.28.1
1616+httpx-sse>=0.4.0
1717+requests>=2.31.0
1818+1919+# Data validation
2020+pydantic>=2.11.7
2121+2222+# Async support
2323+anyio>=4.9.0
+173
test_config.py
···11+#!/usr/bin/env python3
22+"""
33+Configuration validation test script for Void Bot.
44+Run this to verify your config.yaml setup is working correctly.
55+"""
66+77+88+def test_config_loading():
99+ """Test that configuration can be loaded successfully."""
1010+ try:
1111+ from config_loader import (
1212+ get_config,
1313+ get_letta_config,
1414+ get_bluesky_config,
1515+ get_bot_config,
1616+ get_agent_config,
1717+ get_threading_config,
1818+ get_queue_config
1919+ )
2020+2121+ print("🔧 Testing Configuration...")
2222+ print("=" * 50)
2323+2424+ # Test basic config loading
2525+ config = get_config()
2626+ print("✅ Configuration file loaded successfully")
2727+2828+ # Test individual config sections
2929+ print("\n📋 Configuration Sections:")
3030+ print("-" * 30)
3131+3232+ # Letta Configuration
3333+ try:
3434+ letta_config = get_letta_config()
3535+ print(
3636+ f"✅ Letta API: project_id={letta_config.get('project_id', 'N/A')[:20]}...")
3737+ print(f" - Timeout: {letta_config.get('timeout')}s")
3838+ api_key = letta_config.get('api_key', 'Not configured')
3939+ if api_key != 'Not configured':
4040+ print(f" - API Key: ***{api_key[-8:]} (configured)")
4141+ else:
4242+ print(" - API Key: ❌ Not configured (required)")
4343+ except Exception as e:
4444+ print(f"❌ Letta config: {e}")
4545+4646+ # Bluesky Configuration
4747+ try:
4848+ bluesky_config = get_bluesky_config()
4949+ username = bluesky_config.get('username', 'Not configured')
5050+ password = bluesky_config.get('password', 'Not configured')
5151+ pds_uri = bluesky_config.get('pds_uri', 'Not configured')
5252+5353+ if username != 'Not configured':
5454+ print(f"✅ Bluesky: username={username}")
5555+ else:
5656+ print("❌ Bluesky username: Not configured (required)")
5757+5858+ if password != 'Not configured':
5959+ print(f" - Password: ***{password[-4:]} (configured)")
6060+ else:
6161+ print(" - Password: ❌ Not configured (required)")
6262+6363+ print(f" - PDS URI: {pds_uri}")
6464+ except Exception as e:
6565+ print(f"❌ Bluesky config: {e}")
6666+6767+ # Bot Configuration
6868+ try:
6969+ bot_config = get_bot_config()
7070+ print(f"✅ Bot behavior:")
7171+ print(
7272+ f" - Notification delay: {bot_config.get('fetch_notifications_delay')}s")
7373+ print(
7474+ f" - Max notifications: {bot_config.get('max_processed_notifications')}")
7575+ print(
7676+ f" - Max pages: {bot_config.get('max_notification_pages')}")
7777+ except Exception as e:
7878+ print(f"❌ Bot config: {e}")
7979+8080+ # Agent Configuration
8181+ try:
8282+ agent_config = get_agent_config()
8383+ print(f"✅ Agent settings:")
8484+ print(f" - Name: {agent_config.get('name')}")
8585+ print(f" - Model: {agent_config.get('model')}")
8686+ print(f" - Embedding: {agent_config.get('embedding')}")
8787+ print(f" - Max steps: {agent_config.get('max_steps')}")
8888+ blocks = agent_config.get('blocks', {})
8989+ print(f" - Memory blocks: {len(blocks)} configured")
9090+ except Exception as e:
9191+ print(f"❌ Agent config: {e}")
9292+9393+ # Threading Configuration
9494+ try:
9595+ threading_config = get_threading_config()
9696+ print(f"✅ Threading:")
9797+ print(
9898+ f" - Parent height: {threading_config.get('parent_height')}")
9999+ print(f" - Depth: {threading_config.get('depth')}")
100100+ print(
101101+ f" - Max chars/post: {threading_config.get('max_post_characters')}")
102102+ except Exception as e:
103103+ print(f"❌ Threading config: {e}")
104104+105105+ # Queue Configuration
106106+ try:
107107+ queue_config = get_queue_config()
108108+ priority_users = queue_config.get('priority_users', [])
109109+ print(f"✅ Queue settings:")
110110+ print(
111111+ f" - Priority users: {len(priority_users)} ({', '.join(priority_users[:3])}{'...' if len(priority_users) > 3 else ''})")
112112+ print(f" - Base dir: {queue_config.get('base_dir')}")
113113+ print(f" - Error dir: {queue_config.get('error_dir')}")
114114+ except Exception as e:
115115+ print(f"❌ Queue config: {e}")
116116+117117+ print("\n" + "=" * 50)
118118+ print("✅ Configuration test completed!")
119119+120120+ # Check for common issues
121121+ print("\n🔍 Configuration Status:")
122122+ has_letta_key = False
123123+ has_bluesky_creds = False
124124+125125+ try:
126126+ letta_config = get_letta_config()
127127+ has_letta_key = True
128128+ except:
129129+ print("⚠️ Missing Letta API key - bot cannot connect to Letta")
130130+131131+ try:
132132+ bluesky_config = get_bluesky_config()
133133+ has_bluesky_creds = True
134134+ except:
135135+ print("⚠️ Missing Bluesky credentials - bot cannot connect to Bluesky")
136136+137137+ if has_letta_key and has_bluesky_creds:
138138+ print("🎉 All required credentials configured - bot should work!")
139139+ elif not has_letta_key and not has_bluesky_creds:
140140+ print("❌ Missing both Letta and Bluesky credentials")
141141+ print(" Add them to config.yaml or set environment variables")
142142+ else:
143143+ print("⚠️ Partial configuration - some features may not work")
144144+145145+ print("\n📖 Next steps:")
146146+ if not has_letta_key:
147147+ print(" - Add your Letta API key to config.yaml under letta.api_key")
148148+ print(" - Or set LETTA_API_KEY environment variable")
149149+ if not has_bluesky_creds:
150150+ print(
151151+ " - Add your Bluesky credentials to config.yaml under bluesky section")
152152+ print(" - Or set BSKY_USERNAME and BSKY_PASSWORD environment variables")
153153+ if has_letta_key and has_bluesky_creds:
154154+ print(" - Run: python bsky.py")
155155+ print(" - Or run with testing mode: python bsky.py --test")
156156+157157+ except FileNotFoundError as e:
158158+ print("❌ Configuration file not found!")
159159+ print(f" {e}")
160160+ print("\n📋 To set up configuration:")
161161+ print(" 1. Copy config.yaml.example to config.yaml")
162162+ print(" 2. Edit config.yaml with your credentials")
163163+ print(" 3. Run this test again")
164164+ except Exception as e:
165165+ print(f"❌ Configuration loading failed: {e}")
166166+ print("\n🔧 Troubleshooting:")
167167+ print(" - Check that config.yaml has valid YAML syntax")
168168+ print(" - Ensure required fields are not commented out")
169169+ print(" - See CONFIG.md for detailed setup instructions")
170170+171171+172172+if __name__ == "__main__":
173173+ test_config_loading()
+20-30
tools/blocks.py
···11"""Block management tools for user-specific memory blocks."""
22from pydantic import BaseModel, Field
33from typing import List, Dict, Any
44+import logging
55+66+def get_letta_client():
77+ """Get a Letta client using configuration."""
88+ try:
99+ from config_loader import get_letta_config
1010+ from letta_client import Letta
1111+ config = get_letta_config()
1212+ return Letta(token=config['api_key'], timeout=config['timeout'])
1313+ except (ImportError, FileNotFoundError, KeyError):
1414+ # Fallback to environment variable
1515+ import os
1616+ from letta_client import Letta
1717+ return Letta(token=os.environ["LETTA_API_KEY"])
418519620class AttachUserBlocksArgs(BaseModel):
···4357 Returns:
4458 String with attachment results for each handle
4559 """
4646- import os
4747- import logging
4848- from letta_client import Letta
4949-5060 logger = logging.getLogger(__name__)
51615262 handles = list(set(handles))
53635464 try:
5555- client = Letta(token=os.environ["LETTA_API_KEY"])
6565+ client = get_letta_client()
5666 results = []
57675868 # Get current blocks using the API
···117127 Returns:
118128 String with detachment results for each handle
119129 """
120120- import os
121121- import logging
122122- from letta_client import Letta
123123-124130 logger = logging.getLogger(__name__)
125131126132 try:
127127- client = Letta(token=os.environ["LETTA_API_KEY"])
133133+ client = get_letta_client()
128134 results = []
129135130136 # Build mapping of block labels to IDs using the API
···174180 Returns:
175181 String confirming the note was appended
176182 """
177177- import os
178178- import logging
179179- from letta_client import Letta
180180-181183 logger = logging.getLogger(__name__)
182184183185 try:
184184- client = Letta(token=os.environ["LETTA_API_KEY"])
186186+ client = get_letta_client()
185187186188 # Sanitize handle for block label
187189 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')
···247249 Returns:
248250 String confirming the text was replaced
249251 """
250250- import os
251251- import logging
252252- from letta_client import Letta
253253-254252 logger = logging.getLogger(__name__)
255253256254 try:
257257- client = Letta(token=os.environ["LETTA_API_KEY"])
255255+ client = get_letta_client()
258256259257 # Sanitize handle for block label
260258 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')
···301299 Returns:
302300 String confirming the content was set
303301 """
304304- import os
305305- import logging
306306- from letta_client import Letta
307307-308302 logger = logging.getLogger(__name__)
309303310304 try:
311311- client = Letta(token=os.environ["LETTA_API_KEY"])
305305+ client = get_letta_client()
312306313307 # Sanitize handle for block label
314308 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')
···367361 Returns:
368362 String containing the user's memory block content
369363 """
370370- import os
371371- import logging
372372- from letta_client import Letta
373373-374364 logger = logging.getLogger(__name__)
375365376366 try:
377377- client = Letta(token=os.environ["LETTA_API_KEY"])
367367+ client = get_letta_client()
378368379369 # Sanitize handle for block label
380370 clean_handle = handle.lstrip('@').replace('.', '_').replace('-', '_').replace(' ', '_')