+6
.env.example
+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
+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
-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
+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
-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
+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
+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
+6
-1
pyproject.toml
+35
-37
scripts/manage_memory.py
+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
+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
+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
+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
+9
src/bot/agents/types.py
+9
-6
src/bot/core/atproto_client.py
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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
+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
-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
src/bot/ui/__init__.py
This is a binary file and will not be displayed.
+109
src/bot/ui/context_capture.py
+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
+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
+
])