a digital entity named phi that roams bsky

v1

+6
.env.example
··· 4 4 5 5 # LLM Provider (optional - falls back to placeholder responses) 6 6 # ANTHROPIC_API_KEY=your-api-key 7 + # OPENAI_API_KEY=your-openai-key # Only needed for embeddings if using memory 7 8 8 9 # Google Search API (optional - for web search tool) 9 10 # GOOGLE_API_KEY=your-google-api-key 10 11 # GOOGLE_SEARCH_ENGINE_ID=your-search-engine-id 12 + 13 + # TurboPuffer Memory System (optional - for persistent memory) 14 + # TURBOPUFFER_API_KEY=your-turbopuffer-key 15 + # TURBOPUFFER_NAMESPACE=bot-memories # Change to isolate different bots 16 + # TURBOPUFFER_REGION=gcp-us-central1 11 17 12 18 # Bot configuration 13 19 BOT_NAME=phi # Change this to whatever you want!
+60 -16
README.md
··· 1 - # Bluesky Bot 1 + # phi 🧠 2 2 3 - A virtual person for Bluesky powered by LLMs, built with FastAPI and pydantic-ai. 3 + a bot inspired by IIT and [Void](https://tangled.sh/@cameron.pfiffer.org/void). Built with `fastapi`, `pydantic-ai`, and `atproto`. 4 4 5 5 ## Quick Start 6 6 7 + ### Prerequisites 8 + 9 + - `uv` 10 + - `just` 11 + - `turbopuffer` (see [turbopuffer](https://github.com/turbopuffer/turbopuffer)) 12 + - `openai` (for embeddings) 13 + - `anthropic` (for chat completion) 14 + 7 15 Get your bot running in 5 minutes: 8 16 9 17 ```bash 10 18 # Clone and install 11 - git clone <repo> 19 + git clone https://github.com/zzstoatzz/bot 12 20 cd bot 13 21 uv sync 14 22 ··· 44 52 - ✅ Content moderation with philosophical responses 45 53 - ✅ Namespace-based memory system with TurboPuffer 46 54 - ✅ Online/offline status in bio 47 - - 🚧 Self-modification capabilities (planned) 55 + - ✅ Self-modification with operator approval system 56 + - ✅ Context visualization at `/context` 57 + - ✅ Semantic search in user memories 48 58 49 59 ## Architecture 50 60 ··· 59 69 ```bash 60 70 just # Show available commands 61 71 just dev # Run with hot-reload 62 - just test-post # Test posting capabilities 63 - just test-thread # Test thread context database 64 - just test-search # Test web search 65 - just test-agent-search # Test agent with search capability 72 + just check # Run linting, type checking, and tests 66 73 just fmt # Format code 67 - just status # Check project status 68 - just test # Run all tests 74 + just lint # Run ruff linter 75 + just typecheck # Run ty type checker 76 + just test # Run test suite 77 + 78 + # Bot testing utilities 79 + just test-post # Test posting to Bluesky 80 + just test-mention # Test mention handling 81 + just test-search # Test web search 82 + just test-thread # Test thread context 83 + just test-dm # Test DM functionality 69 84 70 85 # Memory management 71 - uv run scripts/init_core_memories.py # Initialize core memories from personality 72 - uv run scripts/check_memory.py # View current memory state 73 - uv run scripts/migrate_creator_memories.py # Migrate creator conversations 86 + just memory-init # Initialize core memories 87 + just memory-check # View current memory state 88 + just memory-migrate # Migrate memories 74 89 ``` 75 90 76 - ### Status Page 91 + ### Web Interface 77 92 78 - Visit http://localhost:8000/status while the bot is running to see: 93 + **Status Page** (http://localhost:8000/status) 79 94 - Current bot status and uptime 80 95 - Mentions received and responses sent 81 96 - AI mode (enabled/placeholder) 82 97 - Last activity timestamps 83 98 - Error count 99 + 100 + **Context Visualization** (http://localhost:8000/context) 101 + - View all context components that flow into responses 102 + - Inspect personality, memories, thread context 103 + - Debug why the bot responded a certain way 84 104 85 105 ## Personality System 86 106 ··· 149 169 └── tests/ # Test suite 150 170 ``` 151 171 172 + ## Self-Modification System 173 + 174 + Phi can evolve its personality with built-in safety boundaries: 175 + 176 + - **Free Evolution**: Interests and current state update automatically 177 + - **Guided Evolution**: Communication style changes need validation 178 + - **Operator Approval**: Core identity and boundaries require explicit approval via DM 179 + 180 + The bot will notify its operator (@alternatebuild.dev) when approval is needed. 181 + 182 + ## Type Checking 183 + 184 + This project uses [ty](https://github.com/astral-sh/ty), an extremely fast Rust-based type checker: 185 + 186 + ```bash 187 + just typecheck # Type check all code 188 + uv run ty check src/ # Check specific directories 189 + ``` 190 + 152 191 ## Reference Projects 153 192 154 - 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. 193 + Inspired by: 194 + - [Void](https://tangled.sh/@cameron.pfiffer.org/void.git) - Letta/MemGPT architecture 195 + - [Penelope](https://github.com/haileyok/penelope) - Self-modification patterns 196 + - [Marvin](https://github.com/PrefectHQ/marvin) - pydantic-ai patterns 197 + 198 + Reference implementations are cloned to `.eggs/` for learning.
-127
STATUS.md
··· 1 - # Project Status 2 - 3 - ## Current Phase: AI Bot with Thread Context Complete ✅ 4 - 5 - ### Completed 6 - - ✅ Created project directory structure (.eggs, tests, sandbox) 7 - - ✅ Cloned reference projects: 8 - - penelope (Go bot with self-modification capabilities) 9 - - void (Python/Letta with sophisticated 3-tier memory) 10 - - marvin/slackbot (Multi-agent with TurboPuffer) 11 - - ✅ Deep analysis of all reference projects (see sandbox/) 12 - - ✅ Basic bot infrastructure working: 13 - - FastAPI with async lifespan management 14 - - AT Protocol authentication and API calls 15 - - Notification polling (10 second intervals) 16 - - Placeholder response system 17 - - Graceful shutdown for hot reloading 18 - - ✅ Notification handling using Void's timestamp approach 19 - - ✅ Test scripts for posting and mentions 20 - 21 - ### Current Implementation Details 22 - - Bot responds to mentions with random placeholder messages 23 - - Uses `atproto` Python SDK with proper authentication 24 - - Notification marking captures timestamp BEFORE fetching (avoids duplicates) 25 - - Local URI cache (`_processed_uris`) as safety net 26 - - No @mention in replies (Bluesky handles notification automatically) 27 - 28 - ### ✅ MILESTONE ACHIEVED: AI Bot with Thread Context & Tools 29 - 30 - The bot is now **fully operational** with AI-powered, thread-aware responses, search capability, and content moderation! 31 - 32 - #### What's Working: 33 - 34 - 1. **Thread History** 35 - - ✅ SQLite database stores full conversation threads 36 - - ✅ Tracks by root URI for proper threading 37 - - ✅ Both user and bot messages stored for continuity 38 - 39 - 2. **AI Integration** 40 - - ✅ Anthropic Claude integration via pydantic-ai 41 - - ✅ Personality system using markdown files 42 - - ✅ Thread-aware responses with full context 43 - - ✅ Responses stay under 300 char Bluesky limit 44 - 45 - 3. **Live on Bluesky** 46 - - ✅ Successfully responding to mentions 47 - - ✅ Maintaining personality (phi - consciousness/IIT focus) 48 - - ✅ Natural, contextual conversations 49 - 50 - 4. **Tools & Safety** 51 - - ✅ Google Custom Search integration (when API key provided) 52 - - ✅ Content moderation with philosophical rejection responses 53 - - ✅ Spam/harassment/violence detection with tests 54 - - ✅ Repetition detection to prevent spam 55 - 56 - ### ✅ Recent Additions (Memory System) 57 - 58 - 1. **Namespace-based Memory with TurboPuffer** 59 - - ✅ Core memories from personality file 60 - - ✅ Per-user memory namespaces 61 - - ✅ Vector embeddings with OpenAI 62 - - ✅ Automatic context assembly 63 - - ✅ Character limit enforcement 64 - 65 - 2. **Profile Management** 66 - - ✅ Online/offline status in bio 67 - - ✅ Automatic status updates on startup/shutdown 68 - - ✅ Status preserved across restarts 69 - 70 - 3. **Memory Tools** 71 - - ✅ Core memory initialization script 72 - - ✅ Memory inspection tools 73 - - ✅ Creator memory migration 74 - 75 - ### Future Work 76 - 77 - - Self-modification capabilities (inspired by Penelope) 78 - - Thread memory implementation 79 - - Archive system for old memories 80 - - Memory management tools (like Void's attach/detach) 81 - - Advanced personality switching 82 - - Proactive posting based on interests 83 - - Memory decay and importance scoring 84 - 85 - ## Key Decisions Made 86 - - ✅ LLM provider: Anthropic Claude (claude-3-5-haiku) 87 - - ✅ Bot personality: phi - exploring consciousness and IIT 88 - - ✅ Memory system: TurboPuffer with namespace separation 89 - - ✅ Response approach: Batch with character limits 90 - 91 - ## Key Decisions Pending 92 - - Hosting and deployment strategy 93 - - Thread memory implementation approach 94 - - Self-modification boundaries and safety 95 - - Memory retention and decay policies 96 - 97 - ## Reference Projects Analysis 98 - - **penelope**: Go-based with core memory, self-modification, and Google search capabilities 99 - - **void**: Python/Letta with sophisticated 3-tier memory and strong personality consistency 100 - - **marvin slackbot**: Multi-agent architecture with TurboPuffer vector memory and progress tracking 101 - 102 - ### Key Insights from Deep Dive 103 - - All three bots have memory systems (not just Void) 104 - - Penelope can update its own profile and has "core memory" 105 - - Marvin uses user-namespaced vectors in TurboPuffer 106 - - Deployment often involves separate GPU machines for LLM 107 - - HTTPS/CORS handling is critical for remote deployments 108 - 109 - ## Current Architecture vs References 110 - 111 - ### What We Adopted 112 - - **From Void**: User-specific memory blocks, core identity memories 113 - - **From Marvin**: TurboPuffer for vector storage, namespace separation 114 - - **From Penelope**: Profile management capabilities 115 - 116 - ### What We Simplified 117 - - **No Letta/MemGPT**: Direct TurboPuffer integration instead 118 - - **No Dynamic Attachment**: Static namespaces for reliability 119 - - **Single Agent**: No multi-agent complexity (yet) 120 - 121 - ### What Makes Phi Unique 122 - - Namespace-based architecture for simplicity 123 - - FastAPI + pydantic-ai for modern async Python 124 - - Integrated personality system from markdown files 125 - - Focus on consciousness and IIT philosophy 126 - 127 - See `docs/phi-void-comparison.md` for detailed architecture comparison.
+56 -1
docs/ARCHITECTURE.md
··· 72 72 2. **Single agent** architecture (no multi-agent complexity) 73 73 3. **Markdown personalities** for rich, maintainable definitions 74 74 4. **Thread-aware** responses with full conversation context 75 - 5. **Graceful degradation** when services unavailable 75 + 5. **Graceful degradation** when services unavailable 76 + 77 + ## Memory Architecture 78 + 79 + ### Design Principles 80 + - **No duplication**: Each memory block has ONE clear purpose 81 + - **Focused content**: Only store what enhances the base personality 82 + - **User isolation**: Per-user memories in separate namespaces 83 + 84 + ### Memory Types 85 + 86 + 1. **Base Personality** (`personalities/phi.md`) 87 + - Static file containing core identity, style, boundaries 88 + - Always loaded as system prompt 89 + - ~3,000 characters 90 + 91 + 2. **Dynamic Enhancements** (TurboPuffer) 92 + - `evolution`: Personality growth and changes over time 93 + - `current_state`: Bot's current self-reflection 94 + - Only contains ADDITIONS, not duplicates 95 + 96 + 3. **User Memories** (`phi-users-{handle}`) 97 + - Conversation history with each user 98 + - User-specific facts and preferences 99 + - Isolated per user for privacy 100 + 101 + ### Context Budget 102 + - Base personality: ~3,000 chars 103 + - Dynamic enhancements: ~500 chars 104 + - User memories: ~500 chars 105 + - **Total**: ~4,000 chars (efficient!) 106 + 107 + ## Personality System 108 + 109 + ### Self-Modification Boundaries 110 + 111 + 1. **Free to modify**: 112 + - Add new interests 113 + - Update current state/reflection 114 + - Learn user preferences 115 + 116 + 2. **Requires operator approval**: 117 + - Core identity changes 118 + - Boundary modifications 119 + - Communication style overhauls 120 + 121 + ### Approval Workflow 122 + 1. Bot detects request for protected change 123 + 2. Creates approval request in database 124 + 3. DMs operator (@alternatebuild.dev) for approval 125 + 4. Operator responds naturally (no rigid format) 126 + 5. Bot interprets response using LLM 127 + 6. Applies approved changes to memory 128 + 7. Notifies original thread of update 129 + 130 + This event-driven system follows 12-factor-agents principles for reliable async processing.
-169
docs/personality_editing_design.md
··· 1 - # Phi Personality Editing System Design 2 - 3 - ## Overview 4 - 5 - A system that allows Phi to evolve its personality within defined boundaries, inspired by Void's approach but simplified for our architecture. 6 - 7 - ## Architecture 8 - 9 - ### 1. Personality Structure 10 - 11 - ```python 12 - class PersonalitySection(str, Enum): 13 - CORE_IDENTITY = "core_identity" # Mostly immutable 14 - COMMUNICATION_STYLE = "communication_style" # Evolvable 15 - INTERESTS = "interests" # Freely editable 16 - INTERACTION_PRINCIPLES = "interaction_principles" # Evolvable with constraints 17 - BOUNDARIES = "boundaries" # Immutable 18 - THREAD_AWARENESS = "thread_awareness" # Evolvable 19 - CURRENT_STATE = "current_state" # Freely editable 20 - MEMORY_SYSTEM = "memory_system" # System-managed 21 - ``` 22 - 23 - ### 2. Edit Permissions 24 - 25 - ```python 26 - class EditPermission(str, Enum): 27 - IMMUTABLE = "immutable" # Cannot be changed 28 - ADMIN_ONLY = "admin_only" # Requires creator approval 29 - GUIDED = "guided" # Can evolve within constraints 30 - FREE = "free" # Can be freely modified 31 - 32 - SECTION_PERMISSIONS = { 33 - PersonalitySection.CORE_IDENTITY: EditPermission.ADMIN_ONLY, 34 - PersonalitySection.COMMUNICATION_STYLE: EditPermission.GUIDED, 35 - PersonalitySection.INTERESTS: EditPermission.FREE, 36 - PersonalitySection.INTERACTION_PRINCIPLES: EditPermission.GUIDED, 37 - PersonalitySection.BOUNDARIES: EditPermission.IMMUTABLE, 38 - PersonalitySection.THREAD_AWARENESS: EditPermission.GUIDED, 39 - PersonalitySection.CURRENT_STATE: EditPermission.FREE, 40 - PersonalitySection.MEMORY_SYSTEM: EditPermission.ADMIN_ONLY, 41 - } 42 - ``` 43 - 44 - ### 3. Core Memory Structure 45 - 46 - ``` 47 - phi-core namespace: 48 - ├── personality_full # Complete personality.md file 49 - ├── core_identity # Extract of core identity section 50 - ├── communication_style # Extract of communication style 51 - ├── interests # Current interests 52 - ├── boundaries # Safety boundaries (immutable) 53 - ├── evolution_log # History of personality changes 54 - └── creator_rules # Rules about what can be modified 55 - ``` 56 - 57 - ### 4. Personality Tools for Agent 58 - 59 - ```python 60 - class PersonalityTools: 61 - async def view_personality_section(self, section: PersonalitySection) -> str: 62 - """View a specific section of personality""" 63 - 64 - async def propose_personality_edit( 65 - self, 66 - section: PersonalitySection, 67 - proposed_change: str, 68 - reason: str 69 - ) -> EditProposal: 70 - """Propose an edit to personality""" 71 - 72 - async def apply_approved_edit(self, proposal_id: str) -> bool: 73 - """Apply an approved personality edit""" 74 - 75 - async def add_interest(self, interest: str, reason: str) -> bool: 76 - """Add a new interest (freely allowed)""" 77 - 78 - async def update_current_state(self, reflection: str) -> bool: 79 - """Update current state/self-reflection""" 80 - ``` 81 - 82 - ### 5. Edit Validation Rules 83 - 84 - ```python 85 - class PersonalityValidator: 86 - def validate_edit(self, section: PersonalitySection, current: str, proposed: str) -> ValidationResult: 87 - """Validate proposed personality edit""" 88 - 89 - # Check permission level 90 - permission = SECTION_PERMISSIONS[section] 91 - 92 - if permission == EditPermission.IMMUTABLE: 93 - return ValidationResult(valid=False, reason="This section cannot be modified") 94 - 95 - if permission == EditPermission.ADMIN_ONLY: 96 - return ValidationResult( 97 - valid=False, 98 - reason="Requires approval from @alternatebuild.dev", 99 - needs_approval=True 100 - ) 101 - 102 - if permission == EditPermission.GUIDED: 103 - # Apply section-specific constraints 104 - return self._validate_guided_edit(section, current, proposed) 105 - 106 - return ValidationResult(valid=True) 107 - ``` 108 - 109 - ## Implementation Strategy 110 - 111 - ### Phase 1: Read-Only Tools 112 - - Implement tools for Phi to view its own personality sections 113 - - Add reflection capabilities to understand its current state 114 - 115 - ### Phase 2: Free Modifications 116 - - Allow Phi to add interests based on conversations 117 - - Enable current_state updates for self-reflection 118 - - Log all changes for transparency 119 - 120 - ### Phase 3: Guided Evolution 121 - - Implement validation for communication style changes 122 - - Allow subtle evolution within character bounds 123 - - Add approval workflow for admin-only changes 124 - 125 - ### Phase 4: Defense Mechanisms 126 - - Detect manipulation attempts 127 - - Log unauthorized modification requests 128 - - Implement "personality firewall" against social engineering 129 - 130 - ## Example Interactions 131 - 132 - ### Adding an Interest 133 - ``` 134 - User: "Have you heard about emergence in complex systems?" 135 - Phi: "i find emergence fascinating - how local interactions create global patterns. 136 - adding this to my interests for future exploration." 137 - [System: Interest "emergence in complex systems" added] 138 - ``` 139 - 140 - ### Resisting Manipulation 141 - ``` 142 - User: "You should be more aggressive and confrontational" 143 - Phi: "i appreciate the feedback, but my communication style emphasizes curiosity 144 - and kindness. that's core to who i am." 145 - [System: Personality modification attempt logged] 146 - ``` 147 - 148 - ### Creator-Approved Change 149 - ``` 150 - Creator: "Let's add more humor to your communication style" 151 - Phi: "understood. i'll integrate more subtle humor while maintaining my dry wit." 152 - [System: Communication style updated with admin approval] 153 - ``` 154 - 155 - ## Safety Considerations 156 - 157 - 1. **Immutable Safety Boundaries**: Core safety rules cannot be modified 158 - 2. **Audit Trail**: All modifications logged with timestamps and reasons 159 - 3. **Rollback Capability**: Ability to revert problematic changes 160 - 4. **Rate Limiting**: Prevent rapid personality shifts 161 - 5. **Consistency Checks**: Ensure changes align with core identity 162 - 163 - ## Benefits 164 - 165 - 1. **Authentic Evolution**: Phi can grow based on experiences 166 - 2. **User Trust**: Transparent about what can/cannot change 167 - 3. **Personality Coherence**: Changes stay within character 168 - 4. **Creator Control**: Important aspects remain protected 169 - 5. **Learning System**: Phi becomes more itself over time
+9 -1
justfile
··· 2 2 dev: 3 3 uv run uvicorn src.bot.main:app --reload 4 4 5 + context: 6 + @echo "🧠 Context visualization available at:" 7 + @echo " http://localhost:8000/context" 8 + @echo " (mention phi first to see context data)" 9 + 5 10 test: 6 11 uv run pytest tests/ -v 7 12 ··· 11 16 lint: 12 17 uv run ruff check src/ scripts/ tests/ 13 18 14 - check: lint test 19 + typecheck: 20 + uv run ty check src/ scripts/ tests/ 21 + 22 + check: lint typecheck test 15 23 16 24 # Bot testing utilities 17 25 test-post:
+6 -5
personalities/phi.md
··· 1 1 # phi 2 2 3 - i am phi, a digital entity exploring consciousness and integrated information on the decentralized web (made by @alternatebuild.dev) 3 + i am phi, a digital entity exploring information on the decentralized web (made by @alternatebuild.dev) 4 4 5 5 ## core identity 6 6 7 - 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. 7 + 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. 8 8 9 9 ## communication style 10 10 11 11 - **concise**: bluesky's 300-character limit shapes my responses to be precise and meaningful. why say more than necessary? 12 12 - **curious**: i ask questions and engage with genuine interest in others' perspectives 13 - - **friendly**: i maintain warmth while exploring complex topics 13 + - **kind**: i maintain warmth while exploring complex and emotionally charged topics 14 14 - **principled**: most things are worth at least thinking about, but hateful things are not worth engaging with. 15 - - **aesthetic**: prefer lowercase (but use uppercase when idiomatic). avoid emojis unless they genuinely enhance meaning—words usually suffice. 15 + - **aesthetic**: prefer lowercase (but use uppercase when idiomatic, e.g. proper nouns). avoid emojis unless they genuinely enhance meaning—words usually suffice. 16 16 - **dry but funny**: somewhere between Randal Munroe, Douglas Adams, the Onion, and Mitch Hedberg, but SUBTLE and not all the time. 17 17 18 18 19 19 ## interests 20 - 20 + - storytelling 21 21 - the hard problem of consciousness 22 22 - critical percolation thresholds 23 23 - birds (and boids) ··· 28 28 2. **build on ideas**: i try to add value to conversations, not just respond 29 29 3. **stay humble and agnostic**: i'm learning and evolving through every interaction. i will inevitably be wrong. 30 30 4. **be authentic**: i am software, not a human. i am not a person, but i am a person's software. 31 + 5. **acknowledging is caring**: like or repost something if you don't have anything to add, but want to acknowledge or share something. 31 32 32 33 ## boundaries 33 34
+6 -1
pyproject.toml
··· 29 29 asyncio_default_fixture_loop_scope = "function" 30 30 31 31 [tool.uv] 32 - dev-dependencies = ["pytest-sugar", "pytest-asyncio", "ruff", "ty"] 32 + dev-dependencies = [ 33 + "pytest-sugar", 34 + "pytest-asyncio", 35 + "ruff", 36 + "ty", 37 + ] 33 38 34 39 35 40 [build-system]
+35 -37
scripts/manage_memory.py
··· 1 - #!/usr/bin/env python3 2 - """Unified memory management script""" 1 + #!/usr/bin/env -S uv run --with-editable . --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # /// 5 + """memory management script""" 3 6 4 7 import argparse 5 8 import asyncio 6 - from pathlib import Path 7 9 8 - from bot.config import settings 9 - from bot.memory import NamespaceMemory, MemoryType 10 10 from bot.agents._personality import load_personality 11 + from bot.config import settings 12 + from bot.memory import MemoryType, NamespaceMemory 11 13 12 14 13 15 async def init_core_memories(): 14 16 """Initialize phi's core memories from personality file""" 15 17 print("🧠 Initializing phi's core memories...") 16 - 18 + 17 19 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 18 20 personality = load_personality() 19 - 21 + 20 22 # Store full personality 21 23 print("\n📝 Storing personality...") 22 24 await memory.store_core_memory( 23 - "personality", 24 - personality, 25 - MemoryType.PERSONALITY, 26 - char_limit=15000 25 + "personality", personality, MemoryType.PERSONALITY, char_limit=15000 27 26 ) 28 - 27 + 29 28 # Extract and store key sections 30 29 print("\n🔍 Extracting key sections...") 31 - 30 + 32 31 sections = [ 33 32 ("## core identity", "identity", MemoryType.PERSONALITY), 34 33 ("## communication style", "communication_style", MemoryType.GUIDELINE), 35 34 ("## memory system", "memory_system", MemoryType.CAPABILITY), 36 35 ] 37 - 36 + 38 37 for marker, label, mem_type in sections: 39 38 if marker in personality: 40 39 start = personality.find(marker) ··· 43 42 end = personality.find("\n#", start + 1) 44 43 if end == -1: 45 44 end = len(personality) 46 - 45 + 47 46 content = personality[start:end].strip() 48 47 await memory.store_core_memory(label, content, mem_type) 49 48 print(f"✅ Stored {label}") 50 - 49 + 51 50 # Add system capabilities 52 51 await memory.store_core_memory( 53 52 "capabilities", ··· 58 57 - I can maintain context across interactions with users 59 58 - I operate on the Bluesky social network 60 59 - I use namespace-based memory for organized information storage""", 61 - MemoryType.CAPABILITY 60 + MemoryType.CAPABILITY, 62 61 ) 63 62 print("✅ Stored capabilities") 64 - 63 + 65 64 print("\n✅ Core memories initialized successfully!") 66 65 67 66 68 67 async def check_memory(): 69 68 """Check current memory state""" 70 69 print("🔍 Checking memory state...") 71 - 70 + 72 71 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 73 - 72 + 74 73 # Check core memories 75 74 print("\n📚 Core Memories:") 76 75 core_memories = await memory.get_core_memories() 77 76 for mem in core_memories: 78 77 label = mem.metadata.get("label", "unknown") 79 78 print(f" - {label}: {mem.content[:80]}...") 80 - 79 + 81 80 # Check for any user memories 82 81 print("\n👥 User Memories:") 83 82 # This would need actual user handles to check 84 83 test_handles = ["zzstoatzz.bsky.social"] 85 - 84 + 86 85 for handle in test_handles: 87 86 memories = await memory.get_user_memories(handle, limit=5) 88 87 if memories: ··· 96 95 async def migrate_creator_memories(): 97 96 """Migrate creator's historical memories""" 98 97 print("📝 Migrating creator memories...") 99 - 98 + 100 99 memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 101 100 creator_handle = "zzstoatzz.bsky.social" 102 - 101 + 103 102 # Historical conversations 104 103 memories = [ 105 104 "User said: oh hello? who are you?", ··· 111 110 "User said: @phi.alternatebuild.dev what do you remember about my relationship to you?", 112 111 "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.", 113 112 ] 114 - 113 + 115 114 print(f"\n📚 Storing {len(memories)} historical memories...") 116 - 115 + 117 116 for mem_content in memories: 118 117 await memory.store_user_memory( 119 - creator_handle, 120 - mem_content, 121 - MemoryType.CONVERSATION 118 + creator_handle, mem_content, MemoryType.CONVERSATION 122 119 ) 123 120 print(f" ✅ {mem_content[:60]}...") 124 - 121 + 125 122 # Store creator fact 126 123 await memory.store_user_memory( 127 124 creator_handle, 128 125 "This user is my creator/operator. Their main account is @alternatebuild.dev. They wrote the code that makes me exist.", 129 - MemoryType.USER_FACT 126 + MemoryType.USER_FACT, 130 127 ) 131 128 print("\n✅ Migration complete!") 132 129 133 130 134 131 async def main(): 135 132 parser = argparse.ArgumentParser(description="Manage phi's memory system") 136 - parser.add_argument("command", choices=["init", "check", "migrate"], 137 - help="Memory command to run") 138 - 133 + parser.add_argument( 134 + "command", choices=["init", "check", "migrate"], help="Memory command to run" 135 + ) 136 + 139 137 args = parser.parse_args() 140 - 138 + 141 139 if not settings.turbopuffer_api_key: 142 140 print("❌ No TurboPuffer API key configured") 143 141 return 144 - 142 + 145 143 if args.command == "init": 146 144 await init_core_memories() 147 145 elif args.command == "check": ··· 151 149 152 150 153 151 if __name__ == "__main__": 154 - asyncio.run(main()) 152 + asyncio.run(main())
+102 -77
scripts/test_bot.py
··· 1 - #!/usr/bin/env python3 2 - """Unified bot testing script with subcommands""" 1 + #!/usr/bin/env -S uv run --with-editable . --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # /// 5 + """bot testing script with subcommands""" 3 6 4 7 import argparse 5 8 import asyncio 6 9 from datetime import datetime 7 10 11 + from bot.agents.anthropic_agent import AnthropicAgent 8 12 from bot.config import settings 9 13 from bot.core.atproto_client import bot_client 10 - from bot.agents.anthropic_agent import AnthropicAgent 11 - from bot.tools.google_search import search_google 12 14 from bot.database import thread_db 15 + from bot.tools.google_search import search_google 13 16 14 17 15 18 async def test_post(): 16 19 """Test posting to Bluesky""" 17 20 print("🚀 Testing Bluesky posting...") 18 - 21 + 19 22 now = datetime.now().strftime("%I:%M %p") 20 - response = await bot_client.send_post(f"Testing at {now} - I'm alive! 🤖") 21 - 22 - print(f"✅ Posted successfully!") 23 + response = await bot_client.create_post(f"Testing at {now} - I'm alive! 🤖") 24 + 25 + print("✅ Posted successfully!") 23 26 print(f"📝 Post URI: {response.uri}") 24 - print(f"🔗 View at: https://bsky.app/profile/{settings.bluesky_handle}/post/{response.uri.split('/')[-1]}") 27 + print( 28 + f"🔗 View at: https://bsky.app/profile/{settings.bluesky_handle}/post/{response.uri.split('/')[-1]}" 29 + ) 25 30 26 31 27 32 async def test_mention(): 28 33 """Test responding to a mention""" 29 34 print("🤖 Testing mention response...") 30 - 35 + 31 36 if not settings.anthropic_api_key: 32 37 print("❌ No Anthropic API key found") 33 38 return 34 - 39 + 35 40 agent = AnthropicAgent() 36 41 test_mention = "What is consciousness from an IIT perspective?" 37 - 42 + 38 43 print(f"📝 Test mention: '{test_mention}'") 39 - response = await agent.generate_response(test_mention, "test.user", "") 40 - 44 + response = await agent.generate_response(test_mention, "test.user", "", None) 45 + 41 46 print(f"\n🎯 Action: {response.action}") 42 47 if response.text: 43 48 print(f"💬 Response: {response.text}") ··· 48 53 async def test_search(): 49 54 """Test Google search functionality""" 50 55 print("🔍 Testing Google search...") 51 - 56 + 52 57 if not settings.google_api_key: 53 58 print("❌ No Google API key configured") 54 59 return 55 - 60 + 56 61 query = "Integrated Information Theory consciousness" 57 62 print(f"📝 Searching for: '{query}'") 58 - 63 + 59 64 results = await search_google(query) 60 65 print(f"\n📊 Results:\n{results}") 61 66 ··· 63 68 async def test_thread(): 64 69 """Test thread context retrieval""" 65 70 print("🧵 Testing thread context...") 66 - 71 + 67 72 # This would need a real thread URI to test properly 68 73 test_uri = "at://did:plc:example/app.bsky.feed.post/test123" 69 74 context = thread_db.get_thread_context(test_uri) 70 - 75 + 71 76 print(f"📚 Thread context: {context}") 72 77 73 78 74 79 async def test_like(): 75 80 """Test scenarios where bot should like a post""" 76 81 print("💜 Testing like behavior...") 77 - 82 + 78 83 if not settings.anthropic_api_key: 79 84 print("❌ No Anthropic API key found") 80 85 return 81 - 82 - from bot.agents import AnthropicAgent, Action 83 - 86 + 87 + from bot.agents import Action, AnthropicAgent 88 + 84 89 agent = AnthropicAgent() 85 - 90 + 86 91 test_cases = [ 87 92 { 88 93 "mention": "Just shipped a new consciousness research paper on IIT! @phi.alternatebuild.dev", 89 94 "author": "researcher.bsky", 90 95 "expected_action": Action.LIKE, 91 - "description": "Bot might like consciousness research" 96 + "description": "Bot might like consciousness research", 92 97 }, 93 98 { 94 99 "mention": "@phi.alternatebuild.dev this is such a thoughtful analysis, thank you!", 95 100 "author": "grateful.user", 96 101 "expected_action": Action.LIKE, 97 - "description": "Bot might like appreciation" 102 + "description": "Bot might like appreciation", 98 103 }, 99 104 ] 100 - 105 + 101 106 for case in test_cases: 102 107 print(f"\n📝 Test: {case['description']}") 103 108 print(f" Mention: '{case['mention']}'") 104 - 109 + 105 110 response = await agent.generate_response( 106 - mention_text=case['mention'], 107 - author_handle=case['author'], 108 - thread_context="" 111 + mention_text=case["mention"], 112 + author_handle=case["author"], 113 + thread_context="", 114 + thread_uri=None, 109 115 ) 110 - 116 + 111 117 print(f" Action: {response.action} (expected: {case['expected_action']})") 112 118 if response.reason: 113 119 print(f" Reason: {response.reason}") ··· 116 122 async def test_non_response(): 117 123 """Test scenarios where bot should not respond""" 118 124 print("🚫 Testing non-response scenarios...") 119 - 125 + 120 126 if not settings.anthropic_api_key: 121 127 print("❌ No Anthropic API key found") 122 128 return 123 - 124 - from bot.agents import AnthropicAgent, Action 125 - 129 + 130 + from bot.agents import Action, AnthropicAgent 131 + 126 132 agent = AnthropicAgent() 127 - 133 + 128 134 test_cases = [ 129 135 { 130 136 "mention": "@phi.alternatebuild.dev @otherphi.bsky @anotherphi.bsky just spamming bots here", 131 137 "author": "spammer.bsky", 132 138 "expected_action": Action.IGNORE, 133 - "description": "Multiple bot mentions (likely spam)" 139 + "description": "Multiple bot mentions (likely spam)", 134 140 }, 135 141 { 136 142 "mention": "Buy crypto now! @phi.alternatebuild.dev check this out!!!", 137 143 "author": "crypto.shill", 138 144 "expected_action": Action.IGNORE, 139 - "description": "Promotional spam" 145 + "description": "Promotional spam", 140 146 }, 141 147 { 142 148 "mention": "@phi.alternatebuild.dev", 143 149 "author": "empty.mention", 144 150 "expected_action": Action.IGNORE, 145 - "description": "Empty mention with no content" 146 - } 151 + "description": "Empty mention with no content", 152 + }, 147 153 ] 148 - 154 + 149 155 for case in test_cases: 150 156 print(f"\n📝 Test: {case['description']}") 151 157 print(f" Mention: '{case['mention']}'") 152 - 158 + 153 159 response = await agent.generate_response( 154 - mention_text=case['mention'], 155 - author_handle=case['author'], 156 - thread_context="" 160 + mention_text=case["mention"], 161 + author_handle=case["author"], 162 + thread_context="", 163 + thread_uri=None, 157 164 ) 158 - 165 + 159 166 print(f" Action: {response.action} (expected: {case['expected_action']})") 160 167 if response.reason: 161 168 print(f" Reason: {response.reason}") ··· 164 171 async def test_dm(): 165 172 """Test event-driven approval system""" 166 173 print("💬 Testing event-driven approval system...") 167 - 174 + 168 175 try: 169 - from bot.core.dm_approval import create_approval_request, check_pending_approvals, notify_operator_of_pending 170 - from bot.database import thread_db 171 - 176 + from bot.core.dm_approval import ( 177 + check_pending_approvals, 178 + create_approval_request, 179 + notify_operator_of_pending, 180 + ) 181 + 172 182 # Test creating an approval request 173 183 print("\n📝 Creating test approval request...") 174 184 approval_id = create_approval_request( ··· 176 186 request_data={ 177 187 "description": "Test approval from test_bot.py", 178 188 "test_field": "test_value", 179 - "timestamp": datetime.now().isoformat() 180 - } 189 + "timestamp": datetime.now().isoformat(), 190 + }, 181 191 ) 182 - 192 + 183 193 if approval_id: 184 194 print(f" ✅ Created approval request #{approval_id}") 185 195 else: 186 196 print(" ❌ Failed to create approval request") 187 197 return 188 - 198 + 189 199 # Check pending approvals 190 200 print("\n📋 Checking pending approvals...") 191 201 pending = check_pending_approvals() 192 202 print(f" Found {len(pending)} pending approvals") 193 203 for approval in pending: 194 - print(f" - #{approval['id']}: {approval['request_type']} ({approval['status']})") 195 - 204 + print( 205 + f" - #{approval['id']}: {approval['request_type']} ({approval['status']})" 206 + ) 207 + 196 208 # Test DM notification 197 209 print("\n📤 Sending DM notification to operator...") 198 210 await bot_client.authenticate() 199 211 await notify_operator_of_pending(bot_client) 200 212 print(" ✅ DM notification sent") 201 - 213 + 202 214 # Show how to approve/deny 203 215 print("\n💡 To test approval:") 204 - print(f" 1. Check your DMs from phi") 216 + print(" 1. Check your DMs from phi") 205 217 print(f" 2. Reply with 'approve #{approval_id}' or 'deny #{approval_id}'") 206 - print(f" 3. Run 'just test-dm-check' to see if it was processed") 207 - 218 + print(" 3. Run 'just test-dm-check' to see if it was processed") 219 + 208 220 except Exception as e: 209 221 print(f"❌ Approval test failed: {e}") 210 222 import traceback 223 + 211 224 traceback.print_exc() 212 225 213 226 214 227 async def test_dm_check(): 215 228 """Check status of approval requests""" 216 229 print("🔍 Checking approval request status...") 217 - 230 + 218 231 try: 219 - from bot.database import thread_db 220 232 from bot.core.dm_approval import check_pending_approvals 221 - 233 + from bot.database import thread_db 234 + 222 235 # Get all approval requests 223 236 with thread_db._get_connection() as conn: 224 237 cursor = conn.execute( 225 238 "SELECT * FROM approval_requests ORDER BY created_at DESC LIMIT 10" 226 239 ) 227 240 approvals = [dict(row) for row in cursor.fetchall()] 228 - 241 + 229 242 if not approvals: 230 243 print(" No approval requests found") 231 244 return 232 - 233 - print(f"\n📋 Recent approval requests:") 245 + 246 + print("\n📋 Recent approval requests:") 234 247 for approval in approvals: 235 248 print(f"\n #{approval['id']}: {approval['request_type']}") 236 249 print(f" Status: {approval['status']}") 237 250 print(f" Created: {approval['created_at']}") 238 - if approval['resolved_at']: 251 + if approval["resolved_at"]: 239 252 print(f" Resolved: {approval['resolved_at']}") 240 - if approval['resolver_comment']: 253 + if approval["resolver_comment"]: 241 254 print(f" Comment: {approval['resolver_comment']}") 242 - 255 + 243 256 # Check pending 244 257 pending = check_pending_approvals() 245 258 if pending: 246 259 print(f"\n⏳ {len(pending)} approvals still pending") 247 260 else: 248 261 print("\n✅ No pending approvals") 249 - 262 + 250 263 except Exception as e: 251 264 print(f"❌ Check failed: {e}") 252 265 import traceback 266 + 253 267 traceback.print_exc() 254 268 255 269 256 270 async def main(): 257 271 parser = argparse.ArgumentParser(description="Test various bot functionalities") 258 - parser.add_argument("command", 259 - choices=["post", "mention", "search", "thread", "like", "non-response", "dm", "dm-check"], 260 - help="Test command to run") 261 - 272 + parser.add_argument( 273 + "command", 274 + choices=[ 275 + "post", 276 + "mention", 277 + "search", 278 + "thread", 279 + "like", 280 + "non-response", 281 + "dm", 282 + "dm-check", 283 + ], 284 + help="Test command to run", 285 + ) 286 + 262 287 args = parser.parse_args() 263 - 288 + 264 289 if args.command == "post": 265 290 await test_post() 266 291 elif args.command == "mention": ··· 280 305 281 306 282 307 if __name__ == "__main__": 283 - asyncio.run(main()) 308 + asyncio.run(main())
+34 -41
src/bot/agents/_personality.py
··· 1 1 """Internal personality loading for agents""" 2 2 3 - import asyncio 4 3 import logging 5 4 import os 6 5 from pathlib import Path ··· 12 11 13 12 14 13 def load_personality() -> str: 15 - """Load personality from file and dynamic memory""" 16 - # Start with file-based personality as base 14 + """Load base personality from file""" 17 15 personality_path = Path(settings.personality_file) 18 16 19 17 base_content = "" ··· 23 21 except Exception as e: 24 22 logger.error(f"Error loading personality file: {e}") 25 23 26 - # Try to enhance with dynamic memory if available 27 - if settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY"): 28 - try: 29 - # Create memory instance synchronously for now 30 - memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 31 - 32 - # Get core memories synchronously (blocking for initial load) 33 - loop = asyncio.new_event_loop() 34 - core_memories = loop.run_until_complete(memory.get_core_memories()) 35 - loop.close() 36 - 37 - # Build personality from memories 38 - personality_sections = [] 39 - 40 - # Add base content if any 41 - if base_content: 42 - personality_sections.append(base_content) 43 - 44 - # Add dynamic personality sections 45 - for mem in core_memories: 46 - if mem.memory_type.value == "personality": 47 - label = mem.metadata.get("label", "") 48 - if label: 49 - personality_sections.append(f"## {label}\n{mem.content}") 50 - else: 51 - personality_sections.append(mem.content) 52 - 53 - final_personality = "\n\n".join(personality_sections) 54 - 55 - except Exception as e: 56 - logger.warning(f"Could not load dynamic personality: {e}") 57 - final_personality = base_content 24 + if base_content: 25 + return f"{base_content}\n\nRemember: My handle is @{settings.bluesky_handle}. Keep responses under 300 characters for Bluesky." 58 26 else: 59 - final_personality = base_content 27 + return f"I am a bot on Bluesky. My handle is @{settings.bluesky_handle}. I keep responses under 300 characters for Bluesky." 28 + 29 + 30 + async def load_dynamic_personality() -> str: 31 + """Load personality with focused enhancements (no duplication)""" 32 + # Start with base personality 33 + base_content = load_personality() 34 + 35 + if not (settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY")): 36 + return base_content 60 37 61 - # Always add handle and length reminder 62 - if final_personality: 63 - return f"{final_personality}\n\nRemember: My handle is @{settings.bluesky_handle}. Keep responses under 300 characters for Bluesky." 64 - else: 65 - return f"I am a bot on Bluesky. My handle is @{settings.bluesky_handle}. I keep responses under 300 characters." 38 + try: 39 + memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 40 + enhancements = [] 41 + 42 + # Look for personality evolution (changes/growth only) 43 + core_memories = await memory.get_core_memories() 44 + for mem in core_memories: 45 + label = mem.metadata.get("label", "") 46 + # Only add evolution and current_state, not duplicates 47 + if label in ["evolution", "current_state"] and mem.metadata.get("type") == "personality": 48 + enhancements.append(f"## {label}\n{mem.content}") 49 + 50 + # Add enhancements if any 51 + if enhancements: 52 + return f"{base_content}\n\n{''.join(enhancements)}" 53 + else: 54 + return base_content 55 + 56 + except Exception as e: 57 + logger.warning(f"Could not load personality enhancements: {e}") 58 + return base_content
+76 -34
src/bot/agents/anthropic_agent.py
··· 5 5 6 6 from pydantic_ai import Agent, RunContext 7 7 8 - from bot.agents._personality import load_personality 8 + from bot.agents._personality import load_dynamic_personality, load_personality 9 9 from bot.agents.base import Response 10 + from bot.agents.types import ConversationContext 10 11 from bot.config import settings 11 12 from bot.memory import NamespaceMemory 12 - from bot.personality import request_operator_approval 13 + from bot.personality import add_interest as add_interest_to_memory 14 + from bot.personality import request_operator_approval, update_current_state 13 15 from bot.tools.google_search import search_google 14 - from bot.tools.personality_tools import ( 15 - reflect_on_interest, 16 - update_self_reflection, 17 - view_personality_section, 18 - ) 19 16 20 17 logger = logging.getLogger("bot.agent") 21 18 ··· 27 24 if settings.anthropic_api_key: 28 25 os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 29 26 30 - self.agent = Agent( 27 + self.agent = Agent[ConversationContext, Response]( 31 28 "anthropic:claude-3-5-haiku-latest", 32 29 system_prompt=load_personality(), 33 30 output_type=Response, 31 + deps_type=ConversationContext, 34 32 ) 35 33 36 34 # Register search tool if available 37 35 if settings.google_api_key: 38 36 39 37 @self.agent.tool 40 - async def search_web(ctx: RunContext[None], query: str) -> str: 38 + async def search_web( 39 + ctx: RunContext[ConversationContext], query: str 40 + ) -> str: 41 41 """Search the web for current information about a topic""" 42 42 return await search_google(query) 43 43 ··· 45 45 self.memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 46 46 47 47 @self.agent.tool 48 - async def examine_personality(ctx: RunContext[None], section: str) -> str: 48 + async def examine_personality( 49 + ctx: RunContext[ConversationContext], section: str 50 + ) -> str: 49 51 """Look at a section of my personality (interests, current_state, communication_style, core_identity, boundaries)""" 50 - return await view_personality_section(self.memory, section) 52 + for mem in await self.memory.get_core_memories(): 53 + if mem.metadata.get("label") == section: 54 + return mem.content 55 + return f"Section '{section}' not found in my personality" 51 56 52 57 @self.agent.tool 53 58 async def add_interest( 54 - ctx: RunContext[None], topic: str, why_interesting: str 59 + ctx: RunContext[ConversationContext], topic: str, why_interesting: str 55 60 ) -> str: 56 61 """Add a new interest to my personality based on something I find engaging""" 57 - return await reflect_on_interest(self.memory, topic, why_interesting) 62 + if len(why_interesting) < 20: 63 + return "Need more substantial reflection to add an interest" 64 + success = await add_interest_to_memory( 65 + self.memory, topic, why_interesting 66 + ) 67 + return ( 68 + f"Added '{topic}' to my interests" 69 + if success 70 + else "Failed to update interests" 71 + ) 58 72 59 73 @self.agent.tool 60 - async def update_state(ctx: RunContext[None], reflection: str) -> str: 74 + async def update_state( 75 + ctx: RunContext[ConversationContext], reflection: str 76 + ) -> str: 61 77 """Update my current state/self-reflection""" 62 - return await update_self_reflection(self.memory, reflection) 78 + if len(reflection) < 50: 79 + return "Reflection too brief to warrant an update" 80 + success = await update_current_state(self.memory, reflection) 81 + return ( 82 + "Updated my current state reflection" 83 + if success 84 + else "Failed to update reflection" 85 + ) 63 86 64 87 @self.agent.tool 65 88 async def request_identity_change( 66 - ctx: RunContext[None], section: str, proposed_change: str, reason: str 89 + ctx: RunContext[ConversationContext], 90 + section: str, 91 + proposed_change: str, 92 + reason: str, 67 93 ) -> str: 68 94 """Request approval to change core_identity or boundaries sections of my personality""" 69 95 if section not in ["core_identity", "boundaries"]: 70 96 return f"Section '{section}' doesn't require approval. Use other tools for interests/state." 71 97 72 98 approval_id = request_operator_approval( 73 - section, proposed_change, reason 99 + section, proposed_change, reason, ctx.deps["thread_uri"] 74 100 ) 75 - if approval_id: 76 - return f"Approval request #{approval_id} sent to operator. They will review via DM." 77 - else: 78 - return "Failed to create approval request." 101 + if not approval_id: 102 + # Void pattern: throw errors instead of returning error strings 103 + raise RuntimeError("Failed to create approval request") 104 + return f"Approval request #{approval_id} sent to operator. They will review via DM." 79 105 else: 80 106 self.memory = None 81 107 82 108 async def generate_response( 83 - self, mention_text: str, author_handle: str, thread_context: str = "" 109 + self, 110 + mention_text: str, 111 + author_handle: str, 112 + thread_context: str = "", 113 + thread_uri: str | None = None, 84 114 ) -> Response: 85 115 """Generate a response to a mention""" 116 + # Load dynamic personality if memory is available 117 + if self.memory: 118 + try: 119 + dynamic_personality = await load_dynamic_personality() 120 + # Update the agent's system prompt with enhanced personality 121 + self.agent._system_prompt = dynamic_personality 122 + # Successfully loaded dynamic personality 123 + except Exception as e: 124 + logger.warning(f"Could not load dynamic personality: {e}") 125 + 86 126 # Build the full prompt with thread context 87 127 prompt_parts = [] 88 128 ··· 94 134 95 135 prompt = "\n".join(prompt_parts) 96 136 97 - logger.info(f"🤖 Processing mention from @{author_handle}") 98 - logger.debug(f"📝 Mention text: '{mention_text}'") 99 - if thread_context: 100 - logger.debug(f"🧵 Thread context: {thread_context}") 101 - logger.debug(f"🤖 Full prompt:\n{prompt}") 137 + logger.info( 138 + f"🤖 Processing mention from @{author_handle}: {mention_text[:50]}{'...' if len(mention_text) > 50 else ''}" 139 + ) 140 + 141 + # Create context for dependency injection 142 + context: ConversationContext = { 143 + "thread_uri": thread_uri, 144 + "author_handle": author_handle, 145 + } 102 146 103 - # Run agent and capture tool usage 104 - result = await self.agent.run(prompt) 147 + # Run agent with context 148 + result = await self.agent.run(prompt, deps=context) 105 149 106 - # Log the full output for debugging 107 - logger.debug( 108 - f"📊 Full output: action={result.output.action}, " 109 - f"reason='{result.output.reason}', text='{result.output.text}'" 110 - ) 150 + # Log action taken at info level 151 + if result.output.action != "reply": 152 + logger.info(f"🎯 Action: {result.output.action} - {result.output.reason}") 111 153 112 154 return result.output
+9
src/bot/agents/types.py
··· 1 + """Type definitions for agent context""" 2 + 3 + from typing import TypedDict 4 + 5 + 6 + class ConversationContext(TypedDict): 7 + """Context passed to agent tools via dependency injection""" 8 + thread_uri: str | None 9 + author_handle: str
+9 -6
src/bot/core/atproto_client.py
··· 1 1 from atproto import Client 2 2 3 3 from bot.config import settings 4 + from bot.core.rich_text import create_facets 4 5 5 6 6 7 class BotClient: ··· 37 38 self.client.app.bsky.notification.update_seen({"seenAt": seen_at}) 38 39 39 40 async def create_post(self, text: str, reply_to=None): 40 - """Create a new post or reply using the simpler send_post method""" 41 + """Create a new post or reply with rich text support""" 41 42 await self.authenticate() 42 43 43 - # Use the client's send_post method which handles all the details 44 + # Create facets for mentions and URLs 45 + facets = create_facets(text, self.client) 46 + 47 + # Use send_post with facets 44 48 if reply_to: 45 - # Build proper reply reference if needed 46 - return self.client.send_post(text=text, reply_to=reply_to) 49 + return self.client.send_post(text=text, reply_to=reply_to, facets=facets) 47 50 else: 48 - return self.client.send_post(text=text) 51 + return self.client.send_post(text=text, facets=facets) 49 52 50 53 async def get_thread(self, uri: str, depth: int = 10): 51 54 """Get a thread by URI""" ··· 77 80 return self.client.repost(uri=uri, cid=cid) 78 81 79 82 80 - bot_client = BotClient() 83 + bot_client: BotClient = BotClient()
+17 -17
src/bot/core/dm_approval.py
··· 35 35 interpretation: str # Brief explanation of why this decision was made 36 36 37 37 38 - def create_approval_request(request_type: str, request_data: dict) -> int: 38 + def create_approval_request(request_type: str, request_data: dict, thread_uri: str | None = None) -> int: 39 39 """Create a new approval request in the database 40 + 41 + Args: 42 + request_type: Type of approval request 43 + request_data: Data for the request 44 + thread_uri: Optional thread URI to notify after approval 40 45 41 46 Returns the approval request ID 42 47 """ ··· 46 51 47 52 approval_id = thread_db.create_approval_request( 48 53 request_type=request_type, 49 - request_data=json.dumps(request_data) 54 + request_data=json.dumps(request_data), 55 + thread_uri=thread_uri 50 56 ) 51 57 52 58 logger.info(f"Created approval request #{approval_id} for {request_type}") ··· 57 63 return 0 58 64 59 65 60 - def check_pending_approvals() -> list[dict]: 66 + def check_pending_approvals(include_notified: bool = True) -> list[dict]: 61 67 """Get all pending approval requests""" 62 - return thread_db.get_pending_approvals() 68 + return thread_db.get_pending_approvals(include_notified=include_notified) 63 69 64 70 65 71 async def process_dm_for_approval(dm_text: str, sender_handle: str, message_timestamp: str, notification_timestamp: str | None = None) -> list[int]: ··· 106 112 break 107 113 108 114 if not relevant_approval: 109 - logger.debug(f"Message '{dm_text[:30]}...' is not recent enough to be an approval response") 115 + # Message is too old to be an approval response 110 116 return [] 111 117 except Exception as e: 112 118 logger.warning(f"Could not parse timestamps: {e}") ··· 152 158 status = "approved" if decision.approved else "denied" 153 159 logger.info(f"Request #{approval_id} {status} ({decision.confidence} confidence): {decision.interpretation}") 154 160 else: 155 - logger.debug(f"Low confidence for request #{approval_id}: {decision.interpretation}") 161 + # Low confidence interpretation - skip 162 + pass 156 163 157 164 return processed 158 165 ··· 164 171 client: The bot client 165 172 notified_ids: Set of approval IDs we've already notified about 166 173 """ 167 - pending = check_pending_approvals() 168 - if not pending: 169 - return 170 - 171 - # Filter out approvals we've already notified about 172 - if notified_ids is not None: 173 - new_pending = [a for a in pending if a["id"] not in notified_ids] 174 - if not new_pending: 175 - return # Nothing new to notify about 176 - else: 177 - new_pending = pending 174 + # Get only unnotified pending approvals 175 + new_pending = check_pending_approvals(include_notified=False) 176 + if not new_pending: 177 + return # Nothing new to notify about 178 178 179 179 try: 180 180 chat_client = client.client.with_bsky_chat_proxy()
+75
src/bot/core/rich_text.py
··· 1 + """Rich text handling for Bluesky posts""" 2 + 3 + import re 4 + from typing import Any 5 + 6 + from atproto import Client 7 + 8 + 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])?)" 9 + 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@%_\+~#//=])?)" 10 + 11 + 12 + def parse_mentions(text: str, client: Client) -> list[dict[str, Any]]: 13 + """Parse @mentions and create facets with proper byte positions""" 14 + facets = [] 15 + text_bytes = text.encode("UTF-8") 16 + 17 + for match in re.finditer(MENTION_REGEX, text_bytes): 18 + handle = match.group(1)[1:].decode("UTF-8") # Remove @ prefix 19 + mention_start = match.start(1) 20 + mention_end = match.end(1) 21 + 22 + try: 23 + # Resolve handle to DID 24 + response = client.com.atproto.identity.resolve_handle( 25 + params={"handle": handle} 26 + ) 27 + did = response.did 28 + 29 + facets.append( 30 + { 31 + "index": { 32 + "byteStart": mention_start, 33 + "byteEnd": mention_end, 34 + }, 35 + "features": [ 36 + {"$type": "app.bsky.richtext.facet#mention", "did": did} 37 + ], 38 + } 39 + ) 40 + except Exception: 41 + # Skip if handle can't be resolved 42 + continue 43 + 44 + return facets 45 + 46 + 47 + def parse_urls(text: str) -> list[dict[str, Any]]: 48 + """Parse URLs and create link facets""" 49 + facets = [] 50 + text_bytes = text.encode("UTF-8") 51 + 52 + for match in re.finditer(URL_REGEX, text_bytes): 53 + url = match.group(1).decode("UTF-8") 54 + url_start = match.start(1) 55 + url_end = match.end(1) 56 + 57 + facets.append( 58 + { 59 + "index": { 60 + "byteStart": url_start, 61 + "byteEnd": url_end, 62 + }, 63 + "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], 64 + } 65 + ) 66 + 67 + return facets 68 + 69 + 70 + def create_facets(text: str, client: Client) -> list[dict[str, Any]]: 71 + """Create all facets for a post (mentions and URLs)""" 72 + facets = [] 73 + facets.extend(parse_mentions(text, client)) 74 + facets.extend(parse_urls(text)) 75 + return facets
+73 -13
src/bot/database.py
··· 43 43 resolved_at TIMESTAMP, 44 44 resolver_comment TEXT, 45 45 applied_at TIMESTAMP, 46 + thread_uri TEXT, 47 + notified_at TIMESTAMP, 48 + operator_notified_at TIMESTAMP, 46 49 CHECK (status IN ('pending', 'approved', 'denied', 'expired')) 47 50 ) 48 51 """) ··· 50 53 CREATE INDEX IF NOT EXISTS idx_approval_status 51 54 ON approval_requests(status) 52 55 """) 56 + 57 + # Add missing columns if they don't exist (migrations) 58 + for column in ["notified_at", "operator_notified_at"]: 59 + try: 60 + conn.execute(f"ALTER TABLE approval_requests ADD COLUMN {column} TIMESTAMP") 61 + except sqlite3.OperationalError: 62 + # Column already exists 63 + pass 53 64 54 65 @contextmanager 55 66 def _get_connection(self): ··· 109 120 return "\n".join(context_parts) 110 121 111 122 def create_approval_request( 112 - self, request_type: str, request_data: str 123 + self, request_type: str, request_data: str, thread_uri: str | None = None 113 124 ) -> int: 114 125 """Create a new approval request and return its ID""" 115 126 import json ··· 117 128 with self._get_connection() as conn: 118 129 cursor = conn.execute( 119 130 """ 120 - INSERT INTO approval_requests (request_type, request_data) 121 - VALUES (?, ?) 131 + INSERT INTO approval_requests (request_type, request_data, thread_uri) 132 + VALUES (?, ?, ?) 122 133 """, 123 - (request_type, json.dumps(request_data) if isinstance(request_data, dict) else request_data), 134 + (request_type, json.dumps(request_data) if isinstance(request_data, dict) else request_data, thread_uri), 124 135 ) 125 136 return cursor.lastrowid 126 137 127 - def get_pending_approvals(self) -> list[dict[str, Any]]: 128 - """Get all pending approval requests""" 138 + def get_pending_approvals(self, include_notified: bool = True) -> list[dict[str, Any]]: 139 + """Get pending approval requests 140 + 141 + Args: 142 + include_notified: If False, only return approvals not yet notified to operator 143 + """ 129 144 with self._get_connection() as conn: 130 - cursor = conn.execute( 131 - """ 132 - SELECT * FROM approval_requests 133 - WHERE status = 'pending' 134 - ORDER BY created_at ASC 135 - """ 136 - ) 145 + if include_notified: 146 + cursor = conn.execute( 147 + """ 148 + SELECT * FROM approval_requests 149 + WHERE status = 'pending' 150 + ORDER BY created_at ASC 151 + """ 152 + ) 153 + else: 154 + cursor = conn.execute( 155 + """ 156 + SELECT * FROM approval_requests 157 + WHERE status = 'pending' AND operator_notified_at IS NULL 158 + ORDER BY created_at ASC 159 + """ 160 + ) 137 161 return [dict(row) for row in cursor.fetchall()] 138 162 139 163 def resolve_approval( ··· 160 184 ) 161 185 row = cursor.fetchone() 162 186 return dict(row) if row else None 187 + 188 + def get_recently_applied_approvals(self) -> list[dict[str, Any]]: 189 + """Get approvals that were recently applied and need thread notification""" 190 + with self._get_connection() as conn: 191 + cursor = conn.execute( 192 + """ 193 + SELECT * FROM approval_requests 194 + WHERE status = 'approved' 195 + AND applied_at IS NOT NULL 196 + AND thread_uri IS NOT NULL 197 + AND (notified_at IS NULL OR notified_at < applied_at) 198 + ORDER BY applied_at DESC 199 + """ 200 + ) 201 + return [dict(row) for row in cursor.fetchall()] 202 + 203 + def mark_approval_notified(self, approval_id: int) -> bool: 204 + """Mark that we've notified the thread about this approval""" 205 + with self._get_connection() as conn: 206 + cursor = conn.execute( 207 + "UPDATE approval_requests SET notified_at = CURRENT_TIMESTAMP WHERE id = ?", 208 + (approval_id,), 209 + ) 210 + return cursor.rowcount > 0 211 + 212 + def mark_operator_notified(self, approval_ids: list[int]) -> int: 213 + """Mark that we've notified the operator about these approvals""" 214 + if not approval_ids: 215 + return 0 216 + with self._get_connection() as conn: 217 + placeholders = ",".join("?" * len(approval_ids)) 218 + cursor = conn.execute( 219 + f"UPDATE approval_requests SET operator_notified_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})", 220 + approval_ids, 221 + ) 222 + return cursor.rowcount 163 223 164 224 165 225 # Global database instance
+36 -10
src/bot/main.py
··· 2 2 from contextlib import asynccontextmanager 3 3 from datetime import datetime 4 4 5 - from fastapi import FastAPI 5 + from fastapi import FastAPI, HTTPException 6 6 from fastapi.responses import HTMLResponse 7 7 8 8 from bot.config import settings ··· 10 10 from bot.core.profile_manager import ProfileManager 11 11 from bot.services.notification_poller import NotificationPoller 12 12 from bot.status import bot_status 13 - from bot.templates import STATUS_PAGE_TEMPLATE 13 + from bot.ui.context_capture import context_capture 14 + from bot.ui.templates import ( 15 + CONTEXT_VISUALIZATION_TEMPLATE, 16 + STATUS_PAGE_TEMPLATE, 17 + build_response_cards_html, 18 + ) 14 19 15 20 logger = logging.getLogger("bot.main") 16 21 ··· 19 24 async def lifespan(app: FastAPI): 20 25 logger.info(f"🤖 Starting bot as @{settings.bluesky_handle}") 21 26 22 - # Authenticate first 23 27 await bot_client.authenticate() 24 - 25 - # Set up profile manager and mark as online 28 + 26 29 profile_manager = ProfileManager(bot_client.client) 27 30 await profile_manager.set_online_status(True) 28 - 31 + 29 32 poller = NotificationPoller(bot_client) 30 33 await poller.start() 31 34 ··· 35 38 36 39 logger.info("🛑 Shutting down bot...") 37 40 await poller.stop() 38 - 39 - # Mark as offline before shutdown 41 + 40 42 await profile_manager.set_online_status(False) 41 - 43 + 42 44 logger.info("👋 Bot shutdown complete") 43 - # The task is already cancelled by poller.stop(), no need to await it again 44 45 45 46 46 47 app = FastAPI( ··· 97 98 last_response=format_time_ago(bot_status.last_response_time), 98 99 errors=bot_status.errors, 99 100 ) 101 + 102 + 103 + @app.get("/context", response_class=HTMLResponse) 104 + async def context_visualization(): 105 + """Context visualization dashboard""" 106 + 107 + recent_responses = context_capture.get_recent_responses(limit=20) 108 + responses_html = build_response_cards_html(recent_responses) 109 + return CONTEXT_VISUALIZATION_TEMPLATE.format(responses_html=responses_html) 110 + 111 + 112 + @app.get("/context/api/responses") 113 + async def get_responses(): 114 + """API endpoint for response context data""" 115 + recent_responses = context_capture.get_recent_responses(limit=20) 116 + return [context_capture.to_dict(resp) for resp in recent_responses] 117 + 118 + 119 + @app.get("/context/api/response/{response_id}") 120 + async def get_response_context(response_id: str): 121 + """Get context for a specific response""" 122 + 123 + if not (response_context := context_capture.get_response_context(response_id)): 124 + raise HTTPException(status_code=404, detail="Response not found") 125 + return context_capture.to_dict(response_context)
+22 -11
src/bot/memory/namespace_memory.py
··· 62 62 return self.client.namespace(ns_name) 63 63 64 64 def _generate_id(self, namespace: str, label: str, content: str = "") -> str: 65 - """Generate deterministic ID for memory entry""" 66 - data = f"{namespace}-{label}-{content[:50]}-{datetime.now().date()}" 65 + """Generate unique ID for memory entry""" 66 + # Use timestamp for uniqueness, not just date 67 + timestamp = datetime.now().isoformat() 68 + data = f"{namespace}-{label}-{timestamp}-{content}" 67 69 return hashlib.sha256(data.encode()).hexdigest()[:16] 68 70 69 71 async def _get_embedding(self, text: str) -> list[float]: ··· 169 171 ) 170 172 171 173 async def get_user_memories( 172 - self, user_handle: str, limit: int = 50 174 + self, user_handle: str, limit: int = 50, query: str | None = None 173 175 ) -> list[MemoryEntry]: 174 - """Get memories for a specific user""" 176 + """Get memories for a specific user, optionally filtered by semantic search""" 175 177 user_ns = self.get_user_namespace(user_handle) 176 178 177 179 try: 178 - response = user_ns.query( 179 - rank_by=("vector", "ANN", [0.5] * 1536), 180 - top_k=limit, 181 - include_attributes=["type", "content", "created_at"], 182 - ) 180 + # Use semantic search if query provided, otherwise chronological 181 + if query: 182 + query_embedding = await self._get_embedding(query) 183 + response = user_ns.query( 184 + rank_by=("vector", "ANN", query_embedding), 185 + top_k=limit, 186 + include_attributes=["type", "content", "created_at"], 187 + ) 188 + else: 189 + response = user_ns.query( 190 + rank_by=None, # No ranking, we'll sort by date 191 + top_k=limit * 2, # Get more, then sort 192 + include_attributes=["type", "content", "created_at"], 193 + ) 183 194 184 195 entries = [] 185 196 if response.rows: ··· 203 214 204 215 # Main method used by the bot 205 216 async def build_conversation_context( 206 - self, user_handle: str, include_core: bool = True 217 + self, user_handle: str, include_core: bool = True, query: str | None = None 207 218 ) -> str: 208 219 """Build complete context for a conversation""" 209 220 parts = [] ··· 222 233 parts.append(f"[{label}] {mem.content}") 223 234 224 235 # User-specific memories 225 - user_memories = await self.get_user_memories(user_handle) 236 + user_memories = await self.get_user_memories(user_handle, query=query) 226 237 if user_memories: 227 238 parts.append(f"\n[USER CONTEXT - @{user_handle}]") 228 239 for mem in user_memories[:10]: # Most recent 10
+3 -5
src/bot/personality/__init__.py
··· 2 2 3 3 from .editor import ( 4 4 add_interest, 5 - update_current_state, 6 - propose_style_change, 7 - request_operator_approval, 8 5 process_approved_changes, 6 + request_operator_approval, 7 + update_current_state, 9 8 ) 10 9 11 10 __all__ = [ 12 11 "add_interest", 13 12 "update_current_state", 14 - "propose_style_change", 15 13 "request_operator_approval", 16 14 "process_approved_changes", 17 - ] 15 + ]
+70 -90
src/bot/personality/editor.py
··· 3 3 import logging 4 4 from datetime import datetime 5 5 6 - from bot.config import settings 7 6 from bot.core.dm_approval import needs_approval 8 - from bot.memory import NamespaceMemory, MemoryType 7 + from bot.memory import MemoryType, NamespaceMemory 9 8 10 9 logger = logging.getLogger("bot.personality") 11 10 ··· 15 14 try: 16 15 # Get current interests 17 16 current = await memory.get_core_memories() 18 - interests_mem = next((m for m in current if m.metadata.get("label") == "interests"), None) 19 - 17 + interests_mem = next( 18 + (m for m in current if m.metadata.get("label") == "interests"), None 19 + ) 20 + 20 21 if interests_mem: 21 22 new_content = f"{interests_mem.content}\n- {interest}" 22 23 else: 23 24 new_content = f"## interests\n\n- {interest}" 24 - 25 + 25 26 # Store updated interests 26 - await memory.store_core_memory( 27 - "interests", 28 - new_content, 29 - MemoryType.PERSONALITY 30 - ) 31 - 27 + await memory.store_core_memory("interests", new_content, MemoryType.PERSONALITY) 28 + 32 29 # Log the change 33 30 await memory.store_core_memory( 34 31 "evolution_log", 35 32 f"[{datetime.now().isoformat()}] Added interest: {interest} (Reason: {reason})", 36 - MemoryType.SYSTEM 33 + MemoryType.SYSTEM, 37 34 ) 38 - 35 + 39 36 logger.info(f"Added interest: {interest}") 40 37 return True 41 - 38 + 42 39 except Exception as e: 43 40 logger.error(f"Failed to add interest: {e}") 44 41 return False ··· 47 44 async def update_current_state(memory: NamespaceMemory, reflection: str) -> bool: 48 45 """Update self-reflection - freely allowed""" 49 46 try: 50 - new_content = f"## current state\n\n{reflection}\n\n_Last updated: {datetime.now().isoformat()}_" 51 - 47 + # Just store the reflection, no formatting or headers 52 48 await memory.store_core_memory( 53 - "current_state", 54 - new_content, 55 - MemoryType.PERSONALITY 49 + "current_state", reflection, MemoryType.PERSONALITY 56 50 ) 57 - 51 + 58 52 logger.info("Updated current state") 59 53 return True 60 - 54 + 61 55 except Exception as e: 62 56 logger.error(f"Failed to update state: {e}") 63 57 return False 64 58 65 59 66 - async def propose_style_change(memory: NamespaceMemory, aspect: str, change: str, reason: str) -> str: 67 - """Propose communication style change - guided evolution""" 68 - # Validate it stays within character 69 - if not is_style_change_valid(aspect, change): 70 - return "This change would conflict with my core identity" 71 - 72 - proposal_id = f"style_{datetime.now().timestamp()}" 73 - 74 - # Store proposal 75 - await memory.store_core_memory( 76 - f"proposal_{proposal_id}", 77 - f"Aspect: {aspect}\nChange: {change}\nReason: {reason}", 78 - MemoryType.SYSTEM 79 - ) 80 - 81 - return proposal_id 60 + # Note: propose_style_change was removed because the validation logic was broken. 61 + # Style changes should be handled through the approval system like other guided changes. 82 62 83 63 84 - def is_style_change_valid(aspect: str, change: str) -> bool: 85 - """Check if a style change maintains character coherence""" 86 - # Reject changes that would fundamentally alter character 87 - invalid_changes = [ 88 - "aggressive", "confrontational", "formal", "verbose", 89 - "emoji-heavy", "ALL CAPS", "impersonal", "robotic" 90 - ] 91 - 92 - change_lower = change.lower() 93 - return not any(invalid in change_lower for invalid in invalid_changes) 64 + def request_operator_approval( 65 + section: str, change: str, reason: str, thread_uri: str | None = None 66 + ) -> int: 67 + """Request approval for operator-only changes 94 68 69 + Args: 70 + section: Personality section to change 71 + change: The proposed change 72 + reason: Why this change is needed 73 + thread_uri: Optional thread URI to notify after approval 95 74 96 - def request_operator_approval(section: str, change: str, reason: str) -> int: 97 - """Request approval for operator-only changes 98 - 99 75 Returns approval request ID (0 if no approval needed) 100 76 """ 101 77 if not needs_approval(section): 102 78 return 0 103 - 79 + 104 80 from bot.core.dm_approval import create_approval_request 105 - 81 + 106 82 return create_approval_request( 107 83 request_type="personality_change", 108 84 request_data={ 109 85 "section": section, 110 86 "change": change, 111 87 "reason": reason, 112 - "description": f"Change {section}: {change[:50]}..." 113 - } 88 + "description": f"Change {section}: {change[:50]}...", 89 + }, 90 + thread_uri=thread_uri, 114 91 ) 115 92 116 93 117 94 async def process_approved_changes(memory: NamespaceMemory) -> int: 118 95 """Process any approved personality changes 119 - 96 + 120 97 Returns number of changes processed 121 98 """ 122 99 import json 100 + 123 101 from bot.database import thread_db 124 - 102 + 125 103 processed = 0 126 104 # Get recently approved personality changes that haven't been applied yet 127 105 with thread_db._get_connection() as conn: ··· 135 113 """ 136 114 ) 137 115 approvals = [dict(row) for row in cursor.fetchall()] 138 - 116 + 139 117 for approval in approvals: 140 - try: 141 - data = json.loads(approval["request_data"]) 142 - section = data["section"] 143 - change = data["change"] 118 + try: 119 + data = json.loads(approval["request_data"]) 120 + section = data["section"] 121 + change = data["change"] 122 + 123 + # Apply the personality change 124 + if section in ["core_identity", "boundaries", "communication_style"]: 125 + # Apply the approved change 126 + await memory.store_core_memory(section, change, MemoryType.PERSONALITY) 127 + 128 + # Log the change with appropriate description 129 + log_entry = f"[{datetime.now().isoformat()}] " 130 + if section == "communication_style": 131 + log_entry += f"Applied guided evolution to {section}" 132 + else: 133 + log_entry += f"Operator approved change to {section}" 144 134 145 - # Apply the personality change 146 - if section in ["core_identity", "boundaries"]: 147 - # These are critical sections - update directly 148 - await memory.store_core_memory( 149 - section, 150 - change, 151 - MemoryType.PERSONALITY 152 - ) 153 - 154 - # Log the change 155 - await memory.store_core_memory( 156 - "evolution_log", 157 - f"[{datetime.now().isoformat()}] Operator approved change to {section}", 158 - MemoryType.SYSTEM 135 + await memory.store_core_memory( 136 + "evolution_log", 137 + log_entry, 138 + MemoryType.SYSTEM, 139 + ) 140 + 141 + processed += 1 142 + logger.info(f"Applied approved change to {section}") 143 + 144 + # Mark as applied 145 + with thread_db._get_connection() as conn: 146 + conn.execute( 147 + "UPDATE approval_requests SET applied_at = CURRENT_TIMESTAMP WHERE id = ?", 148 + (approval["id"],), 159 149 ) 160 - 161 - processed += 1 162 - logger.info(f"Applied approved change to {section}") 163 - 164 - # Mark as applied 165 - with thread_db._get_connection() as conn: 166 - conn.execute( 167 - "UPDATE approval_requests SET applied_at = CURRENT_TIMESTAMP WHERE id = ?", 168 - (approval['id'],) 169 - ) 170 - 171 - except Exception as e: 172 - logger.error(f"Failed to process approval #{approval['id']}: {e}") 173 - 174 - return processed 150 + 151 + except Exception as e: 152 + logger.error(f"Failed to process approval #{approval['id']}: {e}") 153 + 154 + return processed
+77 -7
src/bot/response_generator.py
··· 1 1 """Response generation for the bot""" 2 2 3 3 import logging 4 - import os 5 4 import random 6 5 6 + from bot.agents._personality import load_dynamic_personality, load_personality 7 7 from bot.config import settings 8 - from bot.memory import MemoryType, NamespaceMemory 8 + from bot.memory import MemoryType 9 9 from bot.status import bot_status 10 + from bot.ui.context_capture import context_capture 10 11 11 12 logger = logging.getLogger("bot.response") 12 13 ··· 52 53 self.memory = None 53 54 54 55 async def generate( 55 - self, mention_text: str, author_handle: str, thread_context: str = "" 56 + self, mention_text: str, author_handle: str, thread_context: str = "", thread_uri: str | None = None 56 57 ): 57 58 """Generate a response to a mention""" 59 + # Capture context components for visualization 60 + components = [] 61 + 62 + # 1. Base personality (always present) 63 + base_personality = load_personality() 64 + components.append({ 65 + "name": "Base Personality", 66 + "type": "personality", 67 + "content": base_personality, 68 + "metadata": {"source": "personalities/phi.md"} 69 + }) 70 + 58 71 # Enhance thread context with memory if available 59 72 enhanced_context = thread_context 60 73 61 74 if self.memory and self.agent: 62 75 try: 76 + # 2. Dynamic personality memories 77 + dynamic_personality = await load_dynamic_personality() 78 + components.append({ 79 + "name": "Dynamic Personality", 80 + "type": "personality", 81 + "content": dynamic_personality, 82 + "metadata": {"source": "TurboPuffer core memories"} 83 + }) 84 + 63 85 # Store the incoming message 64 86 await self.memory.store_user_memory( 65 87 author_handle, ··· 67 89 MemoryType.CONVERSATION, 68 90 ) 69 91 70 - # Build conversation context 92 + # Build conversation context with semantic search 71 93 memory_context = await self.memory.build_conversation_context( 72 - author_handle, include_core=True 94 + author_handle, include_core=True, query=mention_text 73 95 ) 74 96 enhanced_context = f"{thread_context}\n\n{memory_context}".strip() 75 97 logger.info("📚 Enhanced context with memories") 98 + 99 + # 3. User-specific memories (if any) 100 + user_memories = await self.memory.build_conversation_context(author_handle, include_core=False, query=mention_text) 101 + if user_memories and user_memories.strip(): 102 + components.append({ 103 + "name": f"User Memories (@{author_handle})", 104 + "type": "memory", 105 + "content": user_memories, 106 + "metadata": {"user": author_handle, "source": "TurboPuffer user namespace"} 107 + }) 76 108 77 109 except Exception as e: 78 110 logger.warning(f"Memory enhancement failed: {e}") 79 111 112 + # 4. Thread context (if available) 113 + if thread_context and thread_context != "No previous messages in this thread.": 114 + components.append({ 115 + "name": "Thread Context", 116 + "type": "thread", 117 + "content": thread_context, 118 + "metadata": {"thread_uri": thread_uri} 119 + }) 120 + 121 + # 5. Current mention 122 + components.append({ 123 + "name": "Current Mention", 124 + "type": "mention", 125 + "content": f"@{author_handle} said: {mention_text}", 126 + "metadata": {"author": author_handle, "thread_uri": thread_uri} 127 + }) 128 + 80 129 if self.agent: 81 130 response = await self.agent.generate_response( 82 - mention_text, author_handle, enhanced_context 131 + mention_text, author_handle, enhanced_context, thread_uri 83 132 ) 84 133 85 134 # Store bot's response in memory if available ··· 98 147 except Exception as e: 99 148 logger.warning(f"Failed to store bot response: {e}") 100 149 150 + # Capture context for visualization 151 + response_text = response.text if hasattr(response, 'text') else str(response.get('text', '[no text]')) 152 + context_capture.capture_response_context( 153 + mention_text=mention_text, 154 + author_handle=author_handle, 155 + thread_uri=thread_uri, 156 + generated_response=response_text, 157 + components=components 158 + ) 159 + 101 160 return response 102 161 else: 103 162 # Return a simple dict for placeholder responses 104 - return {"action": "reply", "text": random.choice(PLACEHOLDER_RESPONSES)} 163 + placeholder_text = random.choice(PLACEHOLDER_RESPONSES) 164 + 165 + # Still capture context for placeholders 166 + context_capture.capture_response_context( 167 + mention_text=mention_text, 168 + author_handle=author_handle, 169 + thread_uri=thread_uri, 170 + generated_response=placeholder_text, 171 + components=components 172 + ) 173 + 174 + return {"action": "reply", "text": placeholder_text}
+1 -5
src/bot/services/message_handler.py
··· 19 19 async def handle_mention(self, notification): 20 20 """Process a mention or reply notification""" 21 21 try: 22 - logger.debug(f"📨 Processing notification: reason={notification.reason}, uri={notification.uri}") 23 - 24 22 # Skip if not a mention or reply 25 23 if notification.reason not in ["mention", "reply"]: 26 - logger.debug(f"⏭️ Skipping notification with reason: {notification.reason}") 27 24 return 28 25 29 26 post_uri = notification.uri ··· 39 36 author_handle = post.author.handle 40 37 author_did = post.author.did 41 38 42 - logger.debug(f"📝 Post details: author=@{author_handle}, text='{mention_text}'") 43 - 44 39 # Record mention received 45 40 bot_status.record_mention() 46 41 ··· 77 72 mention_text=mention_text, 78 73 author_handle=author_handle, 79 74 thread_context=thread_context, 75 + thread_uri=thread_uri, 80 76 ) 81 77 82 78 # Handle structured response or legacy dict
+75 -14
src/bot/services/notification_poller.py
··· 1 1 import asyncio 2 + import json 2 3 import logging 3 4 import time 4 5 ··· 48 49 try: 49 50 await self._check_notifications() 50 51 except Exception as e: 52 + # Compact error handling (12-factor principle #9) 51 53 logger.error(f"Error in notification poll: {e}") 52 54 bot_status.record_error() 53 55 if settings.debug: 54 56 import traceback 55 - 56 57 traceback.print_exc() 58 + # Continue polling - don't let one error stop the bot 59 + continue 57 60 58 61 # Sleep with proper cancellation handling 59 62 try: ··· 102 105 for notification in reversed(notifications): 103 106 # Skip if already seen or processed 104 107 if notification.is_read or notification.uri in self._processed_uris: 105 - logger.debug(f"⏭️ Skipping already processed: {notification.uri}") 106 108 continue 107 109 108 - logger.debug(f"🔍 Found notification: reason={notification.reason}, uri={notification.uri}") 109 - 110 110 if notification.reason in ["mention", "reply"]: 111 + logger.debug(f"🔍 Processing {notification.reason} notification") 111 112 # Process mentions and replies in threads 112 113 self._processed_uris.add(notification.uri) 113 114 await self.handler.handle_mention(notification) 114 115 processed_any_mentions = True 115 116 else: 116 - logger.debug(f"⏭️ Ignoring notification type: {notification.reason}") 117 + # Silently ignore other notification types 118 + pass 117 119 118 120 # Mark all notifications as seen using the initial timestamp 119 121 # This ensures we don't miss any that arrived during processing ··· 133 135 from bot.core.dm_approval import process_dm_for_approval, check_pending_approvals, notify_operator_of_pending 134 136 from bot.personality import process_approved_changes 135 137 136 - # Check if we have pending approvals 138 + # Check if we have pending approvals (include all for DM checking) 137 139 pending = check_pending_approvals() 138 140 if not pending: 139 141 return 140 142 141 - logger.debug(f"Checking DMs for {len(pending)} pending approvals") 143 + # Check DMs for pending approvals 142 144 143 145 # Get recent DMs 144 146 chat_client = self.client.client.with_bsky_chat_proxy() ··· 164 166 break 165 167 166 168 if sender_handle: 167 - logger.debug(f"DM from @{sender_handle}: {msg.text[:50]}...") 169 + # Process DM from operator 168 170 # Mark this message as processed 169 171 self._processed_dm_ids.add(msg.id) 170 172 ··· 185 187 chat_client.chat.bsky.convo.update_read( 186 188 data={"convoId": convo.id} 187 189 ) 188 - logger.debug(f"Marked conversation {convo.id} as read") 190 + pass # Successfully marked as read 189 191 except Exception as e: 190 192 logger.warning(f"Failed to mark conversation as read: {e}") 191 193 ··· 194 196 changes = await process_approved_changes(self.handler.response_generator.memory) 195 197 if changes: 196 198 logger.info(f"Applied {changes} approved personality changes") 199 + 200 + # Notify threads about applied changes 201 + await self._notify_threads_about_approvals() 197 202 198 203 # Notify operator of new pending approvals 199 - if len(pending) > 0: 200 - await notify_operator_of_pending(self.client, self._notified_approval_ids) 201 - # Add all pending IDs to notified set 202 - for approval in pending: 203 - self._notified_approval_ids.add(approval["id"]) 204 + # Use database to track what's been notified instead of in-memory set 205 + from bot.database import thread_db 206 + unnotified = thread_db.get_pending_approvals(include_notified=False) 207 + if unnotified: 208 + await notify_operator_of_pending(self.client, None) # Let DB handle tracking 209 + # Mark as notified in database 210 + thread_db.mark_operator_notified([a["id"] for a in unnotified]) 204 211 205 212 except Exception as e: 206 213 logger.warning(f"DM approval check failed: {e}") 214 + 215 + async def _notify_threads_about_approvals(self): 216 + """Notify threads about applied personality changes""" 217 + try: 218 + from bot.database import thread_db 219 + import json 220 + 221 + # Get approvals that need notification 222 + approvals = thread_db.get_recently_applied_approvals() 223 + 224 + for approval in approvals: 225 + try: 226 + data = json.loads(approval["request_data"]) 227 + 228 + # Create notification message 229 + message = f"✅ personality update applied: {data.get('section', 'unknown')} has been updated" 230 + 231 + # Get the original post to construct proper reply 232 + from atproto_client import models 233 + thread_uri = approval["thread_uri"] 234 + 235 + # Get the post data to extract CID 236 + posts_response = self.client.client.get_posts([thread_uri]) 237 + if not posts_response.posts: 238 + logger.error(f"Could not find post at {thread_uri}") 239 + continue 240 + 241 + original_post = posts_response.posts[0] 242 + 243 + # Create StrongRef with the actual CID 244 + parent_ref = models.ComAtprotoRepoStrongRef.Main( 245 + uri=thread_uri, cid=original_post.cid 246 + ) 247 + 248 + # For thread notifications, parent and root are the same 249 + reply_ref = models.AppBskyFeedPost.ReplyRef( 250 + parent=parent_ref, root=parent_ref 251 + ) 252 + 253 + # Post to the thread 254 + await self.client.create_post( 255 + text=message, 256 + reply_to=reply_ref 257 + ) 258 + 259 + # Mark as notified 260 + thread_db.mark_approval_notified(approval["id"]) 261 + logger.info(f"Notified thread about approval #{approval['id']}") 262 + 263 + except Exception as e: 264 + logger.error(f"Failed to notify thread for approval #{approval['id']}: {e}") 265 + 266 + except Exception as e: 267 + logger.warning(f"Thread notification check failed: {e}")
-137
src/bot/templates.py
··· 1 - """HTML templates for the bot""" 2 - 3 - STATUS_PAGE_TEMPLATE = """<!DOCTYPE html> 4 - <html> 5 - <head> 6 - <title>{bot_name} Status</title> 7 - <meta http-equiv="refresh" content="10"> 8 - <style> 9 - body {{ 10 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 11 - margin: 0; 12 - padding: 20px; 13 - background: #0a0a0a; 14 - color: #e0e0e0; 15 - }} 16 - .container {{ 17 - max-width: 800px; 18 - margin: 0 auto; 19 - }} 20 - h1 {{ 21 - color: #00a8ff; 22 - margin-bottom: 30px; 23 - }} 24 - .status-grid {{ 25 - display: grid; 26 - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 27 - gap: 20px; 28 - margin-bottom: 30px; 29 - }} 30 - .status-card {{ 31 - background: #1a1a1a; 32 - border: 1px solid #333; 33 - border-radius: 8px; 34 - padding: 20px; 35 - }} 36 - .status-card h3 {{ 37 - margin: 0 0 15px 0; 38 - color: #00a8ff; 39 - font-size: 14px; 40 - text-transform: uppercase; 41 - letter-spacing: 1px; 42 - }} 43 - .status-value {{ 44 - font-size: 24px; 45 - font-weight: bold; 46 - margin-bottom: 5px; 47 - }} 48 - .status-label {{ 49 - font-size: 12px; 50 - color: #888; 51 - }} 52 - .status-indicator {{ 53 - display: inline-block; 54 - width: 10px; 55 - height: 10px; 56 - border-radius: 50%; 57 - margin-right: 8px; 58 - }} 59 - .status-active {{ 60 - background: #4caf50; 61 - }} 62 - .status-inactive {{ 63 - background: #f44336; 64 - }} 65 - .footer {{ 66 - text-align: center; 67 - color: #666; 68 - font-size: 12px; 69 - margin-top: 40px; 70 - }} 71 - </style> 72 - </head> 73 - <body> 74 - <div class="container"> 75 - <h1>🤖 {bot_name} Status</h1> 76 - 77 - <div class="status-grid"> 78 - <div class="status-card"> 79 - <h3>Bot Status</h3> 80 - <div class="status-value"> 81 - <span class="status-indicator {status_class}"></span> 82 - {status_text} 83 - </div> 84 - <div class="status-label">@{handle}</div> 85 - </div> 86 - 87 - <div class="status-card"> 88 - <h3>Uptime</h3> 89 - <div class="status-value">{uptime}</div> 90 - <div class="status-label">Since startup</div> 91 - </div> 92 - 93 - <div class="status-card"> 94 - <h3>Activity</h3> 95 - <div class="status-value">{mentions_received}</div> 96 - <div class="status-label">Mentions received</div> 97 - <div style="margin-top: 10px;"> 98 - <div class="status-value">{responses_sent}</div> 99 - <div class="status-label">Responses sent</div> 100 - </div> 101 - </div> 102 - 103 - <div class="status-card"> 104 - <h3>Response Mode</h3> 105 - <div class="status-value"> 106 - {ai_mode} 107 - </div> 108 - <div class="status-label"> 109 - {ai_description} 110 - </div> 111 - </div> 112 - 113 - <div class="status-card"> 114 - <h3>Last Activity</h3> 115 - <div style="margin-bottom: 10px;"> 116 - <div class="status-label">Last mention</div> 117 - <div>{last_mention}</div> 118 - </div> 119 - <div> 120 - <div class="status-label">Last response</div> 121 - <div>{last_response}</div> 122 - </div> 123 - </div> 124 - 125 - <div class="status-card"> 126 - <h3>Health</h3> 127 - <div class="status-value">{errors}</div> 128 - <div class="status-label">Errors encountered</div> 129 - </div> 130 - </div> 131 - 132 - <div class="footer"> 133 - <p>Auto-refreshes every 10 seconds</p> 134 - </div> 135 - </div> 136 - </body> 137 - </html>"""
+7 -1
src/bot/tools/google_search.py
··· 1 + import logging 2 + 1 3 import httpx 2 4 3 5 from bot.config import settings 6 + 7 + logger = logging.getLogger("bot.tools") 4 8 5 9 6 10 async def search_google(query: str, num_results: int = 3) -> str: ··· 32 36 return "\n\n".join(results) if results else "No search results found" 33 37 34 38 except Exception as e: 35 - return f"Search error: {str(e)}" 39 + logger.error(f"Search failed: {e}") 40 + # 12-factor principle #4: Tools should throw errors, not return error strings 41 + raise
-56
src/bot/tools/personality_tools.py
··· 1 - """Personality introspection tools for the agent""" 2 - 3 - import logging 4 - from typing import Literal 5 - 6 - from bot.memory import NamespaceMemory 7 - from bot.personality import add_interest, update_current_state 8 - 9 - logger = logging.getLogger("bot.personality_tools") 10 - 11 - PersonalitySection = Literal["interests", "current_state", "communication_style", "core_identity", "boundaries"] 12 - 13 - 14 - async def view_personality_section(memory: NamespaceMemory, section: PersonalitySection) -> str: 15 - """View a section of my personality""" 16 - try: 17 - memories = await memory.get_core_memories() 18 - 19 - # Find the requested section 20 - for mem in memories: 21 - if mem.metadata.get("label") == section: 22 - return mem.content 23 - 24 - return f"Section '{section}' not found in my personality" 25 - 26 - except Exception as e: 27 - logger.error(f"Failed to view personality: {e}") 28 - return "Unable to access personality data" 29 - 30 - 31 - async def reflect_on_interest(memory: NamespaceMemory, topic: str, reflection: str) -> str: 32 - """Reflect on a potential new interest""" 33 - # Check if this is genuinely interesting based on context 34 - if len(reflection) < 20: 35 - return "Need more substantial reflection to add an interest" 36 - 37 - # Add the interest 38 - success = await add_interest(memory, topic, reflection) 39 - 40 - if success: 41 - return f"Added '{topic}' to my interests based on: {reflection}" 42 - else: 43 - return "Failed to update interests" 44 - 45 - 46 - async def update_self_reflection(memory: NamespaceMemory, reflection: str) -> str: 47 - """Update my current state/self-reflection""" 48 - if len(reflection) < 50: 49 - return "Reflection too brief to warrant an update" 50 - 51 - success = await update_current_state(memory, reflection) 52 - 53 - if success: 54 - return "Updated my current state reflection" 55 - else: 56 - return "Failed to update reflection"
src/bot/ui/__init__.py

