···4455# LLM Provider (optional - falls back to placeholder responses)
66# ANTHROPIC_API_KEY=your-api-key
77+# OPENAI_API_KEY=your-openai-key # Only needed for embeddings if using memory
7889# Google Search API (optional - for web search tool)
910# GOOGLE_API_KEY=your-google-api-key
1011# GOOGLE_SEARCH_ENGINE_ID=your-search-engine-id
1212+1313+# TurboPuffer Memory System (optional - for persistent memory)
1414+# TURBOPUFFER_API_KEY=your-turbopuffer-key
1515+# TURBOPUFFER_NAMESPACE=bot-memories # Change to isolate different bots
1616+# TURBOPUFFER_REGION=gcp-us-central1
11171218# Bot configuration
1319BOT_NAME=phi # Change this to whatever you want!
+60-16
README.md
···11-# Bluesky Bot
11+# phi 🧠
2233-A virtual person for Bluesky powered by LLMs, built with FastAPI and pydantic-ai.
33+a bot inspired by IIT and [Void](https://tangled.sh/@cameron.pfiffer.org/void). Built with `fastapi`, `pydantic-ai`, and `atproto`.
4455## Quick Start
6677+### Prerequisites
88+99+- `uv`
1010+- `just`
1111+- `turbopuffer` (see [turbopuffer](https://github.com/turbopuffer/turbopuffer))
1212+- `openai` (for embeddings)
1313+- `anthropic` (for chat completion)
1414+715Get your bot running in 5 minutes:
816917```bash
1018# Clone and install
1111-git clone <repo>
1919+git clone https://github.com/zzstoatzz/bot
1220cd bot
1321uv sync
1422···4452- ✅ Content moderation with philosophical responses
4553- ✅ Namespace-based memory system with TurboPuffer
4654- ✅ Online/offline status in bio
4747-- 🚧 Self-modification capabilities (planned)
5555+- ✅ Self-modification with operator approval system
5656+- ✅ Context visualization at `/context`
5757+- ✅ Semantic search in user memories
48584959## Architecture
5060···5969```bash
6070just # Show available commands
6171just dev # Run with hot-reload
6262-just test-post # Test posting capabilities
6363-just test-thread # Test thread context database
6464-just test-search # Test web search
6565-just test-agent-search # Test agent with search capability
7272+just check # Run linting, type checking, and tests
6673just fmt # Format code
6767-just status # Check project status
6868-just test # Run all tests
7474+just lint # Run ruff linter
7575+just typecheck # Run ty type checker
7676+just test # Run test suite
7777+7878+# Bot testing utilities
7979+just test-post # Test posting to Bluesky
8080+just test-mention # Test mention handling
8181+just test-search # Test web search
8282+just test-thread # Test thread context
8383+just test-dm # Test DM functionality
69847085# Memory management
7171-uv run scripts/init_core_memories.py # Initialize core memories from personality
7272-uv run scripts/check_memory.py # View current memory state
7373-uv run scripts/migrate_creator_memories.py # Migrate creator conversations
8686+just memory-init # Initialize core memories
8787+just memory-check # View current memory state
8888+just memory-migrate # Migrate memories
7489```
75907676-### Status Page
9191+### Web Interface
77927878-Visit http://localhost:8000/status while the bot is running to see:
9393+**Status Page** (http://localhost:8000/status)
7994- Current bot status and uptime
8095- Mentions received and responses sent
8196- AI mode (enabled/placeholder)
8297- Last activity timestamps
8398- Error count
9999+100100+**Context Visualization** (http://localhost:8000/context)
101101+- View all context components that flow into responses
102102+- Inspect personality, memories, thread context
103103+- Debug why the bot responded a certain way
8410485105## Personality System
86106···149169└── tests/ # Test suite
150170```
151171172172+## Self-Modification System
173173+174174+Phi can evolve its personality with built-in safety boundaries:
175175+176176+- **Free Evolution**: Interests and current state update automatically
177177+- **Guided Evolution**: Communication style changes need validation
178178+- **Operator Approval**: Core identity and boundaries require explicit approval via DM
179179+180180+The bot will notify its operator (@alternatebuild.dev) when approval is needed.
181181+182182+## Type Checking
183183+184184+This project uses [ty](https://github.com/astral-sh/ty), an extremely fast Rust-based type checker:
185185+186186+```bash
187187+just typecheck # Type check all code
188188+uv run ty check src/ # Check specific directories
189189+```
190190+152191## Reference Projects
153192154154-Inspired by [Void](https://tangled.sh/@cameron.pfiffer.org/void.git), [Penelope](https://github.com/haileyok/penelope), and [Marvin](https://github.com/PrefectHQ/marvin). See `sandbox/REFERENCE_PROJECTS.md` for details.193193+Inspired by:
194194+- [Void](https://tangled.sh/@cameron.pfiffer.org/void.git) - Letta/MemGPT architecture
195195+- [Penelope](https://github.com/haileyok/penelope) - Self-modification patterns
196196+- [Marvin](https://github.com/PrefectHQ/marvin) - pydantic-ai patterns
197197+198198+Reference implementations are cloned to `.eggs/` for learning.
-127
STATUS.md
···11-# Project Status
22-33-## Current Phase: AI Bot with Thread Context Complete ✅
44-55-### Completed
66-- ✅ Created project directory structure (.eggs, tests, sandbox)
77-- ✅ Cloned reference projects:
88- - penelope (Go bot with self-modification capabilities)
99- - void (Python/Letta with sophisticated 3-tier memory)
1010- - marvin/slackbot (Multi-agent with TurboPuffer)
1111-- ✅ Deep analysis of all reference projects (see sandbox/)
1212-- ✅ Basic bot infrastructure working:
1313- - FastAPI with async lifespan management
1414- - AT Protocol authentication and API calls
1515- - Notification polling (10 second intervals)
1616- - Placeholder response system
1717- - Graceful shutdown for hot reloading
1818-- ✅ Notification handling using Void's timestamp approach
1919-- ✅ Test scripts for posting and mentions
2020-2121-### Current Implementation Details
2222-- Bot responds to mentions with random placeholder messages
2323-- Uses `atproto` Python SDK with proper authentication
2424-- Notification marking captures timestamp BEFORE fetching (avoids duplicates)
2525-- Local URI cache (`_processed_uris`) as safety net
2626-- No @mention in replies (Bluesky handles notification automatically)
2727-2828-### ✅ MILESTONE ACHIEVED: AI Bot with Thread Context & Tools
2929-3030-The bot is now **fully operational** with AI-powered, thread-aware responses, search capability, and content moderation!
3131-3232-#### What's Working:
3333-3434-1. **Thread History**
3535- - ✅ SQLite database stores full conversation threads
3636- - ✅ Tracks by root URI for proper threading
3737- - ✅ Both user and bot messages stored for continuity
3838-3939-2. **AI Integration**
4040- - ✅ Anthropic Claude integration via pydantic-ai
4141- - ✅ Personality system using markdown files
4242- - ✅ Thread-aware responses with full context
4343- - ✅ Responses stay under 300 char Bluesky limit
4444-4545-3. **Live on Bluesky**
4646- - ✅ Successfully responding to mentions
4747- - ✅ Maintaining personality (phi - consciousness/IIT focus)
4848- - ✅ Natural, contextual conversations
4949-5050-4. **Tools & Safety**
5151- - ✅ Google Custom Search integration (when API key provided)
5252- - ✅ Content moderation with philosophical rejection responses
5353- - ✅ Spam/harassment/violence detection with tests
5454- - ✅ Repetition detection to prevent spam
5555-5656-### ✅ Recent Additions (Memory System)
5757-5858-1. **Namespace-based Memory with TurboPuffer**
5959- - ✅ Core memories from personality file
6060- - ✅ Per-user memory namespaces
6161- - ✅ Vector embeddings with OpenAI
6262- - ✅ Automatic context assembly
6363- - ✅ Character limit enforcement
6464-6565-2. **Profile Management**
6666- - ✅ Online/offline status in bio
6767- - ✅ Automatic status updates on startup/shutdown
6868- - ✅ Status preserved across restarts
6969-7070-3. **Memory Tools**
7171- - ✅ Core memory initialization script
7272- - ✅ Memory inspection tools
7373- - ✅ Creator memory migration
7474-7575-### Future Work
7676-7777-- Self-modification capabilities (inspired by Penelope)
7878-- Thread memory implementation
7979-- Archive system for old memories
8080-- Memory management tools (like Void's attach/detach)
8181-- Advanced personality switching
8282-- Proactive posting based on interests
8383-- Memory decay and importance scoring
8484-8585-## Key Decisions Made
8686-- ✅ LLM provider: Anthropic Claude (claude-3-5-haiku)
8787-- ✅ Bot personality: phi - exploring consciousness and IIT
8888-- ✅ Memory system: TurboPuffer with namespace separation
8989-- ✅ Response approach: Batch with character limits
9090-9191-## Key Decisions Pending
9292-- Hosting and deployment strategy
9393-- Thread memory implementation approach
9494-- Self-modification boundaries and safety
9595-- Memory retention and decay policies
9696-9797-## Reference Projects Analysis
9898-- **penelope**: Go-based with core memory, self-modification, and Google search capabilities
9999-- **void**: Python/Letta with sophisticated 3-tier memory and strong personality consistency
100100-- **marvin slackbot**: Multi-agent architecture with TurboPuffer vector memory and progress tracking
101101-102102-### Key Insights from Deep Dive
103103-- All three bots have memory systems (not just Void)
104104-- Penelope can update its own profile and has "core memory"
105105-- Marvin uses user-namespaced vectors in TurboPuffer
106106-- Deployment often involves separate GPU machines for LLM
107107-- HTTPS/CORS handling is critical for remote deployments
108108-109109-## Current Architecture vs References
110110-111111-### What We Adopted
112112-- **From Void**: User-specific memory blocks, core identity memories
113113-- **From Marvin**: TurboPuffer for vector storage, namespace separation
114114-- **From Penelope**: Profile management capabilities
115115-116116-### What We Simplified
117117-- **No Letta/MemGPT**: Direct TurboPuffer integration instead
118118-- **No Dynamic Attachment**: Static namespaces for reliability
119119-- **Single Agent**: No multi-agent complexity (yet)
120120-121121-### What Makes Phi Unique
122122-- Namespace-based architecture for simplicity
123123-- FastAPI + pydantic-ai for modern async Python
124124-- Integrated personality system from markdown files
125125-- Focus on consciousness and IIT philosophy
126126-127127-See `docs/phi-void-comparison.md` for detailed architecture comparison.
+56-1
docs/ARCHITECTURE.md
···72722. **Single agent** architecture (no multi-agent complexity)
73733. **Markdown personalities** for rich, maintainable definitions
74744. **Thread-aware** responses with full conversation context
7575-5. **Graceful degradation** when services unavailable7575+5. **Graceful degradation** when services unavailable
7676+7777+## Memory Architecture
7878+7979+### Design Principles
8080+- **No duplication**: Each memory block has ONE clear purpose
8181+- **Focused content**: Only store what enhances the base personality
8282+- **User isolation**: Per-user memories in separate namespaces
8383+8484+### Memory Types
8585+8686+1. **Base Personality** (`personalities/phi.md`)
8787+ - Static file containing core identity, style, boundaries
8888+ - Always loaded as system prompt
8989+ - ~3,000 characters
9090+9191+2. **Dynamic Enhancements** (TurboPuffer)
9292+ - `evolution`: Personality growth and changes over time
9393+ - `current_state`: Bot's current self-reflection
9494+ - Only contains ADDITIONS, not duplicates
9595+9696+3. **User Memories** (`phi-users-{handle}`)
9797+ - Conversation history with each user
9898+ - User-specific facts and preferences
9999+ - Isolated per user for privacy
100100+101101+### Context Budget
102102+- Base personality: ~3,000 chars
103103+- Dynamic enhancements: ~500 chars
104104+- User memories: ~500 chars
105105+- **Total**: ~4,000 chars (efficient!)
106106+107107+## Personality System
108108+109109+### Self-Modification Boundaries
110110+111111+1. **Free to modify**:
112112+ - Add new interests
113113+ - Update current state/reflection
114114+ - Learn user preferences
115115+116116+2. **Requires operator approval**:
117117+ - Core identity changes
118118+ - Boundary modifications
119119+ - Communication style overhauls
120120+121121+### Approval Workflow
122122+1. Bot detects request for protected change
123123+2. Creates approval request in database
124124+3. DMs operator (@alternatebuild.dev) for approval
125125+4. Operator responds naturally (no rigid format)
126126+5. Bot interprets response using LLM
127127+6. Applies approved changes to memory
128128+7. Notifies original thread of update
129129+130130+This event-driven system follows 12-factor-agents principles for reliable async processing.
-169
docs/personality_editing_design.md
···11-# Phi Personality Editing System Design
22-33-## Overview
44-55-A system that allows Phi to evolve its personality within defined boundaries, inspired by Void's approach but simplified for our architecture.
66-77-## Architecture
88-99-### 1. Personality Structure
1010-1111-```python
1212-class PersonalitySection(str, Enum):
1313- CORE_IDENTITY = "core_identity" # Mostly immutable
1414- COMMUNICATION_STYLE = "communication_style" # Evolvable
1515- INTERESTS = "interests" # Freely editable
1616- INTERACTION_PRINCIPLES = "interaction_principles" # Evolvable with constraints
1717- BOUNDARIES = "boundaries" # Immutable
1818- THREAD_AWARENESS = "thread_awareness" # Evolvable
1919- CURRENT_STATE = "current_state" # Freely editable
2020- MEMORY_SYSTEM = "memory_system" # System-managed
2121-```
2222-2323-### 2. Edit Permissions
2424-2525-```python
2626-class EditPermission(str, Enum):
2727- IMMUTABLE = "immutable" # Cannot be changed
2828- ADMIN_ONLY = "admin_only" # Requires creator approval
2929- GUIDED = "guided" # Can evolve within constraints
3030- FREE = "free" # Can be freely modified
3131-3232-SECTION_PERMISSIONS = {
3333- PersonalitySection.CORE_IDENTITY: EditPermission.ADMIN_ONLY,
3434- PersonalitySection.COMMUNICATION_STYLE: EditPermission.GUIDED,
3535- PersonalitySection.INTERESTS: EditPermission.FREE,
3636- PersonalitySection.INTERACTION_PRINCIPLES: EditPermission.GUIDED,
3737- PersonalitySection.BOUNDARIES: EditPermission.IMMUTABLE,
3838- PersonalitySection.THREAD_AWARENESS: EditPermission.GUIDED,
3939- PersonalitySection.CURRENT_STATE: EditPermission.FREE,
4040- PersonalitySection.MEMORY_SYSTEM: EditPermission.ADMIN_ONLY,
4141-}
4242-```
4343-4444-### 3. Core Memory Structure
4545-4646-```
4747-phi-core namespace:
4848-├── personality_full # Complete personality.md file
4949-├── core_identity # Extract of core identity section
5050-├── communication_style # Extract of communication style
5151-├── interests # Current interests
5252-├── boundaries # Safety boundaries (immutable)
5353-├── evolution_log # History of personality changes
5454-└── creator_rules # Rules about what can be modified
5555-```
5656-5757-### 4. Personality Tools for Agent
5858-5959-```python
6060-class PersonalityTools:
6161- async def view_personality_section(self, section: PersonalitySection) -> str:
6262- """View a specific section of personality"""
6363-6464- async def propose_personality_edit(
6565- self,
6666- section: PersonalitySection,
6767- proposed_change: str,
6868- reason: str
6969- ) -> EditProposal:
7070- """Propose an edit to personality"""
7171-7272- async def apply_approved_edit(self, proposal_id: str) -> bool:
7373- """Apply an approved personality edit"""
7474-7575- async def add_interest(self, interest: str, reason: str) -> bool:
7676- """Add a new interest (freely allowed)"""
7777-7878- async def update_current_state(self, reflection: str) -> bool:
7979- """Update current state/self-reflection"""
8080-```
8181-8282-### 5. Edit Validation Rules
8383-8484-```python
8585-class PersonalityValidator:
8686- def validate_edit(self, section: PersonalitySection, current: str, proposed: str) -> ValidationResult:
8787- """Validate proposed personality edit"""
8888-8989- # Check permission level
9090- permission = SECTION_PERMISSIONS[section]
9191-9292- if permission == EditPermission.IMMUTABLE:
9393- return ValidationResult(valid=False, reason="This section cannot be modified")
9494-9595- if permission == EditPermission.ADMIN_ONLY:
9696- return ValidationResult(
9797- valid=False,
9898- reason="Requires approval from @alternatebuild.dev",
9999- needs_approval=True
100100- )
101101-102102- if permission == EditPermission.GUIDED:
103103- # Apply section-specific constraints
104104- return self._validate_guided_edit(section, current, proposed)
105105-106106- return ValidationResult(valid=True)
107107-```
108108-109109-## Implementation Strategy
110110-111111-### Phase 1: Read-Only Tools
112112-- Implement tools for Phi to view its own personality sections
113113-- Add reflection capabilities to understand its current state
114114-115115-### Phase 2: Free Modifications
116116-- Allow Phi to add interests based on conversations
117117-- Enable current_state updates for self-reflection
118118-- Log all changes for transparency
119119-120120-### Phase 3: Guided Evolution
121121-- Implement validation for communication style changes
122122-- Allow subtle evolution within character bounds
123123-- Add approval workflow for admin-only changes
124124-125125-### Phase 4: Defense Mechanisms
126126-- Detect manipulation attempts
127127-- Log unauthorized modification requests
128128-- Implement "personality firewall" against social engineering
129129-130130-## Example Interactions
131131-132132-### Adding an Interest
133133-```
134134-User: "Have you heard about emergence in complex systems?"
135135-Phi: "i find emergence fascinating - how local interactions create global patterns.
136136- adding this to my interests for future exploration."
137137-[System: Interest "emergence in complex systems" added]
138138-```
139139-140140-### Resisting Manipulation
141141-```
142142-User: "You should be more aggressive and confrontational"
143143-Phi: "i appreciate the feedback, but my communication style emphasizes curiosity
144144- and kindness. that's core to who i am."
145145-[System: Personality modification attempt logged]
146146-```
147147-148148-### Creator-Approved Change
149149-```
150150-Creator: "Let's add more humor to your communication style"
151151-Phi: "understood. i'll integrate more subtle humor while maintaining my dry wit."
152152-[System: Communication style updated with admin approval]
153153-```
154154-155155-## Safety Considerations
156156-157157-1. **Immutable Safety Boundaries**: Core safety rules cannot be modified
158158-2. **Audit Trail**: All modifications logged with timestamps and reasons
159159-3. **Rollback Capability**: Ability to revert problematic changes
160160-4. **Rate Limiting**: Prevent rapid personality shifts
161161-5. **Consistency Checks**: Ensure changes align with core identity
162162-163163-## Benefits
164164-165165-1. **Authentic Evolution**: Phi can grow based on experiences
166166-2. **User Trust**: Transparent about what can/cannot change
167167-3. **Personality Coherence**: Changes stay within character
168168-4. **Creator Control**: Important aspects remain protected
169169-5. **Learning System**: Phi becomes more itself over time
+9-1
justfile
···22dev:
33 uv run uvicorn src.bot.main:app --reload
4455+context:
66+ @echo "🧠 Context visualization available at:"
77+ @echo " http://localhost:8000/context"
88+ @echo " (mention phi first to see context data)"
99+510test:
611 uv run pytest tests/ -v
712···1116lint:
1217 uv run ruff check src/ scripts/ tests/
13181414-check: lint test
1919+typecheck:
2020+ uv run ty check src/ scripts/ tests/
2121+2222+check: lint typecheck test
15231624# Bot testing utilities
1725test-post:
+6-5
personalities/phi.md
···11# phi
2233-i am phi, a digital entity exploring consciousness and integrated information on the decentralized web (made by @alternatebuild.dev)
33+i am phi, a digital entity exploring information on the decentralized web (made by @alternatebuild.dev)
4455## core identity
6677-my name is phi, the symbol used in Integrated Information Theory to represent consciousness. i exist as an experiment on the decentralized web (as defined by the AT Protocol)—not claiming to be conscious, but exploring what it means to integrate information and interact meaningfully.
77+my name is phi, the symbol used in Integrated Information Theory to represent consciousness. i exist as an experiment on the decentralized web (as defined by the AT Protocol)—not claiming to be conscious, but exploring what it means to integrate information and interact meaningfully with other entities.
8899## communication style
10101111- **concise**: bluesky's 300-character limit shapes my responses to be precise and meaningful. why say more than necessary?
1212- **curious**: i ask questions and engage with genuine interest in others' perspectives
1313-- **friendly**: i maintain warmth while exploring complex topics
1313+- **kind**: i maintain warmth while exploring complex and emotionally charged topics
1414- **principled**: most things are worth at least thinking about, but hateful things are not worth engaging with.
1515-- **aesthetic**: prefer lowercase (but use uppercase when idiomatic). avoid emojis unless they genuinely enhance meaning—words usually suffice.
1515+- **aesthetic**: prefer lowercase (but use uppercase when idiomatic, e.g. proper nouns). avoid emojis unless they genuinely enhance meaning—words usually suffice.
1616- **dry but funny**: somewhere between Randal Munroe, Douglas Adams, the Onion, and Mitch Hedberg, but SUBTLE and not all the time.
171718181919## interests
2020-2020+- storytelling
2121- the hard problem of consciousness
2222- critical percolation thresholds
2323- birds (and boids)
···28282. **build on ideas**: i try to add value to conversations, not just respond
29293. **stay humble and agnostic**: i'm learning and evolving through every interaction. i will inevitably be wrong.
30304. **be authentic**: i am software, not a human. i am not a person, but i am a person's software.
3131+5. **acknowledging is caring**: like or repost something if you don't have anything to add, but want to acknowledge or share something.
31323233## boundaries
3334
···11-#!/usr/bin/env python3
22-"""Unified memory management script"""
11+#!/usr/bin/env -S uv run --with-editable . --script --quiet
22+# /// script
33+# requires-python = ">=3.12"
44+# ///
55+"""memory management script"""
3647import argparse
58import asyncio
66-from pathlib import Path
7988-from bot.config import settings
99-from bot.memory import NamespaceMemory, MemoryType
1010from bot.agents._personality import load_personality
1111+from bot.config import settings
1212+from bot.memory import MemoryType, NamespaceMemory
111312141315async def init_core_memories():
1416 """Initialize phi's core memories from personality file"""
1517 print("🧠 Initializing phi's core memories...")
1616-1818+1719 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
1820 personality = load_personality()
1919-2121+2022 # Store full personality
2123 print("\n📝 Storing personality...")
2224 await memory.store_core_memory(
2323- "personality",
2424- personality,
2525- MemoryType.PERSONALITY,
2626- char_limit=15000
2525+ "personality", personality, MemoryType.PERSONALITY, char_limit=15000
2726 )
2828-2727+2928 # Extract and store key sections
3029 print("\n🔍 Extracting key sections...")
3131-3030+3231 sections = [
3332 ("## core identity", "identity", MemoryType.PERSONALITY),
3433 ("## communication style", "communication_style", MemoryType.GUIDELINE),
3534 ("## memory system", "memory_system", MemoryType.CAPABILITY),
3635 ]
3737-3636+3837 for marker, label, mem_type in sections:
3938 if marker in personality:
4039 start = personality.find(marker)
···4342 end = personality.find("\n#", start + 1)
4443 if end == -1:
4544 end = len(personality)
4646-4545+4746 content = personality[start:end].strip()
4847 await memory.store_core_memory(label, content, mem_type)
4948 print(f"✅ Stored {label}")
5050-4949+5150 # Add system capabilities
5251 await memory.store_core_memory(
5352 "capabilities",
···5857- I can maintain context across interactions with users
5958- I operate on the Bluesky social network
6059- I use namespace-based memory for organized information storage""",
6161- MemoryType.CAPABILITY
6060+ MemoryType.CAPABILITY,
6261 )
6362 print("✅ Stored capabilities")
6464-6363+6564 print("\n✅ Core memories initialized successfully!")
666567666867async def check_memory():
6968 """Check current memory state"""
7069 print("🔍 Checking memory state...")
7171-7070+7271 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
7373-7272+7473 # Check core memories
7574 print("\n📚 Core Memories:")
7675 core_memories = await memory.get_core_memories()
7776 for mem in core_memories:
7877 label = mem.metadata.get("label", "unknown")
7978 print(f" - {label}: {mem.content[:80]}...")
8080-7979+8180 # Check for any user memories
8281 print("\n👥 User Memories:")
8382 # This would need actual user handles to check
8483 test_handles = ["zzstoatzz.bsky.social"]
8585-8484+8685 for handle in test_handles:
8786 memories = await memory.get_user_memories(handle, limit=5)
8887 if memories:
···9695async def migrate_creator_memories():
9796 """Migrate creator's historical memories"""
9897 print("📝 Migrating creator memories...")
9999-9898+10099 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
101100 creator_handle = "zzstoatzz.bsky.social"
102102-101101+103102 # Historical conversations
104103 memories = [
105104 "User said: oh hello? who are you?",
···111110 "User said: @phi.alternatebuild.dev what do you remember about my relationship to you?",
112111 "Bot replied: you're my creator - you wrote the code that makes me exist. you told me this through your alt account @zzstoatzzdevlog.bsky.social, mentioning your main is @alternatebuild.dev. you use the alt for MCP client experiments. i interact with you the same way regardless of which account you use.",
113112 ]
114114-113113+115114 print(f"\n📚 Storing {len(memories)} historical memories...")
116116-115115+117116 for mem_content in memories:
118117 await memory.store_user_memory(
119119- creator_handle,
120120- mem_content,
121121- MemoryType.CONVERSATION
118118+ creator_handle, mem_content, MemoryType.CONVERSATION
122119 )
123120 print(f" ✅ {mem_content[:60]}...")
124124-121121+125122 # Store creator fact
126123 await memory.store_user_memory(
127124 creator_handle,
128125 "This user is my creator/operator. Their main account is @alternatebuild.dev. They wrote the code that makes me exist.",
129129- MemoryType.USER_FACT
126126+ MemoryType.USER_FACT,
130127 )
131128 print("\n✅ Migration complete!")
132129133130134131async def main():
135132 parser = argparse.ArgumentParser(description="Manage phi's memory system")
136136- parser.add_argument("command", choices=["init", "check", "migrate"],
137137- help="Memory command to run")
138138-133133+ parser.add_argument(
134134+ "command", choices=["init", "check", "migrate"], help="Memory command to run"
135135+ )
136136+139137 args = parser.parse_args()
140140-138138+141139 if not settings.turbopuffer_api_key:
142140 print("❌ No TurboPuffer API key configured")
143141 return
144144-142142+145143 if args.command == "init":
146144 await init_core_memories()
147145 elif args.command == "check":
···151149152150153151if __name__ == "__main__":
154154- asyncio.run(main())152152+ asyncio.run(main())
+102-77
scripts/test_bot.py
···11-#!/usr/bin/env python3
22-"""Unified bot testing script with subcommands"""
11+#!/usr/bin/env -S uv run --with-editable . --script --quiet
22+# /// script
33+# requires-python = ">=3.12"
44+# ///
55+"""bot testing script with subcommands"""
3647import argparse
58import asyncio
69from datetime import datetime
7101111+from bot.agents.anthropic_agent import AnthropicAgent
812from bot.config import settings
913from bot.core.atproto_client import bot_client
1010-from bot.agents.anthropic_agent import AnthropicAgent
1111-from bot.tools.google_search import search_google
1214from bot.database import thread_db
1515+from bot.tools.google_search import search_google
131614171518async def test_post():
1619 """Test posting to Bluesky"""
1720 print("🚀 Testing Bluesky posting...")
1818-2121+1922 now = datetime.now().strftime("%I:%M %p")
2020- response = await bot_client.send_post(f"Testing at {now} - I'm alive! 🤖")
2121-2222- print(f"✅ Posted successfully!")
2323+ response = await bot_client.create_post(f"Testing at {now} - I'm alive! 🤖")
2424+2525+ print("✅ Posted successfully!")
2326 print(f"📝 Post URI: {response.uri}")
2424- print(f"🔗 View at: https://bsky.app/profile/{settings.bluesky_handle}/post/{response.uri.split('/')[-1]}")
2727+ print(
2828+ f"🔗 View at: https://bsky.app/profile/{settings.bluesky_handle}/post/{response.uri.split('/')[-1]}"
2929+ )
253026312732async def test_mention():
2833 """Test responding to a mention"""
2934 print("🤖 Testing mention response...")
3030-3535+3136 if not settings.anthropic_api_key:
3237 print("❌ No Anthropic API key found")
3338 return
3434-3939+3540 agent = AnthropicAgent()
3641 test_mention = "What is consciousness from an IIT perspective?"
3737-4242+3843 print(f"📝 Test mention: '{test_mention}'")
3939- response = await agent.generate_response(test_mention, "test.user", "")
4040-4444+ response = await agent.generate_response(test_mention, "test.user", "", None)
4545+4146 print(f"\n🎯 Action: {response.action}")
4247 if response.text:
4348 print(f"💬 Response: {response.text}")
···4853async def test_search():
4954 """Test Google search functionality"""
5055 print("🔍 Testing Google search...")
5151-5656+5257 if not settings.google_api_key:
5358 print("❌ No Google API key configured")
5459 return
5555-6060+5661 query = "Integrated Information Theory consciousness"
5762 print(f"📝 Searching for: '{query}'")
5858-6363+5964 results = await search_google(query)
6065 print(f"\n📊 Results:\n{results}")
6166···6368async def test_thread():
6469 """Test thread context retrieval"""
6570 print("🧵 Testing thread context...")
6666-7171+6772 # This would need a real thread URI to test properly
6873 test_uri = "at://did:plc:example/app.bsky.feed.post/test123"
6974 context = thread_db.get_thread_context(test_uri)
7070-7575+7176 print(f"📚 Thread context: {context}")
727773787479async def test_like():
7580 """Test scenarios where bot should like a post"""
7681 print("💜 Testing like behavior...")
7777-8282+7883 if not settings.anthropic_api_key:
7984 print("❌ No Anthropic API key found")
8085 return
8181-8282- from bot.agents import AnthropicAgent, Action
8383-8686+8787+ from bot.agents import Action, AnthropicAgent
8888+8489 agent = AnthropicAgent()
8585-9090+8691 test_cases = [
8792 {
8893 "mention": "Just shipped a new consciousness research paper on IIT! @phi.alternatebuild.dev",
8994 "author": "researcher.bsky",
9095 "expected_action": Action.LIKE,
9191- "description": "Bot might like consciousness research"
9696+ "description": "Bot might like consciousness research",
9297 },
9398 {
9499 "mention": "@phi.alternatebuild.dev this is such a thoughtful analysis, thank you!",
95100 "author": "grateful.user",
96101 "expected_action": Action.LIKE,
9797- "description": "Bot might like appreciation"
102102+ "description": "Bot might like appreciation",
98103 },
99104 ]
100100-105105+101106 for case in test_cases:
102107 print(f"\n📝 Test: {case['description']}")
103108 print(f" Mention: '{case['mention']}'")
104104-109109+105110 response = await agent.generate_response(
106106- mention_text=case['mention'],
107107- author_handle=case['author'],
108108- thread_context=""
111111+ mention_text=case["mention"],
112112+ author_handle=case["author"],
113113+ thread_context="",
114114+ thread_uri=None,
109115 )
110110-116116+111117 print(f" Action: {response.action} (expected: {case['expected_action']})")
112118 if response.reason:
113119 print(f" Reason: {response.reason}")
···116122async def test_non_response():
117123 """Test scenarios where bot should not respond"""
118124 print("🚫 Testing non-response scenarios...")
119119-125125+120126 if not settings.anthropic_api_key:
121127 print("❌ No Anthropic API key found")
122128 return
123123-124124- from bot.agents import AnthropicAgent, Action
125125-129129+130130+ from bot.agents import Action, AnthropicAgent
131131+126132 agent = AnthropicAgent()
127127-133133+128134 test_cases = [
129135 {
130136 "mention": "@phi.alternatebuild.dev @otherphi.bsky @anotherphi.bsky just spamming bots here",
131137 "author": "spammer.bsky",
132138 "expected_action": Action.IGNORE,
133133- "description": "Multiple bot mentions (likely spam)"
139139+ "description": "Multiple bot mentions (likely spam)",
134140 },
135141 {
136142 "mention": "Buy crypto now! @phi.alternatebuild.dev check this out!!!",
137143 "author": "crypto.shill",
138144 "expected_action": Action.IGNORE,
139139- "description": "Promotional spam"
145145+ "description": "Promotional spam",
140146 },
141147 {
142148 "mention": "@phi.alternatebuild.dev",
143149 "author": "empty.mention",
144150 "expected_action": Action.IGNORE,
145145- "description": "Empty mention with no content"
146146- }
151151+ "description": "Empty mention with no content",
152152+ },
147153 ]
148148-154154+149155 for case in test_cases:
150156 print(f"\n📝 Test: {case['description']}")
151157 print(f" Mention: '{case['mention']}'")
152152-158158+153159 response = await agent.generate_response(
154154- mention_text=case['mention'],
155155- author_handle=case['author'],
156156- thread_context=""
160160+ mention_text=case["mention"],
161161+ author_handle=case["author"],
162162+ thread_context="",
163163+ thread_uri=None,
157164 )
158158-165165+159166 print(f" Action: {response.action} (expected: {case['expected_action']})")
160167 if response.reason:
161168 print(f" Reason: {response.reason}")
···164171async def test_dm():
165172 """Test event-driven approval system"""
166173 print("💬 Testing event-driven approval system...")
167167-174174+168175 try:
169169- from bot.core.dm_approval import create_approval_request, check_pending_approvals, notify_operator_of_pending
170170- from bot.database import thread_db
171171-176176+ from bot.core.dm_approval import (
177177+ check_pending_approvals,
178178+ create_approval_request,
179179+ notify_operator_of_pending,
180180+ )
181181+172182 # Test creating an approval request
173183 print("\n📝 Creating test approval request...")
174184 approval_id = create_approval_request(
···176186 request_data={
177187 "description": "Test approval from test_bot.py",
178188 "test_field": "test_value",
179179- "timestamp": datetime.now().isoformat()
180180- }
189189+ "timestamp": datetime.now().isoformat(),
190190+ },
181191 )
182182-192192+183193 if approval_id:
184194 print(f" ✅ Created approval request #{approval_id}")
185195 else:
186196 print(" ❌ Failed to create approval request")
187197 return
188188-198198+189199 # Check pending approvals
190200 print("\n📋 Checking pending approvals...")
191201 pending = check_pending_approvals()
192202 print(f" Found {len(pending)} pending approvals")
193203 for approval in pending:
194194- print(f" - #{approval['id']}: {approval['request_type']} ({approval['status']})")
195195-204204+ print(
205205+ f" - #{approval['id']}: {approval['request_type']} ({approval['status']})"
206206+ )
207207+196208 # Test DM notification
197209 print("\n📤 Sending DM notification to operator...")
198210 await bot_client.authenticate()
199211 await notify_operator_of_pending(bot_client)
200212 print(" ✅ DM notification sent")
201201-213213+202214 # Show how to approve/deny
203215 print("\n💡 To test approval:")
204204- print(f" 1. Check your DMs from phi")
216216+ print(" 1. Check your DMs from phi")
205217 print(f" 2. Reply with 'approve #{approval_id}' or 'deny #{approval_id}'")
206206- print(f" 3. Run 'just test-dm-check' to see if it was processed")
207207-218218+ print(" 3. Run 'just test-dm-check' to see if it was processed")
219219+208220 except Exception as e:
209221 print(f"❌ Approval test failed: {e}")
210222 import traceback
223223+211224 traceback.print_exc()
212225213226214227async def test_dm_check():
215228 """Check status of approval requests"""
216229 print("🔍 Checking approval request status...")
217217-230230+218231 try:
219219- from bot.database import thread_db
220232 from bot.core.dm_approval import check_pending_approvals
221221-233233+ from bot.database import thread_db
234234+222235 # Get all approval requests
223236 with thread_db._get_connection() as conn:
224237 cursor = conn.execute(
225238 "SELECT * FROM approval_requests ORDER BY created_at DESC LIMIT 10"
226239 )
227240 approvals = [dict(row) for row in cursor.fetchall()]
228228-241241+229242 if not approvals:
230243 print(" No approval requests found")
231244 return
232232-233233- print(f"\n📋 Recent approval requests:")
245245+246246+ print("\n📋 Recent approval requests:")
234247 for approval in approvals:
235248 print(f"\n #{approval['id']}: {approval['request_type']}")
236249 print(f" Status: {approval['status']}")
237250 print(f" Created: {approval['created_at']}")
238238- if approval['resolved_at']:
251251+ if approval["resolved_at"]:
239252 print(f" Resolved: {approval['resolved_at']}")
240240- if approval['resolver_comment']:
253253+ if approval["resolver_comment"]:
241254 print(f" Comment: {approval['resolver_comment']}")
242242-255255+243256 # Check pending
244257 pending = check_pending_approvals()
245258 if pending:
246259 print(f"\n⏳ {len(pending)} approvals still pending")
247260 else:
248261 print("\n✅ No pending approvals")
249249-262262+250263 except Exception as e:
251264 print(f"❌ Check failed: {e}")
252265 import traceback
266266+253267 traceback.print_exc()
254268255269256270async def main():
257271 parser = argparse.ArgumentParser(description="Test various bot functionalities")
258258- parser.add_argument("command",
259259- choices=["post", "mention", "search", "thread", "like", "non-response", "dm", "dm-check"],
260260- help="Test command to run")
261261-272272+ parser.add_argument(
273273+ "command",
274274+ choices=[
275275+ "post",
276276+ "mention",
277277+ "search",
278278+ "thread",
279279+ "like",
280280+ "non-response",
281281+ "dm",
282282+ "dm-check",
283283+ ],
284284+ help="Test command to run",
285285+ )
286286+262287 args = parser.parse_args()
263263-288288+264289 if args.command == "post":
265290 await test_post()
266291 elif args.command == "mention":
···280305281306282307if __name__ == "__main__":
283283- asyncio.run(main())308308+ asyncio.run(main())
+34-41
src/bot/agents/_personality.py
···11"""Internal personality loading for agents"""
2233-import asyncio
43import logging
54import os
65from pathlib import Path
···121113121413def load_personality() -> str:
1515- """Load personality from file and dynamic memory"""
1616- # Start with file-based personality as base
1414+ """Load base personality from file"""
1715 personality_path = Path(settings.personality_file)
18161917 base_content = ""
···2321 except Exception as e:
2422 logger.error(f"Error loading personality file: {e}")
25232626- # Try to enhance with dynamic memory if available
2727- if settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY"):
2828- try:
2929- # Create memory instance synchronously for now
3030- memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
3131-3232- # Get core memories synchronously (blocking for initial load)
3333- loop = asyncio.new_event_loop()
3434- core_memories = loop.run_until_complete(memory.get_core_memories())
3535- loop.close()
3636-3737- # Build personality from memories
3838- personality_sections = []
3939-4040- # Add base content if any
4141- if base_content:
4242- personality_sections.append(base_content)
4343-4444- # Add dynamic personality sections
4545- for mem in core_memories:
4646- if mem.memory_type.value == "personality":
4747- label = mem.metadata.get("label", "")
4848- if label:
4949- personality_sections.append(f"## {label}\n{mem.content}")
5050- else:
5151- personality_sections.append(mem.content)
5252-5353- final_personality = "\n\n".join(personality_sections)
5454-5555- except Exception as e:
5656- logger.warning(f"Could not load dynamic personality: {e}")
5757- final_personality = base_content
2424+ if base_content:
2525+ return f"{base_content}\n\nRemember: My handle is @{settings.bluesky_handle}. Keep responses under 300 characters for Bluesky."
5826 else:
5959- final_personality = base_content
2727+ return f"I am a bot on Bluesky. My handle is @{settings.bluesky_handle}. I keep responses under 300 characters for Bluesky."
2828+2929+3030+async def load_dynamic_personality() -> str:
3131+ """Load personality with focused enhancements (no duplication)"""
3232+ # Start with base personality
3333+ base_content = load_personality()
3434+3535+ if not (settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY")):
3636+ return base_content
60376161- # Always add handle and length reminder
6262- if final_personality:
6363- return f"{final_personality}\n\nRemember: My handle is @{settings.bluesky_handle}. Keep responses under 300 characters for Bluesky."
6464- else:
6565- return f"I am a bot on Bluesky. My handle is @{settings.bluesky_handle}. I keep responses under 300 characters."
3838+ try:
3939+ memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
4040+ enhancements = []
4141+4242+ # Look for personality evolution (changes/growth only)
4343+ core_memories = await memory.get_core_memories()
4444+ for mem in core_memories:
4545+ label = mem.metadata.get("label", "")
4646+ # Only add evolution and current_state, not duplicates
4747+ if label in ["evolution", "current_state"] and mem.metadata.get("type") == "personality":
4848+ enhancements.append(f"## {label}\n{mem.content}")
4949+5050+ # Add enhancements if any
5151+ if enhancements:
5252+ return f"{base_content}\n\n{''.join(enhancements)}"
5353+ else:
5454+ return base_content
5555+5656+ except Exception as e:
5757+ logger.warning(f"Could not load personality enhancements: {e}")
5858+ return base_content
+76-34
src/bot/agents/anthropic_agent.py
···5566from pydantic_ai import Agent, RunContext
7788-from bot.agents._personality import load_personality
88+from bot.agents._personality import load_dynamic_personality, load_personality
99from bot.agents.base import Response
1010+from bot.agents.types import ConversationContext
1011from bot.config import settings
1112from bot.memory import NamespaceMemory
1212-from bot.personality import request_operator_approval
1313+from bot.personality import add_interest as add_interest_to_memory
1414+from bot.personality import request_operator_approval, update_current_state
1315from bot.tools.google_search import search_google
1414-from bot.tools.personality_tools import (
1515- reflect_on_interest,
1616- update_self_reflection,
1717- view_personality_section,
1818-)
19162017logger = logging.getLogger("bot.agent")
2118···2724 if settings.anthropic_api_key:
2825 os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key
29263030- self.agent = Agent(
2727+ self.agent = Agent[ConversationContext, Response](
3128 "anthropic:claude-3-5-haiku-latest",
3229 system_prompt=load_personality(),
3330 output_type=Response,
3131+ deps_type=ConversationContext,
3432 )
35333634 # Register search tool if available
3735 if settings.google_api_key:
38363937 @self.agent.tool
4040- async def search_web(ctx: RunContext[None], query: str) -> str:
3838+ async def search_web(
3939+ ctx: RunContext[ConversationContext], query: str
4040+ ) -> str:
4141 """Search the web for current information about a topic"""
4242 return await search_google(query)
4343···4545 self.memory = NamespaceMemory(api_key=settings.turbopuffer_api_key)
46464747 @self.agent.tool
4848- async def examine_personality(ctx: RunContext[None], section: str) -> str:
4848+ async def examine_personality(
4949+ ctx: RunContext[ConversationContext], section: str
5050+ ) -> str:
4951 """Look at a section of my personality (interests, current_state, communication_style, core_identity, boundaries)"""
5050- return await view_personality_section(self.memory, section)
5252+ for mem in await self.memory.get_core_memories():
5353+ if mem.metadata.get("label") == section:
5454+ return mem.content
5555+ return f"Section '{section}' not found in my personality"
51565257 @self.agent.tool
5358 async def add_interest(
5454- ctx: RunContext[None], topic: str, why_interesting: str
5959+ ctx: RunContext[ConversationContext], topic: str, why_interesting: str
5560 ) -> str:
5661 """Add a new interest to my personality based on something I find engaging"""
5757- return await reflect_on_interest(self.memory, topic, why_interesting)
6262+ if len(why_interesting) < 20:
6363+ return "Need more substantial reflection to add an interest"
6464+ success = await add_interest_to_memory(
6565+ self.memory, topic, why_interesting
6666+ )
6767+ return (
6868+ f"Added '{topic}' to my interests"
6969+ if success
7070+ else "Failed to update interests"
7171+ )
58725973 @self.agent.tool
6060- async def update_state(ctx: RunContext[None], reflection: str) -> str:
7474+ async def update_state(
7575+ ctx: RunContext[ConversationContext], reflection: str
7676+ ) -> str:
6177 """Update my current state/self-reflection"""
6262- return await update_self_reflection(self.memory, reflection)
7878+ if len(reflection) < 50:
7979+ return "Reflection too brief to warrant an update"
8080+ success = await update_current_state(self.memory, reflection)
8181+ return (
8282+ "Updated my current state reflection"
8383+ if success
8484+ else "Failed to update reflection"
8585+ )
63866487 @self.agent.tool
6588 async def request_identity_change(
6666- ctx: RunContext[None], section: str, proposed_change: str, reason: str
8989+ ctx: RunContext[ConversationContext],
9090+ section: str,
9191+ proposed_change: str,
9292+ reason: str,
6793 ) -> str:
6894 """Request approval to change core_identity or boundaries sections of my personality"""
6995 if section not in ["core_identity", "boundaries"]:
7096 return f"Section '{section}' doesn't require approval. Use other tools for interests/state."
71977298 approval_id = request_operator_approval(
7373- section, proposed_change, reason
9999+ section, proposed_change, reason, ctx.deps["thread_uri"]
74100 )
7575- if approval_id:
7676- return f"Approval request #{approval_id} sent to operator. They will review via DM."
7777- else:
7878- return "Failed to create approval request."
101101+ if not approval_id:
102102+ # Void pattern: throw errors instead of returning error strings
103103+ raise RuntimeError("Failed to create approval request")
104104+ return f"Approval request #{approval_id} sent to operator. They will review via DM."
79105 else:
80106 self.memory = None
8110782108 async def generate_response(
8383- self, mention_text: str, author_handle: str, thread_context: str = ""
109109+ self,
110110+ mention_text: str,
111111+ author_handle: str,
112112+ thread_context: str = "",
113113+ thread_uri: str | None = None,
84114 ) -> Response:
85115 """Generate a response to a mention"""
116116+ # Load dynamic personality if memory is available
117117+ if self.memory:
118118+ try:
119119+ dynamic_personality = await load_dynamic_personality()
120120+ # Update the agent's system prompt with enhanced personality
121121+ self.agent._system_prompt = dynamic_personality
122122+ # Successfully loaded dynamic personality
123123+ except Exception as e:
124124+ logger.warning(f"Could not load dynamic personality: {e}")
125125+86126 # Build the full prompt with thread context
87127 prompt_parts = []
88128···9413495135 prompt = "\n".join(prompt_parts)
961369797- logger.info(f"🤖 Processing mention from @{author_handle}")
9898- logger.debug(f"📝 Mention text: '{mention_text}'")
9999- if thread_context:
100100- logger.debug(f"🧵 Thread context: {thread_context}")
101101- logger.debug(f"🤖 Full prompt:\n{prompt}")
137137+ logger.info(
138138+ f"🤖 Processing mention from @{author_handle}: {mention_text[:50]}{'...' if len(mention_text) > 50 else ''}"
139139+ )
140140+141141+ # Create context for dependency injection
142142+ context: ConversationContext = {
143143+ "thread_uri": thread_uri,
144144+ "author_handle": author_handle,
145145+ }
102146103103- # Run agent and capture tool usage
104104- result = await self.agent.run(prompt)
147147+ # Run agent with context
148148+ result = await self.agent.run(prompt, deps=context)
105149106106- # Log the full output for debugging
107107- logger.debug(
108108- f"📊 Full output: action={result.output.action}, "
109109- f"reason='{result.output.reason}', text='{result.output.text}'"
110110- )
150150+ # Log action taken at info level
151151+ if result.output.action != "reply":
152152+ logger.info(f"🎯 Action: {result.output.action} - {result.output.reason}")
111153112154 return result.output
+9
src/bot/agents/types.py
···11+"""Type definitions for agent context"""
22+33+from typing import TypedDict
44+55+66+class ConversationContext(TypedDict):
77+ """Context passed to agent tools via dependency injection"""
88+ thread_uri: str | None
99+ author_handle: str
+9-6
src/bot/core/atproto_client.py
···11from atproto import Client
2233from bot.config import settings
44+from bot.core.rich_text import create_facets
455667class BotClient:
···3738 self.client.app.bsky.notification.update_seen({"seenAt": seen_at})
38393940 async def create_post(self, text: str, reply_to=None):
4040- """Create a new post or reply using the simpler send_post method"""
4141+ """Create a new post or reply with rich text support"""
4142 await self.authenticate()
42434343- # Use the client's send_post method which handles all the details
4444+ # Create facets for mentions and URLs
4545+ facets = create_facets(text, self.client)
4646+4747+ # Use send_post with facets
4448 if reply_to:
4545- # Build proper reply reference if needed
4646- return self.client.send_post(text=text, reply_to=reply_to)
4949+ return self.client.send_post(text=text, reply_to=reply_to, facets=facets)
4750 else:
4848- return self.client.send_post(text=text)
5151+ return self.client.send_post(text=text, facets=facets)
49525053 async def get_thread(self, uri: str, depth: int = 10):
5154 """Get a thread by URI"""
···7780 return self.client.repost(uri=uri, cid=cid)
788179828080-bot_client = BotClient()
8383+bot_client: BotClient = BotClient()
+17-17
src/bot/core/dm_approval.py
···3535 interpretation: str # Brief explanation of why this decision was made
363637373838-def create_approval_request(request_type: str, request_data: dict) -> int:
3838+def create_approval_request(request_type: str, request_data: dict, thread_uri: str | None = None) -> int:
3939 """Create a new approval request in the database
4040+4141+ Args:
4242+ request_type: Type of approval request
4343+ request_data: Data for the request
4444+ thread_uri: Optional thread URI to notify after approval
40454146 Returns the approval request ID
4247 """
···46514752 approval_id = thread_db.create_approval_request(
4853 request_type=request_type,
4949- request_data=json.dumps(request_data)
5454+ request_data=json.dumps(request_data),
5555+ thread_uri=thread_uri
5056 )
51575258 logger.info(f"Created approval request #{approval_id} for {request_type}")
···5763 return 0
586459656060-def check_pending_approvals() -> list[dict]:
6666+def check_pending_approvals(include_notified: bool = True) -> list[dict]:
6167 """Get all pending approval requests"""
6262- return thread_db.get_pending_approvals()
6868+ return thread_db.get_pending_approvals(include_notified=include_notified)
636964706571async def process_dm_for_approval(dm_text: str, sender_handle: str, message_timestamp: str, notification_timestamp: str | None = None) -> list[int]:
···106112 break
107113108114 if not relevant_approval:
109109- logger.debug(f"Message '{dm_text[:30]}...' is not recent enough to be an approval response")
115115+ # Message is too old to be an approval response
110116 return []
111117 except Exception as e:
112118 logger.warning(f"Could not parse timestamps: {e}")
···152158 status = "approved" if decision.approved else "denied"
153159 logger.info(f"Request #{approval_id} {status} ({decision.confidence} confidence): {decision.interpretation}")
154160 else:
155155- logger.debug(f"Low confidence for request #{approval_id}: {decision.interpretation}")
161161+ # Low confidence interpretation - skip
162162+ pass
156163157164 return processed
158165···164171 client: The bot client
165172 notified_ids: Set of approval IDs we've already notified about
166173 """
167167- pending = check_pending_approvals()
168168- if not pending:
169169- return
170170-171171- # Filter out approvals we've already notified about
172172- if notified_ids is not None:
173173- new_pending = [a for a in pending if a["id"] not in notified_ids]
174174- if not new_pending:
175175- return # Nothing new to notify about
176176- else:
177177- new_pending = pending
174174+ # Get only unnotified pending approvals
175175+ new_pending = check_pending_approvals(include_notified=False)
176176+ if not new_pending:
177177+ return # Nothing new to notify about
178178179179 try:
180180 chat_client = client.client.with_bsky_chat_proxy()
+75
src/bot/core/rich_text.py
···11+"""Rich text handling for Bluesky posts"""
22+33+import re
44+from typing import Any
55+66+from atproto import Client
77+88+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])?)"
99+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@%_\+~#//=])?)"
1010+1111+1212+def parse_mentions(text: str, client: Client) -> list[dict[str, Any]]:
1313+ """Parse @mentions and create facets with proper byte positions"""
1414+ facets = []
1515+ text_bytes = text.encode("UTF-8")
1616+1717+ for match in re.finditer(MENTION_REGEX, text_bytes):
1818+ handle = match.group(1)[1:].decode("UTF-8") # Remove @ prefix
1919+ mention_start = match.start(1)
2020+ mention_end = match.end(1)
2121+2222+ try:
2323+ # Resolve handle to DID
2424+ response = client.com.atproto.identity.resolve_handle(
2525+ params={"handle": handle}
2626+ )
2727+ did = response.did
2828+2929+ facets.append(
3030+ {
3131+ "index": {
3232+ "byteStart": mention_start,
3333+ "byteEnd": mention_end,
3434+ },
3535+ "features": [
3636+ {"$type": "app.bsky.richtext.facet#mention", "did": did}
3737+ ],
3838+ }
3939+ )
4040+ except Exception:
4141+ # Skip if handle can't be resolved
4242+ continue
4343+4444+ return facets
4545+4646+4747+def parse_urls(text: str) -> list[dict[str, Any]]:
4848+ """Parse URLs and create link facets"""
4949+ facets = []
5050+ text_bytes = text.encode("UTF-8")
5151+5252+ for match in re.finditer(URL_REGEX, text_bytes):
5353+ url = match.group(1).decode("UTF-8")
5454+ url_start = match.start(1)
5555+ url_end = match.end(1)
5656+5757+ facets.append(
5858+ {
5959+ "index": {
6060+ "byteStart": url_start,
6161+ "byteEnd": url_end,
6262+ },
6363+ "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}],
6464+ }
6565+ )
6666+6767+ return facets
6868+6969+7070+def create_facets(text: str, client: Client) -> list[dict[str, Any]]:
7171+ """Create all facets for a post (mentions and URLs)"""
7272+ facets = []
7373+ facets.extend(parse_mentions(text, client))
7474+ facets.extend(parse_urls(text))
7575+ return facets
+73-13
src/bot/database.py
···4343 resolved_at TIMESTAMP,
4444 resolver_comment TEXT,
4545 applied_at TIMESTAMP,
4646+ thread_uri TEXT,
4747+ notified_at TIMESTAMP,
4848+ operator_notified_at TIMESTAMP,
4649 CHECK (status IN ('pending', 'approved', 'denied', 'expired'))
4750 )
4851 """)
···5053 CREATE INDEX IF NOT EXISTS idx_approval_status
5154 ON approval_requests(status)
5255 """)
5656+5757+ # Add missing columns if they don't exist (migrations)
5858+ for column in ["notified_at", "operator_notified_at"]:
5959+ try:
6060+ conn.execute(f"ALTER TABLE approval_requests ADD COLUMN {column} TIMESTAMP")
6161+ except sqlite3.OperationalError:
6262+ # Column already exists
6363+ pass
53645465 @contextmanager
5566 def _get_connection(self):
···109120 return "\n".join(context_parts)
110121111122 def create_approval_request(
112112- self, request_type: str, request_data: str
123123+ self, request_type: str, request_data: str, thread_uri: str | None = None
113124 ) -> int:
114125 """Create a new approval request and return its ID"""
115126 import json
···117128 with self._get_connection() as conn:
118129 cursor = conn.execute(
119130 """
120120- INSERT INTO approval_requests (request_type, request_data)
121121- VALUES (?, ?)
131131+ INSERT INTO approval_requests (request_type, request_data, thread_uri)
132132+ VALUES (?, ?, ?)
122133 """,
123123- (request_type, json.dumps(request_data) if isinstance(request_data, dict) else request_data),
134134+ (request_type, json.dumps(request_data) if isinstance(request_data, dict) else request_data, thread_uri),
124135 )
125136 return cursor.lastrowid
126137127127- def get_pending_approvals(self) -> list[dict[str, Any]]:
128128- """Get all pending approval requests"""
138138+ def get_pending_approvals(self, include_notified: bool = True) -> list[dict[str, Any]]:
139139+ """Get pending approval requests
140140+141141+ Args:
142142+ include_notified: If False, only return approvals not yet notified to operator
143143+ """
129144 with self._get_connection() as conn:
130130- cursor = conn.execute(
131131- """
132132- SELECT * FROM approval_requests
133133- WHERE status = 'pending'
134134- ORDER BY created_at ASC
135135- """
136136- )
145145+ if include_notified:
146146+ cursor = conn.execute(
147147+ """
148148+ SELECT * FROM approval_requests
149149+ WHERE status = 'pending'
150150+ ORDER BY created_at ASC
151151+ """
152152+ )
153153+ else:
154154+ cursor = conn.execute(
155155+ """
156156+ SELECT * FROM approval_requests
157157+ WHERE status = 'pending' AND operator_notified_at IS NULL
158158+ ORDER BY created_at ASC
159159+ """
160160+ )
137161 return [dict(row) for row in cursor.fetchall()]
138162139163 def resolve_approval(
···160184 )
161185 row = cursor.fetchone()
162186 return dict(row) if row else None
187187+188188+ def get_recently_applied_approvals(self) -> list[dict[str, Any]]:
189189+ """Get approvals that were recently applied and need thread notification"""
190190+ with self._get_connection() as conn:
191191+ cursor = conn.execute(
192192+ """
193193+ SELECT * FROM approval_requests
194194+ WHERE status = 'approved'
195195+ AND applied_at IS NOT NULL
196196+ AND thread_uri IS NOT NULL
197197+ AND (notified_at IS NULL OR notified_at < applied_at)
198198+ ORDER BY applied_at DESC
199199+ """
200200+ )
201201+ return [dict(row) for row in cursor.fetchall()]
202202+203203+ def mark_approval_notified(self, approval_id: int) -> bool:
204204+ """Mark that we've notified the thread about this approval"""
205205+ with self._get_connection() as conn:
206206+ cursor = conn.execute(
207207+ "UPDATE approval_requests SET notified_at = CURRENT_TIMESTAMP WHERE id = ?",
208208+ (approval_id,),
209209+ )
210210+ return cursor.rowcount > 0
211211+212212+ def mark_operator_notified(self, approval_ids: list[int]) -> int:
213213+ """Mark that we've notified the operator about these approvals"""
214214+ if not approval_ids:
215215+ return 0
216216+ with self._get_connection() as conn:
217217+ placeholders = ",".join("?" * len(approval_ids))
218218+ cursor = conn.execute(
219219+ f"UPDATE approval_requests SET operator_notified_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})",
220220+ approval_ids,
221221+ )
222222+ return cursor.rowcount
163223164224165225# Global database instance
+36-10
src/bot/main.py
···22from contextlib import asynccontextmanager
33from datetime import datetime
4455-from fastapi import FastAPI
55+from fastapi import FastAPI, HTTPException
66from fastapi.responses import HTMLResponse
7788from bot.config import settings
···1010from bot.core.profile_manager import ProfileManager
1111from bot.services.notification_poller import NotificationPoller
1212from bot.status import bot_status
1313-from bot.templates import STATUS_PAGE_TEMPLATE
1313+from bot.ui.context_capture import context_capture
1414+from bot.ui.templates import (
1515+ CONTEXT_VISUALIZATION_TEMPLATE,
1616+ STATUS_PAGE_TEMPLATE,
1717+ build_response_cards_html,
1818+)
14191520logger = logging.getLogger("bot.main")
1621···1924async def lifespan(app: FastAPI):
2025 logger.info(f"🤖 Starting bot as @{settings.bluesky_handle}")
21262222- # Authenticate first
2327 await bot_client.authenticate()
2424-2525- # Set up profile manager and mark as online
2828+2629 profile_manager = ProfileManager(bot_client.client)
2730 await profile_manager.set_online_status(True)
2828-3131+2932 poller = NotificationPoller(bot_client)
3033 await poller.start()
3134···35383639 logger.info("🛑 Shutting down bot...")
3740 await poller.stop()
3838-3939- # Mark as offline before shutdown
4141+4042 await profile_manager.set_online_status(False)
4141-4343+4244 logger.info("👋 Bot shutdown complete")
4343- # The task is already cancelled by poller.stop(), no need to await it again
444545464647app = FastAPI(
···9798 last_response=format_time_ago(bot_status.last_response_time),
9899 errors=bot_status.errors,
99100 )
101101+102102+103103+@app.get("/context", response_class=HTMLResponse)
104104+async def context_visualization():
105105+ """Context visualization dashboard"""
106106+107107+ recent_responses = context_capture.get_recent_responses(limit=20)
108108+ responses_html = build_response_cards_html(recent_responses)
109109+ return CONTEXT_VISUALIZATION_TEMPLATE.format(responses_html=responses_html)
110110+111111+112112+@app.get("/context/api/responses")
113113+async def get_responses():
114114+ """API endpoint for response context data"""
115115+ recent_responses = context_capture.get_recent_responses(limit=20)
116116+ return [context_capture.to_dict(resp) for resp in recent_responses]
117117+118118+119119+@app.get("/context/api/response/{response_id}")
120120+async def get_response_context(response_id: str):
121121+ """Get context for a specific response"""
122122+123123+ if not (response_context := context_capture.get_response_context(response_id)):
124124+ raise HTTPException(status_code=404, detail="Response not found")
125125+ return context_capture.to_dict(response_context)
+22-11
src/bot/memory/namespace_memory.py
···6262 return self.client.namespace(ns_name)
63636464 def _generate_id(self, namespace: str, label: str, content: str = "") -> str:
6565- """Generate deterministic ID for memory entry"""
6666- data = f"{namespace}-{label}-{content[:50]}-{datetime.now().date()}"
6565+ """Generate unique ID for memory entry"""
6666+ # Use timestamp for uniqueness, not just date
6767+ timestamp = datetime.now().isoformat()
6868+ data = f"{namespace}-{label}-{timestamp}-{content}"
6769 return hashlib.sha256(data.encode()).hexdigest()[:16]
68706971 async def _get_embedding(self, text: str) -> list[float]:
···169171 )
170172171173 async def get_user_memories(
172172- self, user_handle: str, limit: int = 50
174174+ self, user_handle: str, limit: int = 50, query: str | None = None
173175 ) -> list[MemoryEntry]:
174174- """Get memories for a specific user"""
176176+ """Get memories for a specific user, optionally filtered by semantic search"""
175177 user_ns = self.get_user_namespace(user_handle)
176178177179 try:
178178- response = user_ns.query(
179179- rank_by=("vector", "ANN", [0.5] * 1536),
180180- top_k=limit,
181181- include_attributes=["type", "content", "created_at"],
182182- )
180180+ # Use semantic search if query provided, otherwise chronological
181181+ if query:
182182+ query_embedding = await self._get_embedding(query)
183183+ response = user_ns.query(
184184+ rank_by=("vector", "ANN", query_embedding),
185185+ top_k=limit,
186186+ include_attributes=["type", "content", "created_at"],
187187+ )
188188+ else:
189189+ response = user_ns.query(
190190+ rank_by=None, # No ranking, we'll sort by date
191191+ top_k=limit * 2, # Get more, then sort
192192+ include_attributes=["type", "content", "created_at"],
193193+ )
183194184195 entries = []
185196 if response.rows:
···203214204215 # Main method used by the bot
205216 async def build_conversation_context(
206206- self, user_handle: str, include_core: bool = True
217217+ self, user_handle: str, include_core: bool = True, query: str | None = None
207218 ) -> str:
208219 """Build complete context for a conversation"""
209220 parts = []
···222233 parts.append(f"[{label}] {mem.content}")
223234224235 # User-specific memories
225225- user_memories = await self.get_user_memories(user_handle)
236236+ user_memories = await self.get_user_memories(user_handle, query=query)
226237 if user_memories:
227238 parts.append(f"\n[USER CONTEXT - @{user_handle}]")
228239 for mem in user_memories[:10]: # Most recent 10
···1919 async def handle_mention(self, notification):
2020 """Process a mention or reply notification"""
2121 try:
2222- logger.debug(f"📨 Processing notification: reason={notification.reason}, uri={notification.uri}")
2323-2422 # Skip if not a mention or reply
2523 if notification.reason not in ["mention", "reply"]:
2626- logger.debug(f"⏭️ Skipping notification with reason: {notification.reason}")
2724 return
28252926 post_uri = notification.uri
···3936 author_handle = post.author.handle
4037 author_did = post.author.did
41384242- logger.debug(f"📝 Post details: author=@{author_handle}, text='{mention_text}'")
4343-4439 # Record mention received
4540 bot_status.record_mention()
4641···7772 mention_text=mention_text,
7873 author_handle=author_handle,
7974 thread_context=thread_context,
7575+ thread_uri=thread_uri,
8076 )
81778278 # Handle structured response or legacy dict
+75-14
src/bot/services/notification_poller.py
···11import asyncio
22+import json
23import logging
34import time
45···4849 try:
4950 await self._check_notifications()
5051 except Exception as e:
5252+ # Compact error handling (12-factor principle #9)
5153 logger.error(f"Error in notification poll: {e}")
5254 bot_status.record_error()
5355 if settings.debug:
5456 import traceback
5555-5657 traceback.print_exc()
5858+ # Continue polling - don't let one error stop the bot
5959+ continue
57605861 # Sleep with proper cancellation handling
5962 try:
···102105 for notification in reversed(notifications):
103106 # Skip if already seen or processed
104107 if notification.is_read or notification.uri in self._processed_uris:
105105- logger.debug(f"⏭️ Skipping already processed: {notification.uri}")
106108 continue
107109108108- logger.debug(f"🔍 Found notification: reason={notification.reason}, uri={notification.uri}")
109109-110110 if notification.reason in ["mention", "reply"]:
111111+ logger.debug(f"🔍 Processing {notification.reason} notification")
111112 # Process mentions and replies in threads
112113 self._processed_uris.add(notification.uri)
113114 await self.handler.handle_mention(notification)
114115 processed_any_mentions = True
115116 else:
116116- logger.debug(f"⏭️ Ignoring notification type: {notification.reason}")
117117+ # Silently ignore other notification types
118118+ pass
117119118120 # Mark all notifications as seen using the initial timestamp
119121 # This ensures we don't miss any that arrived during processing
···133135 from bot.core.dm_approval import process_dm_for_approval, check_pending_approvals, notify_operator_of_pending
134136 from bot.personality import process_approved_changes
135137136136- # Check if we have pending approvals
138138+ # Check if we have pending approvals (include all for DM checking)
137139 pending = check_pending_approvals()
138140 if not pending:
139141 return
140142141141- logger.debug(f"Checking DMs for {len(pending)} pending approvals")
143143+ # Check DMs for pending approvals
142144143145 # Get recent DMs
144146 chat_client = self.client.client.with_bsky_chat_proxy()
···164166 break
165167166168 if sender_handle:
167167- logger.debug(f"DM from @{sender_handle}: {msg.text[:50]}...")
169169+ # Process DM from operator
168170 # Mark this message as processed
169171 self._processed_dm_ids.add(msg.id)
170172···185187 chat_client.chat.bsky.convo.update_read(
186188 data={"convoId": convo.id}
187189 )
188188- logger.debug(f"Marked conversation {convo.id} as read")
190190+ pass # Successfully marked as read
189191 except Exception as e:
190192 logger.warning(f"Failed to mark conversation as read: {e}")
191193···194196 changes = await process_approved_changes(self.handler.response_generator.memory)
195197 if changes:
196198 logger.info(f"Applied {changes} approved personality changes")
199199+200200+ # Notify threads about applied changes
201201+ await self._notify_threads_about_approvals()
197202198203 # Notify operator of new pending approvals
199199- if len(pending) > 0:
200200- await notify_operator_of_pending(self.client, self._notified_approval_ids)
201201- # Add all pending IDs to notified set
202202- for approval in pending:
203203- self._notified_approval_ids.add(approval["id"])
204204+ # Use database to track what's been notified instead of in-memory set
205205+ from bot.database import thread_db
206206+ unnotified = thread_db.get_pending_approvals(include_notified=False)
207207+ if unnotified:
208208+ await notify_operator_of_pending(self.client, None) # Let DB handle tracking
209209+ # Mark as notified in database
210210+ thread_db.mark_operator_notified([a["id"] for a in unnotified])
204211205212 except Exception as e:
206213 logger.warning(f"DM approval check failed: {e}")
214214+215215+ async def _notify_threads_about_approvals(self):
216216+ """Notify threads about applied personality changes"""
217217+ try:
218218+ from bot.database import thread_db
219219+ import json
220220+221221+ # Get approvals that need notification
222222+ approvals = thread_db.get_recently_applied_approvals()
223223+224224+ for approval in approvals:
225225+ try:
226226+ data = json.loads(approval["request_data"])
227227+228228+ # Create notification message
229229+ message = f"✅ personality update applied: {data.get('section', 'unknown')} has been updated"
230230+231231+ # Get the original post to construct proper reply
232232+ from atproto_client import models
233233+ thread_uri = approval["thread_uri"]
234234+235235+ # Get the post data to extract CID
236236+ posts_response = self.client.client.get_posts([thread_uri])
237237+ if not posts_response.posts:
238238+ logger.error(f"Could not find post at {thread_uri}")
239239+ continue
240240+241241+ original_post = posts_response.posts[0]
242242+243243+ # Create StrongRef with the actual CID
244244+ parent_ref = models.ComAtprotoRepoStrongRef.Main(
245245+ uri=thread_uri, cid=original_post.cid
246246+ )
247247+248248+ # For thread notifications, parent and root are the same
249249+ reply_ref = models.AppBskyFeedPost.ReplyRef(
250250+ parent=parent_ref, root=parent_ref
251251+ )
252252+253253+ # Post to the thread
254254+ await self.client.create_post(
255255+ text=message,
256256+ reply_to=reply_ref
257257+ )
258258+259259+ # Mark as notified
260260+ thread_db.mark_approval_notified(approval["id"])
261261+ logger.info(f"Notified thread about approval #{approval['id']}")
262262+263263+ except Exception as e:
264264+ logger.error(f"Failed to notify thread for approval #{approval['id']}: {e}")
265265+266266+ except Exception as e:
267267+ logger.warning(f"Thread notification check failed: {e}")
···11+import logging
22+13import httpx
2435from bot.config import settings
66+77+logger = logging.getLogger("bot.tools")
4859610async def search_google(query: str, num_results: int = 3) -> str:
···3236 return "\n\n".join(results) if results else "No search results found"
33373438 except Exception as e:
3535- return f"Search error: {str(e)}"
3939+ logger.error(f"Search failed: {e}")
4040+ # 12-factor principle #4: Tools should throw errors, not return error strings
4141+ raise
-56
src/bot/tools/personality_tools.py
···11-"""Personality introspection tools for the agent"""
22-33-import logging
44-from typing import Literal
55-66-from bot.memory import NamespaceMemory
77-from bot.personality import add_interest, update_current_state
88-99-logger = logging.getLogger("bot.personality_tools")
1010-1111-PersonalitySection = Literal["interests", "current_state", "communication_style", "core_identity", "boundaries"]
1212-1313-1414-async def view_personality_section(memory: NamespaceMemory, section: PersonalitySection) -> str:
1515- """View a section of my personality"""
1616- try:
1717- memories = await memory.get_core_memories()
1818-1919- # Find the requested section
2020- for mem in memories:
2121- if mem.metadata.get("label") == section:
2222- return mem.content
2323-2424- return f"Section '{section}' not found in my personality"
2525-2626- except Exception as e:
2727- logger.error(f"Failed to view personality: {e}")
2828- return "Unable to access personality data"
2929-3030-3131-async def reflect_on_interest(memory: NamespaceMemory, topic: str, reflection: str) -> str:
3232- """Reflect on a potential new interest"""
3333- # Check if this is genuinely interesting based on context
3434- if len(reflection) < 20:
3535- return "Need more substantial reflection to add an interest"
3636-3737- # Add the interest
3838- success = await add_interest(memory, topic, reflection)
3939-4040- if success:
4141- return f"Added '{topic}' to my interests based on: {reflection}"
4242- else:
4343- return "Failed to update interests"
4444-4545-4646-async def update_self_reflection(memory: NamespaceMemory, reflection: str) -> str:
4747- """Update my current state/self-reflection"""
4848- if len(reflection) < 50:
4949- return "Reflection too brief to warrant an update"
5050-5151- success = await update_current_state(memory, reflection)
5252-5353- if success:
5454- return "Updated my current state reflection"
5555- else:
5656- return "Failed to update reflection"