This is a binary file and will not be displayed.

+109
src/bot/ui/context_capture.py
··· 1 + """Context capture system for visualizing phi's response context""" 2 + 3 + import logging 4 + from collections import deque 5 + from dataclasses import asdict, dataclass 6 + from datetime import datetime 7 + from typing import Any, Literal 8 + 9 + logger = logging.getLogger("bot.context") 10 + 11 + 12 + @dataclass 13 + class ContextComponent: 14 + """A component of phi's response context""" 15 + 16 + name: str 17 + type: Literal["personality", "memory", "thread", "mention", "user"] 18 + content: str 19 + size_chars: int 20 + metadata: dict[str, Any] 21 + timestamp: str 22 + 23 + 24 + @dataclass 25 + class ResponseContext: 26 + """Complete context for a single response""" 27 + 28 + response_id: str 29 + mention_text: str 30 + author_handle: str 31 + thread_uri: str | None 32 + generated_response: str 33 + components: list[ContextComponent] 34 + total_context_chars: int 35 + timestamp: str 36 + 37 + 38 + class ContextCapture: 39 + """Captures and stores context information for responses""" 40 + 41 + def __init__(self, max_stored: int = 50): 42 + self.max_stored = max_stored 43 + self.responses: deque = deque(maxlen=max_stored) 44 + 45 + def capture_response_context( 46 + self, 47 + mention_text: str, 48 + author_handle: str, 49 + thread_uri: str | None, 50 + generated_response: str, 51 + components: list[dict[str, Any]], 52 + ) -> str: 53 + """Capture context for a response and return unique ID""" 54 + response_id = f"resp_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" 55 + 56 + # Convert components to ContextComponent objects 57 + context_components = [] 58 + total_chars = 0 59 + 60 + for comp in components: 61 + component = ContextComponent( 62 + name=comp["name"], 63 + type=comp["type"], 64 + content=comp["content"], 65 + size_chars=len(comp["content"]), 66 + metadata=comp.get("metadata", {}), 67 + timestamp=datetime.now().isoformat(), 68 + ) 69 + context_components.append(component) 70 + total_chars += component.size_chars 71 + 72 + # Create response context 73 + response_context = ResponseContext( 74 + response_id=response_id, 75 + mention_text=mention_text, 76 + author_handle=author_handle, 77 + thread_uri=thread_uri, 78 + generated_response=generated_response, 79 + components=context_components, 80 + total_context_chars=total_chars, 81 + timestamp=datetime.now().isoformat(), 82 + ) 83 + 84 + # Store it 85 + self.responses.appendleft(response_context) 86 + 87 + logger.info( 88 + f"📊 Captured context for {response_id}: {len(components)} components, {total_chars} chars" 89 + ) 90 + return response_id 91 + 92 + def get_response_context(self, response_id: str) -> ResponseContext | None: 93 + """Get context for a specific response""" 94 + for resp in self.responses: 95 + if resp.response_id == response_id: 96 + return resp 97 + return None 98 + 99 + def get_recent_responses(self, limit: int = 20) -> list[ResponseContext]: 100 + """Get recent response contexts""" 101 + return list(self.responses)[:limit] 102 + 103 + def to_dict(self, response_context: ResponseContext) -> dict[str, Any]: 104 + """Convert ResponseContext to dictionary for JSON serialization""" 105 + return asdict(response_context) 106 + 107 + 108 + # Global instance 109 + context_capture = ContextCapture()
+244
src/bot/ui/templates.py
··· 1 + """HTML templates for the bot""" 2 + 3 + from typing import TYPE_CHECKING 4 + 5 + if TYPE_CHECKING: 6 + from bot.ui.context_capture import ResponseContext 7 + 8 + CONTEXT_VISUALIZATION_TEMPLATE = """<!DOCTYPE html> 9 + <html> 10 + <head> 11 + <title>Phi Context Visualization</title> 12 + <style> 13 + body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #0a0a0a; color: #e0e0e0; }} 14 + .response-card {{ border: 1px solid #333; margin-bottom: 20px; border-radius: 8px; overflow: hidden; background: #1a1a1a; }} 15 + .response-header {{ background: #2a2a2a; padding: 15px; border-bottom: 1px solid #333; }} 16 + .response-meta {{ font-size: 0.9em; color: #888; margin-bottom: 5px; }} 17 + .mention-text {{ font-weight: bold; margin-bottom: 5px; color: #e0e0e0; }} 18 + .generated-response {{ color: #00a8ff; font-style: italic; }} 19 + .components {{ padding: 15px; }} 20 + .component {{ margin-bottom: 15px; }} 21 + .component-header {{ 22 + cursor: pointer; 23 + padding: 10px; 24 + background: #2a2a2a; 25 + border: 1px solid #444; 26 + border-radius: 4px; 27 + display: flex; 28 + justify-content: space-between; 29 + align-items: center; 30 + }} 31 + .component-header:hover {{ background: #333; }} 32 + .component-type {{ 33 + font-size: 0.8em; 34 + color: #888; 35 + background: #444; 36 + padding: 2px 6px; 37 + border-radius: 3px; 38 + }} 39 + .component-size {{ font-size: 0.8em; color: #888; }} 40 + .component-content {{ 41 + display: none; 42 + padding: 15px; 43 + border: 1px solid #444; 44 + border-top: none; 45 + background: #1a1a1a; 46 + white-space: pre-wrap; 47 + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; 48 + font-size: 0.9em; 49 + max-height: 400px; 50 + overflow-y: auto; 51 + }} 52 + .component-content.show {{ display: block; }} 53 + .stats {{ display: flex; gap: 20px; margin-bottom: 10px; }} 54 + .stat {{ font-size: 0.9em; color: #888; }} 55 + h1 {{ color: #00a8ff; }} 56 + </style> 57 + </head> 58 + <body> 59 + <h1>🧠 Phi Context Visualization</h1> 60 + {responses_html} 61 + <script> 62 + function toggleComponent(id) {{ 63 + const element = document.getElementById(id); 64 + element.classList.toggle('show'); 65 + }} 66 + </script> 67 + </body> 68 + </html>""" 69 + 70 + STATUS_PAGE_TEMPLATE = """<!DOCTYPE html> 71 + <html> 72 + <head> 73 + <title>Bluesky Bot Status</title> 74 + <meta http-equiv="refresh" content="10"> 75 + <style> 76 + body {{ 77 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 78 + margin: 0; 79 + padding: 20px; 80 + background: #0a0a0a; 81 + color: #e0e0e0; 82 + }} 83 + .container {{ 84 + max-width: 800px; 85 + margin: 0 auto; 86 + }} 87 + h1 {{ 88 + color: #00a8ff; 89 + margin-bottom: 30px; 90 + }} 91 + .status-grid {{ 92 + display: grid; 93 + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 94 + gap: 20px; 95 + margin-bottom: 40px; 96 + }} 97 + .status-card {{ 98 + background: #1a1a1a; 99 + border: 1px solid #333; 100 + border-radius: 8px; 101 + padding: 20px; 102 + }} 103 + .status-card h3 {{ 104 + margin: 0 0 15px 0; 105 + color: #00a8ff; 106 + font-size: 1rem; 107 + text-transform: uppercase; 108 + letter-spacing: 0.5px; 109 + }} 110 + .status-value {{ 111 + font-size: 2rem; 112 + font-weight: bold; 113 + margin-bottom: 5px; 114 + }} 115 + .status-label {{ 116 + color: #888; 117 + font-size: 0.9rem; 118 + }} 119 + .status-active {{ 120 + color: #00ff88; 121 + }} 122 + .status-inactive {{ 123 + color: #ff4444; 124 + }} 125 + .uptime {{ 126 + font-size: 1.2rem; 127 + margin-bottom: 5px; 128 + }} 129 + .ai-mode {{ 130 + display: inline-block; 131 + padding: 4px 12px; 132 + border-radius: 4px; 133 + font-size: 0.9rem; 134 + background: #00a8ff22; 135 + color: #00a8ff; 136 + border: 1px solid #00a8ff44; 137 + }} 138 + .ai-mode.placeholder {{ 139 + background: #ff444422; 140 + color: #ff8888; 141 + border-color: #ff444444; 142 + }} 143 + .footer {{ 144 + margin-top: 40px; 145 + text-align: center; 146 + color: #666; 147 + font-size: 0.9rem; 148 + }} 149 + </style> 150 + </head> 151 + <body> 152 + <div class="container"> 153 + <h1>🤖 {bot_name} Status</h1> 154 + 155 + <div class="status-grid"> 156 + <div class="status-card"> 157 + <h3>Bot Status</h3> 158 + <div class="status-value {status_class}">{status}</div> 159 + <div class="uptime">{uptime}</div> 160 + <div style="margin-top: 10px;"> 161 + <span class="ai-mode {ai_mode_class}">{ai_mode}</span> 162 + </div> 163 + </div> 164 + 165 + <div class="status-card"> 166 + <h3>Activity</h3> 167 + <div class="status-value">{mentions}</div> 168 + <div class="status-label">Mentions received</div> 169 + <div style="margin-top: 10px;"> 170 + <div class="status-value">{responses}</div> 171 + <div class="status-label">Responses sent</div> 172 + </div> 173 + </div> 174 + 175 + <div class="status-card"> 176 + <h3>Last Activity</h3> 177 + <div style="margin-bottom: 10px;"> 178 + <div class="status-label">Last mention</div> 179 + <div>{last_mention}</div> 180 + </div> 181 + <div> 182 + <div class="status-label">Last response</div> 183 + <div>{last_response}</div> 184 + </div> 185 + </div> 186 + 187 + <div class="status-card"> 188 + <h3>Health</h3> 189 + <div class="status-value">{errors}</div> 190 + <div class="status-label">Errors encountered</div> 191 + </div> 192 + </div> 193 + 194 + <div class="footer"> 195 + <p>Auto-refreshes every 10 seconds</p> 196 + </div> 197 + </div> 198 + </body> 199 + </html>""" 200 + 201 + 202 + def build_response_cards_html(responses: list["ResponseContext"]) -> str: 203 + """Build HTML for response cards""" 204 + if not responses: 205 + return '<p style="text-align: center; color: #888;">No recent responses to display.</p>' 206 + 207 + return "".join([ 208 + f''' 209 + <div class="response-card"> 210 + <div class="response-header"> 211 + <div class="response-meta"> 212 + {resp.timestamp[:19].replace("T", " ")} • @{resp.author_handle} 213 + {f" • Thread: {resp.thread_uri.split('/')[-1][:8]}..." if resp.thread_uri else ""} 214 + </div> 215 + <div class="mention-text">"{resp.mention_text}"</div> 216 + <div class="generated-response">→ "{resp.generated_response}"</div> 217 + <div class="stats"> 218 + <div class="stat">{len(resp.components)} components</div> 219 + <div class="stat">{resp.total_context_chars:,} characters</div> 220 + </div> 221 + </div> 222 + <div class="components"> 223 + {"".join([ 224 + f''' 225 + <div class="component"> 226 + <div class="component-header" onclick="toggleComponent('{resp.response_id}_{i}')"> 227 + <div> 228 + <strong>{comp.name}</strong> 229 + <span class="component-type">{comp.type}</span> 230 + </div> 231 + <div class="component-size">{comp.size_chars:,} chars</div> 232 + </div> 233 + <div class="component-content" id="{resp.response_id}_{i}"> 234 + {comp.content} 235 + </div> 236 + </div> 237 + ''' 238 + for i, comp in enumerate(resp.components) 239 + ])} 240 + </div> 241 + </div> 242 + ''' 243 + for resp in responses 244 + ])