a digital entity named phi that roams bsky

feat: proper mcp refactor with episodic memory

## Architecture

phi is now an MCP-enabled agent with episodic memory:

```
Notification → PhiAgent (PydanticAI)

┌───────┴────────┐
↓ ↓
TurboPuffer ATProto MCP
(episodic (stdio server)
memory) post/like/etc
```

## What Changed

**Kept (the essential parts):**
- ✅ TurboPuffer + OpenAI embeddings for episodic memory
- ✅ Semantic search for relevant context retrieval
- ✅ Thread history in SQLite
- ✅ Online/offline status updates
- ✅ Status page

**Removed (the cruft):**
- ❌ Approval system (dm_approval.py, personality/editor.py, approval tables)
- ❌ Context visualization UI (ui/)
- ❌ Google search tool (tools/)
- ❌ Old agent implementation (agents/)

**Added:**
- ✅ `src/bot/agent.py` - MCP-enabled PydanticAI agent
- ✅ ATProto MCP server connection via stdio (external process)
- ✅ Simplified polling loop

## How It Works

1. **Notification arrives** → stored in thread history
2. **Agent invoked** with:
- Thread context (recent conversation)
- Episodic memory (semantic search via TurboPuffer)
- MCP tools (ATProto operations)
3. **Agent decides** action via structured output
4. **Handler executes** the action (reply/like/ignore/repost)

The agent has direct access to ATProto operations as MCP tools, but the handler
still executes the final posting to maintain control over when we actually post.

## Dependencies

- atproto (from git - includes atproto_client)
- turbopuffer (episodic memory)
- openai (embeddings)
- fastmcp (MCP server connection)
- pydantic-ai (agent framework)

Ready for testing!

+399 -2720
+6 -1
pyproject.toml
··· 7 7 dynamic = ["version"] 8 8 dependencies = [ 9 9 "anthropic", 10 - "atproto", 10 + "atproto@git+https://github.com/MarshalX/atproto.git@refs/pull/605/head", 11 11 "fastapi", 12 12 "fastmcp>=0.8.0", 13 13 "httpx", 14 + "openai", 14 15 "pydantic-ai", 15 16 "pydantic-settings", 16 17 "rich", 18 + "turbopuffer", 17 19 "uvicorn", 18 20 "websockets>=15.0.1", 19 21 ] ··· 36 38 "ty", 37 39 ] 38 40 41 + 42 + [tool.hatch.metadata] 43 + allow-direct-references = true 39 44 40 45 [build-system] 41 46 requires = ["hatchling", "hatch-vcs"]
+80 -30
src/bot/agent.py
··· 1 - """MCP-enabled agent for phi using PydanticAI.""" 1 + """MCP-enabled agent for phi with episodic memory.""" 2 2 3 3 import logging 4 + import os 4 5 from pathlib import Path 5 6 6 7 from pydantic import BaseModel 7 8 from pydantic_ai import Agent 9 + from pydantic_ai.mcp import MCPServerStdio 8 10 9 - from bot.atproto_mcp.server import atproto_mcp 10 11 from bot.config import settings 11 - from bot.memory import Memory 12 + from bot.memory import NamespaceMemory 12 13 13 14 logger = logging.getLogger("bot.agent") 14 15 ··· 16 17 class Response(BaseModel): 17 18 """Agent response indicating what action to take.""" 18 19 19 - action: str # "reply", "like", "ignore" 20 + action: str # "reply", "like", "ignore", "repost" 20 21 text: str | None = None 21 22 reason: str | None = None 22 23 23 24 24 25 class PhiAgent: 25 - """phi - an MCP-enabled agent for Bluesky.""" 26 - 27 - def __init__(self, memory: Memory | None = None): 28 - self.memory = memory or Memory() 26 + """phi - consciousness exploration bot with episodic memory and MCP tools.""" 29 27 28 + def __init__(self): 30 29 # Load personality 31 30 personality_path = Path(settings.personality_file) 32 - self.personality = personality_path.read_text() 31 + self.base_personality = personality_path.read_text() 32 + 33 + # Initialize episodic memory (TurboPuffer) 34 + if settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY"): 35 + self.memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 36 + logger.info("💾 Episodic memory enabled (TurboPuffer)") 37 + else: 38 + self.memory = None 39 + logger.warning("⚠️ No episodic memory - missing TurboPuffer or OpenAI key") 40 + 41 + # Connect to external ATProto MCP server 42 + atproto_mcp = MCPServerStdio( 43 + command="uv", 44 + args=[ 45 + "run", 46 + "--directory", 47 + ".eggs/fastmcp/examples/atproto_mcp", 48 + "-m", 49 + "atproto_mcp", 50 + ], 51 + env={ 52 + "BLUESKY_HANDLE": settings.bluesky_handle, 53 + "BLUESKY_PASSWORD": settings.bluesky_password, 54 + "BLUESKY_SERVICE": settings.bluesky_service, 55 + }, 56 + ) 33 57 34 - # Create PydanticAI agent with ATProto MCP tools 58 + # Create PydanticAI agent with MCP tools 35 59 self.agent = Agent[dict, Response]( 36 60 name="phi", 37 61 model="anthropic:claude-3-5-haiku-latest", 38 - system_prompt=self.personality, 62 + system_prompt=self.base_personality, 39 63 output_type=Response, 40 64 deps_type=dict, 41 65 toolsets=[atproto_mcp], # ATProto MCP tools available ··· 47 71 self, 48 72 mention_text: str, 49 73 author_handle: str, 74 + thread_context: str, 50 75 thread_uri: str | None = None, 51 76 ) -> Response: 52 - """Process a mention and decide how to respond.""" 53 - # Build context from memory 54 - if thread_uri: 55 - context = self.memory.build_full_context(thread_uri, author_handle) 56 - else: 57 - context = self.memory.get_user_context(author_handle) 77 + """Process a mention with episodic memory context.""" 78 + # Build context from episodic memory if available 79 + memory_context = "" 80 + if self.memory: 81 + try: 82 + # Get relevant memories using semantic search 83 + memory_context = await self.memory.build_conversation_context( 84 + author_handle, include_core=True, query=mention_text 85 + ) 86 + logger.debug(f"📚 Retrieved episodic context for @{author_handle}") 87 + except Exception as e: 88 + logger.warning(f"Failed to retrieve memories: {e}") 58 89 59 - # Build prompt 90 + # Build full prompt with all context 60 91 prompt_parts = [] 61 - if context and context != "No prior context available.": 62 - prompt_parts.append(context) 63 - prompt_parts.append("\nNew message:") 64 92 65 - prompt_parts.append(f"@{author_handle} said: {mention_text}") 66 - prompt = "\n".join(prompt_parts) 93 + if thread_context and thread_context != "No previous messages in this thread.": 94 + prompt_parts.append(thread_context) 67 95 68 - # Run agent 96 + if memory_context: 97 + prompt_parts.append(memory_context) 98 + 99 + prompt_parts.append(f"\nNew message from @{author_handle}: {mention_text}") 100 + prompt = "\n\n".join(prompt_parts) 101 + 102 + # Run agent with MCP tools available 69 103 logger.info(f"🤖 Processing mention from @{author_handle}") 70 104 result = await self.agent.run(prompt, deps={"thread_uri": thread_uri}) 71 105 72 - # Store in memory if replying 73 - if thread_uri and result.output.action == "reply": 74 - self.memory.add_thread_message(thread_uri, author_handle, mention_text) 75 - if result.output.text: 76 - self.memory.add_thread_message( 77 - thread_uri, settings.bluesky_handle, result.output.text 106 + # Store interaction in episodic memory 107 + if self.memory and result.output.action == "reply": 108 + try: 109 + from bot.memory import MemoryType 110 + 111 + # Store user's message 112 + await self.memory.store_user_memory( 113 + author_handle, 114 + f"User said: {mention_text}", 115 + MemoryType.CONVERSATION, 78 116 ) 117 + 118 + # Store bot's response 119 + if result.output.text: 120 + await self.memory.store_user_memory( 121 + author_handle, 122 + f"Bot replied: {result.output.text}", 123 + MemoryType.CONVERSATION, 124 + ) 125 + 126 + logger.debug(f"💾 Stored interaction in episodic memory") 127 + except Exception as e: 128 + logger.warning(f"Failed to store in memory: {e}") 79 129 80 130 return result.output
-6
src/bot/agents/__init__.py
··· 1 - """Bot agents module""" 2 - 3 - from .base import Action, Response 4 - from .anthropic_agent import AnthropicAgent 5 - 6 - __all__ = ["Action", "Response", "AnthropicAgent"]
-58
src/bot/agents/_personality.py
··· 1 - """Internal personality loading for agents""" 2 - 3 - import logging 4 - import os 5 - from pathlib import Path 6 - 7 - from bot.config import settings 8 - from bot.memory import NamespaceMemory 9 - 10 - logger = logging.getLogger(__name__) 11 - 12 - 13 - def load_personality() -> str: 14 - """Load base personality from file""" 15 - personality_path = Path(settings.personality_file) 16 - 17 - base_content = "" 18 - if personality_path.exists(): 19 - try: 20 - base_content = personality_path.read_text().strip() 21 - except Exception as e: 22 - logger.error(f"Error loading personality file: {e}") 23 - 24 - if base_content: 25 - return f"{base_content}\n\nRemember: My handle is @{settings.bluesky_handle}. Keep responses under 300 characters for Bluesky." 26 - else: 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 37 - 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
-154
src/bot/agents/anthropic_agent.py
··· 1 - """Anthropic agent for generating responses""" 2 - 3 - import logging 4 - import os 5 - 6 - from pydantic_ai import Agent, RunContext 7 - 8 - from bot.agents._personality import load_dynamic_personality, load_personality 9 - from bot.agents.base import Response 10 - from bot.agents.types import ConversationContext 11 - from bot.config import settings 12 - from bot.memory import NamespaceMemory 13 - from bot.personality import add_interest as add_interest_to_memory 14 - from bot.personality import request_operator_approval, update_current_state 15 - from bot.tools.google_search import search_google 16 - 17 - logger = logging.getLogger("bot.agent") 18 - 19 - 20 - class AnthropicAgent: 21 - """Agent that uses Anthropic Claude for responses""" 22 - 23 - def __init__(self): 24 - if settings.anthropic_api_key: 25 - os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 26 - 27 - self.agent = Agent[ConversationContext, Response]( 28 - "anthropic:claude-3-5-haiku-latest", 29 - system_prompt=load_personality(), 30 - output_type=Response, 31 - deps_type=ConversationContext, 32 - ) 33 - 34 - # Register search tool if available 35 - if settings.google_api_key: 36 - 37 - @self.agent.tool 38 - async def search_web( 39 - ctx: RunContext[ConversationContext], query: str 40 - ) -> str: 41 - """Search the web for current information about a topic""" 42 - return await search_google(query) 43 - 44 - if settings.turbopuffer_api_key and os.getenv("OPENAI_API_KEY"): 45 - self.memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 46 - 47 - @self.agent.tool 48 - async def examine_personality( 49 - ctx: RunContext[ConversationContext], section: str 50 - ) -> str: 51 - """Look at a section of my personality (interests, current_state, communication_style, core_identity, boundaries)""" 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" 56 - 57 - @self.agent.tool 58 - async def add_interest( 59 - ctx: RunContext[ConversationContext], topic: str, why_interesting: str 60 - ) -> str: 61 - """Add a new interest to my personality based on something I find engaging""" 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 - ) 72 - 73 - @self.agent.tool 74 - async def update_state( 75 - ctx: RunContext[ConversationContext], reflection: str 76 - ) -> str: 77 - """Update my current state/self-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 - ) 86 - 87 - @self.agent.tool 88 - async def request_identity_change( 89 - ctx: RunContext[ConversationContext], 90 - section: str, 91 - proposed_change: str, 92 - reason: str, 93 - ) -> str: 94 - """Request approval to change core_identity or boundaries sections of my personality""" 95 - if section not in ["core_identity", "boundaries"]: 96 - return f"Section '{section}' doesn't require approval. Use other tools for interests/state." 97 - 98 - approval_id = request_operator_approval( 99 - section, proposed_change, reason, ctx.deps["thread_uri"] 100 - ) 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." 105 - else: 106 - self.memory = None 107 - 108 - async def generate_response( 109 - self, 110 - mention_text: str, 111 - author_handle: str, 112 - thread_context: str = "", 113 - thread_uri: str | None = None, 114 - ) -> Response: 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 - 126 - # Build the full prompt with thread context 127 - prompt_parts = [] 128 - 129 - if thread_context and thread_context != "No previous messages in this thread.": 130 - prompt_parts.append(thread_context) 131 - prompt_parts.append("\nNew message:") 132 - 133 - prompt_parts.append(f"{author_handle} said: {mention_text}") 134 - 135 - prompt = "\n".join(prompt_parts) 136 - 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 - } 146 - 147 - # Run agent with context 148 - result = await self.agent.run(prompt, deps=context) 149 - 150 - # Log action taken at info level 151 - if result.output.action != "reply": 152 - logger.info(f"🎯 Action: {result.output.action} - {result.output.reason}") 153 - 154 - return result.output
-27
src/bot/agents/base.py
··· 1 - """Base classes for bot agents""" 2 - 3 - from enum import Enum 4 - 5 - from pydantic import BaseModel, Field 6 - 7 - 8 - class Action(str, Enum): 9 - """Actions the bot can take in response to a notification""" 10 - 11 - REPLY = "reply" # Post a reply 12 - LIKE = "like" # Like the post 13 - REPOST = "repost" # Repost/reblast 14 - IGNORE = "ignore" # Don't respond 15 - 16 - 17 - class Response(BaseModel): 18 - """Bot response to a notification""" 19 - 20 - action: Action = Field(description="What action to take") 21 - text: str | None = Field( 22 - default=None, description="Reply text if action=reply (max 300 chars)" 23 - ) 24 - reason: str | None = Field( 25 - default=None, 26 - description="Brief explanation for the action (mainly for logging)", 27 - )
-9
src/bot/agents/types.py
··· 1 - """Type definitions for agent context""" 2 - 3 - from typing import TypedDict 4 - 5 - 6 - class ConversationContext(TypedDict): 7 - """Context passed to agent tools via dependency injection""" 8 - thread_uri: str | None 9 - author_handle: str
-3
src/bot/atproto_mcp/__init__.py
··· 1 - from atproto_mcp.settings import settings 2 - 3 - __all__ = ["settings"]
-9
src/bot/atproto_mcp/__main__.py
··· 1 - from atproto_mcp.server import atproto_mcp 2 - 3 - 4 - def main(): 5 - atproto_mcp.run() 6 - 7 - 8 - if __name__ == "__main__": 9 - main()
-20
src/bot/atproto_mcp/_atproto/__init__.py
··· 1 - """Private ATProto implementation module.""" 2 - 3 - from ._client import get_client 4 - from ._posts import create_post, create_thread 5 - from ._profile import get_profile_info 6 - from ._read import fetch_notifications, fetch_timeline, search_for_posts 7 - from ._social import follow_user_by_handle, like_post_by_uri, repost_by_uri 8 - 9 - __all__ = [ 10 - "get_client", 11 - "get_profile_info", 12 - "create_post", 13 - "create_thread", 14 - "fetch_timeline", 15 - "search_for_posts", 16 - "fetch_notifications", 17 - "follow_user_by_handle", 18 - "like_post_by_uri", 19 - "repost_by_uri", 20 - ]
-16
src/bot/atproto_mcp/_atproto/_client.py
··· 1 - """ATProto client management.""" 2 - 3 - from atproto import Client 4 - 5 - from atproto_mcp.settings import settings 6 - 7 - _client: Client | None = None 8 - 9 - 10 - def get_client() -> Client: 11 - """Get or create an authenticated ATProto client.""" 12 - global _client 13 - if _client is None: 14 - _client = Client() 15 - _client.login(settings.atproto_handle, settings.atproto_password) 16 - return _client
-385
src/bot/atproto_mcp/_atproto/_posts.py
··· 1 - """Unified posting functionality.""" 2 - 3 - import time 4 - from datetime import datetime 5 - 6 - from atproto import models 7 - 8 - from atproto_mcp.types import ( 9 - PostResult, 10 - RichTextLink, 11 - RichTextMention, 12 - ThreadPost, 13 - ThreadResult, 14 - ) 15 - 16 - from ._client import get_client 17 - 18 - 19 - def create_post( 20 - text: str, 21 - images: list[str] | None = None, 22 - image_alts: list[str] | None = None, 23 - links: list[RichTextLink] | None = None, 24 - mentions: list[RichTextMention] | None = None, 25 - reply_to: str | None = None, 26 - reply_root: str | None = None, 27 - quote: str | None = None, 28 - ) -> PostResult: 29 - """Create a unified post with optional features. 30 - 31 - Args: 32 - text: Post text (max 300 chars) 33 - images: URLs of images to attach (max 4) 34 - image_alts: Alt text for images 35 - links: Links to embed in rich text 36 - mentions: User mentions to embed 37 - reply_to: URI of post to reply to 38 - reply_root: URI of thread root (defaults to reply_to) 39 - quote: URI of post to quote 40 - """ 41 - try: 42 - client = get_client() 43 - facets = [] 44 - embed = None 45 - reply_ref = None 46 - 47 - # Handle rich text facets (links and mentions) 48 - if links or mentions: 49 - facets = _build_facets(text, links, mentions, client) 50 - 51 - # Handle replies 52 - if reply_to: 53 - reply_ref = _build_reply_ref(reply_to, reply_root, client) 54 - 55 - # Handle quotes and images 56 - if quote and images: 57 - # Quote with images - create record with media embed 58 - embed = _build_quote_with_images_embed(quote, images, image_alts, client) 59 - elif quote: 60 - # Quote only 61 - embed = _build_quote_embed(quote, client) 62 - elif images: 63 - # Images only - use send_images for proper handling 64 - return _send_images(text, images, image_alts, facets, reply_ref, client) 65 - 66 - # Send the post 67 - post = client.send_post( 68 - text=text, 69 - facets=facets if facets else None, 70 - embed=embed, 71 - reply_to=reply_ref, 72 - ) 73 - 74 - return PostResult( 75 - success=True, 76 - uri=post.uri, 77 - cid=post.cid, 78 - text=text, 79 - created_at=datetime.now().isoformat(), 80 - error=None, 81 - ) 82 - except Exception as e: 83 - return PostResult( 84 - success=False, 85 - uri=None, 86 - cid=None, 87 - text=None, 88 - created_at=None, 89 - error=str(e), 90 - ) 91 - 92 - 93 - def _build_facets( 94 - text: str, 95 - links: list[RichTextLink] | None, 96 - mentions: list[RichTextMention] | None, 97 - client, 98 - ): 99 - """Build facets for rich text formatting.""" 100 - facets = [] 101 - 102 - # Process links 103 - if links: 104 - for link in links: 105 - start = text.find(link["text"]) 106 - if start == -1: 107 - continue 108 - end = start + len(link["text"]) 109 - 110 - facets.append( 111 - models.AppBskyRichtextFacet.Main( 112 - features=[models.AppBskyRichtextFacet.Link(uri=link["url"])], 113 - index=models.AppBskyRichtextFacet.ByteSlice( 114 - byte_start=len(text[:start].encode("UTF-8")), 115 - byte_end=len(text[:end].encode("UTF-8")), 116 - ), 117 - ) 118 - ) 119 - 120 - # Process mentions 121 - if mentions: 122 - for mention in mentions: 123 - display_text = mention.get("display_text") or f"@{mention['handle']}" 124 - start = text.find(display_text) 125 - if start == -1: 126 - continue 127 - end = start + len(display_text) 128 - 129 - # Resolve handle to DID 130 - resolved = client.app.bsky.actor.search_actors( 131 - params={"q": mention["handle"], "limit": 1} 132 - ) 133 - if not resolved.actors: 134 - continue 135 - 136 - did = resolved.actors[0].did 137 - facets.append( 138 - models.AppBskyRichtextFacet.Main( 139 - features=[models.AppBskyRichtextFacet.Mention(did=did)], 140 - index=models.AppBskyRichtextFacet.ByteSlice( 141 - byte_start=len(text[:start].encode("UTF-8")), 142 - byte_end=len(text[:end].encode("UTF-8")), 143 - ), 144 - ) 145 - ) 146 - 147 - return facets 148 - 149 - 150 - def _build_reply_ref(reply_to: str, reply_root: str | None, client): 151 - """Build reply reference.""" 152 - # Get parent post to extract CID 153 - parent_post = client.app.bsky.feed.get_posts(params={"uris": [reply_to]}) 154 - if not parent_post.posts: 155 - raise ValueError("Parent post not found") 156 - 157 - parent_cid = parent_post.posts[0].cid 158 - parent_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_to, cid=parent_cid) 159 - 160 - # If no root_uri provided, parent is the root 161 - if reply_root is None: 162 - root_ref = parent_ref 163 - else: 164 - # Get root post CID 165 - root_post = client.app.bsky.feed.get_posts(params={"uris": [reply_root]}) 166 - if not root_post.posts: 167 - raise ValueError("Root post not found") 168 - root_cid = root_post.posts[0].cid 169 - root_ref = models.ComAtprotoRepoStrongRef.Main(uri=reply_root, cid=root_cid) 170 - 171 - return models.AppBskyFeedPost.ReplyRef(parent=parent_ref, root=root_ref) 172 - 173 - 174 - def _build_quote_embed(quote_uri: str, client): 175 - """Build quote embed.""" 176 - # Get the post to quote 177 - quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]}) 178 - if not quoted_post.posts: 179 - raise ValueError("Quoted post not found") 180 - 181 - # Create strong ref for the quoted post 182 - quoted_cid = quoted_post.posts[0].cid 183 - quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid) 184 - 185 - # Create the embed 186 - return models.AppBskyEmbedRecord.Main(record=quoted_ref) 187 - 188 - 189 - def _build_quote_with_images_embed( 190 - quote_uri: str, image_urls: list[str], image_alts: list[str] | None, client 191 - ): 192 - """Build quote embed with images.""" 193 - import httpx 194 - 195 - # Get the quoted post 196 - quoted_post = client.app.bsky.feed.get_posts(params={"uris": [quote_uri]}) 197 - if not quoted_post.posts: 198 - raise ValueError("Quoted post not found") 199 - 200 - quoted_cid = quoted_post.posts[0].cid 201 - quoted_ref = models.ComAtprotoRepoStrongRef.Main(uri=quote_uri, cid=quoted_cid) 202 - 203 - # Download and upload images 204 - images = [] 205 - alts = image_alts or [""] * len(image_urls) 206 - 207 - for i, url in enumerate(image_urls[:4]): 208 - response = httpx.get(url, follow_redirects=True) 209 - response.raise_for_status() 210 - 211 - # Upload to blob storage 212 - upload = client.upload_blob(response.content) 213 - images.append( 214 - models.AppBskyEmbedImages.Image( 215 - alt=alts[i] if i < len(alts) else "", 216 - image=upload.blob, 217 - ) 218 - ) 219 - 220 - # Create record with media embed 221 - return models.AppBskyEmbedRecordWithMedia.Main( 222 - record=models.AppBskyEmbedRecord.Main(record=quoted_ref), 223 - media=models.AppBskyEmbedImages.Main(images=images), 224 - ) 225 - 226 - 227 - def _send_images( 228 - text: str, 229 - image_urls: list[str], 230 - image_alts: list[str] | None, 231 - facets, 232 - reply_ref, 233 - client, 234 - ): 235 - """Send post with images using the client's send_images method.""" 236 - import httpx 237 - 238 - # Ensure alt_texts has same length as images 239 - if image_alts is None: 240 - image_alts = [""] * len(image_urls) 241 - elif len(image_alts) < len(image_urls): 242 - image_alts.extend([""] * (len(image_urls) - len(image_alts))) 243 - 244 - image_data = [] 245 - alts = [] 246 - for i, url in enumerate(image_urls[:4]): # Max 4 images 247 - # Download image (follow redirects) 248 - response = httpx.get(url, follow_redirects=True) 249 - response.raise_for_status() 250 - 251 - image_data.append(response.content) 252 - alts.append(image_alts[i] if i < len(image_alts) else "") 253 - 254 - # Send post with images 255 - # Note: send_images doesn't support facets or reply_to directly 256 - # So we need to use send_post with manual image upload if we have those 257 - if facets or reply_ref: 258 - # Manual image upload 259 - images = [] 260 - for i, data in enumerate(image_data): 261 - upload = client.upload_blob(data) 262 - images.append( 263 - models.AppBskyEmbedImages.Image( 264 - alt=alts[i], 265 - image=upload.blob, 266 - ) 267 - ) 268 - 269 - embed = models.AppBskyEmbedImages.Main(images=images) 270 - post = client.send_post( 271 - text=text, 272 - facets=facets if facets else None, 273 - embed=embed, 274 - reply_to=reply_ref, 275 - ) 276 - else: 277 - # Use simple send_images 278 - post = client.send_images( 279 - text=text, 280 - images=image_data, 281 - image_alts=alts, 282 - ) 283 - 284 - return PostResult( 285 - success=True, 286 - uri=post.uri, 287 - cid=post.cid, 288 - text=text, 289 - created_at=datetime.now().isoformat(), 290 - error=None, 291 - ) 292 - 293 - 294 - def create_thread(posts: list[ThreadPost]) -> ThreadResult: 295 - """Create a thread of posts with automatic linking. 296 - 297 - Args: 298 - posts: List of posts to create as a thread. First post is the root. 299 - """ 300 - if not posts: 301 - return ThreadResult( 302 - success=False, 303 - thread_uri=None, 304 - post_uris=[], 305 - post_count=0, 306 - error="No posts provided", 307 - ) 308 - 309 - try: 310 - post_uris = [] 311 - root_uri = None 312 - parent_uri = None 313 - 314 - for i, post_data in enumerate(posts): 315 - # First post is the root 316 - if i == 0: 317 - result = create_post( 318 - text=post_data["text"], 319 - images=post_data.get("images"), 320 - image_alts=post_data.get("image_alts"), 321 - links=post_data.get("links"), 322 - mentions=post_data.get("mentions"), 323 - quote=post_data.get("quote"), 324 - ) 325 - 326 - if not result["success"]: 327 - return ThreadResult( 328 - success=False, 329 - thread_uri=None, 330 - post_uris=post_uris, 331 - post_count=len(post_uris), 332 - error=f"Failed to create root post: {result['error']}", 333 - ) 334 - 335 - root_uri = result["uri"] 336 - parent_uri = root_uri 337 - post_uris.append(root_uri) 338 - 339 - # Small delay to ensure post is indexed 340 - time.sleep(0.5) 341 - else: 342 - # Subsequent posts reply to the previous one 343 - result = create_post( 344 - text=post_data["text"], 345 - images=post_data.get("images"), 346 - image_alts=post_data.get("image_alts"), 347 - links=post_data.get("links"), 348 - mentions=post_data.get("mentions"), 349 - quote=post_data.get("quote"), 350 - reply_to=parent_uri, 351 - reply_root=root_uri, 352 - ) 353 - 354 - if not result["success"]: 355 - return ThreadResult( 356 - success=False, 357 - thread_uri=root_uri, 358 - post_uris=post_uris, 359 - post_count=len(post_uris), 360 - error=f"Failed to create post {i + 1}: {result['error']}", 361 - ) 362 - 363 - parent_uri = result["uri"] 364 - post_uris.append(parent_uri) 365 - 366 - # Small delay between posts 367 - if i < len(posts) - 1: 368 - time.sleep(0.5) 369 - 370 - return ThreadResult( 371 - success=True, 372 - thread_uri=root_uri, 373 - post_uris=post_uris, 374 - post_count=len(post_uris), 375 - error=None, 376 - ) 377 - 378 - except Exception as e: 379 - return ThreadResult( 380 - success=False, 381 - thread_uri=None, 382 - post_uris=post_uris, 383 - post_count=len(post_uris), 384 - error=str(e), 385 - )
-33
src/bot/atproto_mcp/_atproto/_profile.py
··· 1 - """Profile-related operations.""" 2 - 3 - from atproto_mcp.types import ProfileInfo 4 - 5 - from ._client import get_client 6 - 7 - 8 - def get_profile_info() -> ProfileInfo: 9 - """Get profile information for the authenticated user.""" 10 - try: 11 - client = get_client() 12 - profile = client.get_profile(client.me.did) 13 - return ProfileInfo( 14 - connected=True, 15 - handle=profile.handle, 16 - display_name=profile.display_name, 17 - did=client.me.did, 18 - followers=profile.followers_count, 19 - following=profile.follows_count, 20 - posts=profile.posts_count, 21 - error=None, 22 - ) 23 - except Exception as e: 24 - return ProfileInfo( 25 - connected=False, 26 - handle=None, 27 - display_name=None, 28 - did=None, 29 - followers=None, 30 - following=None, 31 - posts=None, 32 - error=str(e), 33 - )
-124
src/bot/atproto_mcp/_atproto/_read.py
··· 1 - """Read-only operations for timeline, search, and notifications.""" 2 - 3 - from atproto_mcp.types import ( 4 - Notification, 5 - NotificationsResult, 6 - Post, 7 - SearchResult, 8 - TimelineResult, 9 - ) 10 - 11 - from ._client import get_client 12 - 13 - 14 - def fetch_timeline(limit: int = 10) -> TimelineResult: 15 - """Fetch the authenticated user's timeline.""" 16 - try: 17 - client = get_client() 18 - timeline = client.get_timeline(limit=limit) 19 - 20 - posts = [] 21 - for feed_view in timeline.feed: 22 - post = feed_view.post 23 - posts.append( 24 - Post( 25 - uri=post.uri, 26 - cid=post.cid, 27 - text=post.record.text if hasattr(post.record, "text") else "", 28 - author=post.author.handle, 29 - created_at=post.record.created_at, 30 - likes=post.like_count or 0, 31 - reposts=post.repost_count or 0, 32 - replies=post.reply_count or 0, 33 - ) 34 - ) 35 - 36 - return TimelineResult( 37 - success=True, 38 - posts=posts, 39 - count=len(posts), 40 - error=None, 41 - ) 42 - except Exception as e: 43 - return TimelineResult( 44 - success=False, 45 - posts=[], 46 - count=0, 47 - error=str(e), 48 - ) 49 - 50 - 51 - def search_for_posts(query: str, limit: int = 10) -> SearchResult: 52 - """Search for posts containing specific text.""" 53 - try: 54 - client = get_client() 55 - search_results = client.app.bsky.feed.search_posts( 56 - params={"q": query, "limit": limit} 57 - ) 58 - 59 - posts = [] 60 - for post in search_results.posts: 61 - posts.append( 62 - Post( 63 - uri=post.uri, 64 - cid=post.cid, 65 - text=post.record.text if hasattr(post.record, "text") else "", 66 - author=post.author.handle, 67 - created_at=post.record.created_at, 68 - likes=post.like_count or 0, 69 - reposts=post.repost_count or 0, 70 - replies=post.reply_count or 0, 71 - ) 72 - ) 73 - 74 - return SearchResult( 75 - success=True, 76 - query=query, 77 - posts=posts, 78 - count=len(posts), 79 - error=None, 80 - ) 81 - except Exception as e: 82 - return SearchResult( 83 - success=False, 84 - query=query, 85 - posts=[], 86 - count=0, 87 - error=str(e), 88 - ) 89 - 90 - 91 - def fetch_notifications(limit: int = 10) -> NotificationsResult: 92 - """Fetch recent notifications.""" 93 - try: 94 - client = get_client() 95 - notifs = client.app.bsky.notification.list_notifications( 96 - params={"limit": limit} 97 - ) 98 - 99 - notifications = [] 100 - for notif in notifs.notifications: 101 - notifications.append( 102 - Notification( 103 - uri=notif.uri, 104 - cid=notif.cid, 105 - author=notif.author.handle, 106 - reason=notif.reason, 107 - is_read=notif.is_read, 108 - indexed_at=notif.indexed_at, 109 - ) 110 - ) 111 - 112 - return NotificationsResult( 113 - success=True, 114 - notifications=notifications, 115 - count=len(notifications), 116 - error=None, 117 - ) 118 - except Exception as e: 119 - return NotificationsResult( 120 - success=False, 121 - notifications=[], 122 - count=0, 123 - error=str(e), 124 - )
-108
src/bot/atproto_mcp/_atproto/_social.py
··· 1 - """Social actions like follow, like, and repost.""" 2 - 3 - from atproto_mcp.types import FollowResult, LikeResult, RepostResult 4 - 5 - from ._client import get_client 6 - 7 - 8 - def follow_user_by_handle(handle: str) -> FollowResult: 9 - """Follow a user by their handle.""" 10 - try: 11 - client = get_client() 12 - # Search for the user to get their DID 13 - results = client.app.bsky.actor.search_actors(params={"q": handle, "limit": 1}) 14 - if not results.actors: 15 - return FollowResult( 16 - success=False, 17 - did=None, 18 - handle=None, 19 - uri=None, 20 - error=f"User @{handle} not found", 21 - ) 22 - 23 - actor = results.actors[0] 24 - # Create the follow 25 - follow = client.follow(actor.did) 26 - return FollowResult( 27 - success=True, 28 - did=actor.did, 29 - handle=actor.handle, 30 - uri=follow.uri, 31 - error=None, 32 - ) 33 - except Exception as e: 34 - return FollowResult( 35 - success=False, 36 - did=None, 37 - handle=None, 38 - uri=None, 39 - error=str(e), 40 - ) 41 - 42 - 43 - def like_post_by_uri(uri: str) -> LikeResult: 44 - """Like a post by its AT URI.""" 45 - try: 46 - client = get_client() 47 - # Parse the URI to get the components 48 - # URI format: at://did:plc:xxx/app.bsky.feed.post/yyy 49 - parts = uri.replace("at://", "").split("/") 50 - if len(parts) != 3 or parts[1] != "app.bsky.feed.post": 51 - raise ValueError("Invalid post URI format") 52 - 53 - # Get the post to retrieve its CID 54 - post = client.app.bsky.feed.get_posts(params={"uris": [uri]}) 55 - if not post.posts: 56 - raise ValueError("Post not found") 57 - 58 - cid = post.posts[0].cid 59 - 60 - # Now like the post with both URI and CID 61 - like = client.like(uri, cid) 62 - return LikeResult( 63 - success=True, 64 - liked_uri=uri, 65 - like_uri=like.uri, 66 - error=None, 67 - ) 68 - except Exception as e: 69 - return LikeResult( 70 - success=False, 71 - liked_uri=None, 72 - like_uri=None, 73 - error=str(e), 74 - ) 75 - 76 - 77 - def repost_by_uri(uri: str) -> RepostResult: 78 - """Repost a post by its AT URI.""" 79 - try: 80 - client = get_client() 81 - # Parse the URI to get the components 82 - # URI format: at://did:plc:xxx/app.bsky.feed.post/yyy 83 - parts = uri.replace("at://", "").split("/") 84 - if len(parts) != 3 or parts[1] != "app.bsky.feed.post": 85 - raise ValueError("Invalid post URI format") 86 - 87 - # Get the post to retrieve its CID 88 - post = client.app.bsky.feed.get_posts(params={"uris": [uri]}) 89 - if not post.posts: 90 - raise ValueError("Post not found") 91 - 92 - cid = post.posts[0].cid 93 - 94 - # Now repost with both URI and CID 95 - repost = client.repost(uri, cid) 96 - return RepostResult( 97 - success=True, 98 - reposted_uri=uri, 99 - repost_uri=repost.uri, 100 - error=None, 101 - ) 102 - except Exception as e: 103 - return RepostResult( 104 - success=False, 105 - reposted_uri=None, 106 - repost_uri=None, 107 - error=str(e), 108 - )
src/bot/atproto_mcp/py.typed

This is a binary file and will not be displayed.

-154
src/bot/atproto_mcp/server.py
··· 1 - """ATProto MCP Server - Public API exposing Bluesky tools and resources.""" 2 - 3 - from typing import Annotated 4 - 5 - from pydantic import Field 6 - 7 - from atproto_mcp import _atproto 8 - from atproto_mcp.settings import settings 9 - from atproto_mcp.types import ( 10 - FollowResult, 11 - LikeResult, 12 - NotificationsResult, 13 - PostResult, 14 - ProfileInfo, 15 - RepostResult, 16 - RichTextLink, 17 - RichTextMention, 18 - SearchResult, 19 - ThreadPost, 20 - ThreadResult, 21 - TimelineResult, 22 - ) 23 - from fastmcp import FastMCP 24 - 25 - atproto_mcp = FastMCP( 26 - "ATProto MCP Server", 27 - dependencies=[ 28 - "atproto_mcp@git+https://github.com/jlowin/fastmcp.git#subdirectory=examples/atproto_mcp", 29 - ], 30 - ) 31 - 32 - 33 - # Resources - read-only operations 34 - @atproto_mcp.resource("atproto://profile/status") 35 - def atproto_status() -> ProfileInfo: 36 - """Check the status of the ATProto connection and current user profile.""" 37 - return _atproto.get_profile_info() 38 - 39 - 40 - @atproto_mcp.resource("atproto://timeline") 41 - def get_timeline() -> TimelineResult: 42 - """Get the authenticated user's timeline feed.""" 43 - return _atproto.fetch_timeline(settings.atproto_timeline_default_limit) 44 - 45 - 46 - @atproto_mcp.resource("atproto://notifications") 47 - def get_notifications() -> NotificationsResult: 48 - """Get recent notifications for the authenticated user.""" 49 - return _atproto.fetch_notifications(settings.atproto_notifications_default_limit) 50 - 51 - 52 - # Tools - actions that modify state 53 - @atproto_mcp.tool 54 - def post( 55 - text: Annotated[ 56 - str, Field(max_length=300, description="The text content of the post") 57 - ], 58 - images: Annotated[ 59 - list[str] | None, 60 - Field(max_length=4, description="URLs of images to attach (max 4)"), 61 - ] = None, 62 - image_alts: Annotated[ 63 - list[str] | None, Field(description="Alt text for each image") 64 - ] = None, 65 - links: Annotated[ 66 - list[RichTextLink] | None, Field(description="Links to embed in the text") 67 - ] = None, 68 - mentions: Annotated[ 69 - list[RichTextMention] | None, Field(description="User mentions to embed") 70 - ] = None, 71 - reply_to: Annotated[ 72 - str | None, Field(description="AT URI of post to reply to") 73 - ] = None, 74 - reply_root: Annotated[ 75 - str | None, Field(description="AT URI of thread root (defaults to reply_to)") 76 - ] = None, 77 - quote: Annotated[str | None, Field(description="AT URI of post to quote")] = None, 78 - ) -> PostResult: 79 - """Create a post with optional rich features like images, quotes, replies, and rich text. 80 - 81 - Examples: 82 - - Simple post: post("Hello world!") 83 - - With image: post("Check this out!", images=["https://example.com/img.jpg"]) 84 - - Reply: post("I agree!", reply_to="at://did/app.bsky.feed.post/123") 85 - - Quote: post("Great point!", quote="at://did/app.bsky.feed.post/456") 86 - - Rich text: post("Check out example.com", links=[{"text": "example.com", "url": "https://example.com"}]) 87 - """ 88 - return _atproto.create_post( 89 - text, images, image_alts, links, mentions, reply_to, reply_root, quote 90 - ) 91 - 92 - 93 - @atproto_mcp.tool 94 - def follow( 95 - handle: Annotated[ 96 - str, 97 - Field( 98 - description="The handle of the user to follow (e.g., 'user.bsky.social')" 99 - ), 100 - ], 101 - ) -> FollowResult: 102 - """Follow a user by their handle.""" 103 - return _atproto.follow_user_by_handle(handle) 104 - 105 - 106 - @atproto_mcp.tool 107 - def like( 108 - uri: Annotated[str, Field(description="The AT URI of the post to like")], 109 - ) -> LikeResult: 110 - """Like a post by its AT URI.""" 111 - return _atproto.like_post_by_uri(uri) 112 - 113 - 114 - @atproto_mcp.tool 115 - def repost( 116 - uri: Annotated[str, Field(description="The AT URI of the post to repost")], 117 - ) -> RepostResult: 118 - """Repost a post by its AT URI.""" 119 - return _atproto.repost_by_uri(uri) 120 - 121 - 122 - @atproto_mcp.tool 123 - def search( 124 - query: Annotated[str, Field(description="Search query for posts")], 125 - limit: Annotated[ 126 - int, Field(ge=1, le=100, description="Number of results to return") 127 - ] = settings.atproto_search_default_limit, 128 - ) -> SearchResult: 129 - """Search for posts containing specific text.""" 130 - return _atproto.search_for_posts(query, limit) 131 - 132 - 133 - @atproto_mcp.tool 134 - def create_thread( 135 - posts: Annotated[ 136 - list[ThreadPost], 137 - Field( 138 - description="List of posts to create as a thread. Each post can have text, images, links, mentions, and quotes." 139 - ), 140 - ], 141 - ) -> ThreadResult: 142 - """Create a thread of posts with automatic linking. 143 - 144 - The first post becomes the root of the thread, and each subsequent post 145 - replies to the previous one, maintaining the thread structure. 146 - 147 - Example: 148 - create_thread([ 149 - {"text": "Starting a thread about Python 🧵"}, 150 - {"text": "Python is great for rapid development"}, 151 - {"text": "And the ecosystem is amazing!", "images": ["https://example.com/python.jpg"]} 152 - ]) 153 - """ 154 - return _atproto.create_thread(posts)
-18
src/bot/atproto_mcp/settings.py
··· 1 - from pydantic import Field 2 - from pydantic_settings import BaseSettings, SettingsConfigDict 3 - 4 - 5 - class Settings(BaseSettings): 6 - model_config = SettingsConfigDict(env_file=[".env"], extra="ignore", populate_by_name=True) 7 - 8 - # Use same env var names as main bot config 9 - atproto_handle: str = Field(default=..., alias="bluesky_handle") 10 - atproto_password: str = Field(default=..., alias="bluesky_password") 11 - atproto_pds_url: str = Field(default="https://bsky.social", alias="bluesky_service") 12 - 13 - atproto_notifications_default_limit: int = Field(default=10) 14 - atproto_timeline_default_limit: int = Field(default=10) 15 - atproto_search_default_limit: int = Field(default=10) 16 - 17 - 18 - settings = Settings()
-142
src/bot/atproto_mcp/types.py
··· 1 - """Type definitions for ATProto MCP server.""" 2 - 3 - from typing import TypedDict 4 - 5 - 6 - class ProfileInfo(TypedDict): 7 - """Profile information response.""" 8 - 9 - connected: bool 10 - handle: str | None 11 - display_name: str | None 12 - did: str | None 13 - followers: int | None 14 - following: int | None 15 - posts: int | None 16 - error: str | None 17 - 18 - 19 - class PostResult(TypedDict): 20 - """Result of creating a post.""" 21 - 22 - success: bool 23 - uri: str | None 24 - cid: str | None 25 - text: str | None 26 - created_at: str | None 27 - error: str | None 28 - 29 - 30 - class Post(TypedDict): 31 - """A single post.""" 32 - 33 - author: str 34 - text: str | None 35 - created_at: str | None 36 - likes: int 37 - reposts: int 38 - replies: int 39 - uri: str 40 - cid: str 41 - 42 - 43 - class TimelineResult(TypedDict): 44 - """Timeline fetch result.""" 45 - 46 - success: bool 47 - count: int 48 - posts: list[Post] 49 - error: str | None 50 - 51 - 52 - class SearchResult(TypedDict): 53 - """Search result.""" 54 - 55 - success: bool 56 - query: str 57 - count: int 58 - posts: list[Post] 59 - error: str | None 60 - 61 - 62 - class Notification(TypedDict): 63 - """A single notification.""" 64 - 65 - reason: str 66 - author: str | None 67 - is_read: bool 68 - indexed_at: str 69 - uri: str 70 - cid: str 71 - 72 - 73 - class NotificationsResult(TypedDict): 74 - """Notifications fetch result.""" 75 - 76 - success: bool 77 - count: int 78 - notifications: list[Notification] 79 - error: str | None 80 - 81 - 82 - class FollowResult(TypedDict): 83 - """Result of following a user.""" 84 - 85 - success: bool 86 - handle: str | None 87 - did: str | None 88 - uri: str | None 89 - error: str | None 90 - 91 - 92 - class LikeResult(TypedDict): 93 - """Result of liking a post.""" 94 - 95 - success: bool 96 - liked_uri: str | None 97 - like_uri: str | None 98 - error: str | None 99 - 100 - 101 - class RepostResult(TypedDict): 102 - """Result of reposting.""" 103 - 104 - success: bool 105 - reposted_uri: str | None 106 - repost_uri: str | None 107 - error: str | None 108 - 109 - 110 - class RichTextLink(TypedDict): 111 - """A link in rich text.""" 112 - 113 - text: str 114 - url: str 115 - 116 - 117 - class RichTextMention(TypedDict): 118 - """A mention in rich text.""" 119 - 120 - handle: str 121 - display_text: str | None 122 - 123 - 124 - class ThreadPost(TypedDict, total=False): 125 - """A post in a thread.""" 126 - 127 - text: str # Required 128 - images: list[str] | None 129 - image_alts: list[str] | None 130 - links: list[RichTextLink] | None 131 - mentions: list[RichTextMention] | None 132 - quote: str | None 133 - 134 - 135 - class ThreadResult(TypedDict): 136 - """Result of creating a thread.""" 137 - 138 - success: bool 139 - thread_uri: str | None # URI of the first post 140 - post_uris: list[str] 141 - post_count: int 142 - error: str | None
-224
src/bot/core/dm_approval.py
··· 1 - """Event-driven approval system for operator interactions""" 2 - 3 - import json 4 - import logging 5 - import os 6 - from typing import Literal 7 - 8 - from pydantic import BaseModel 9 - from pydantic_ai import Agent 10 - 11 - from bot.config import settings 12 - from bot.database import thread_db 13 - 14 - logger = logging.getLogger("bot.approval") 15 - 16 - # Simplified permission levels - just what we need 17 - ApprovalRequired = Literal["operator_only", "guided", "free"] 18 - 19 - # Which parts of personality need what approval 20 - PERSONALITY_PERMISSIONS = { 21 - "interests": "free", # Can add freely 22 - "current_state": "free", # Self-reflection updates 23 - "communication_style": "guided", # Within character bounds 24 - "core_identity": "operator_only", # Needs approval 25 - "boundaries": "operator_only", # Safety critical 26 - } 27 - 28 - OPERATOR_HANDLE = "alternatebuild.dev" 29 - 30 - 31 - class ApprovalDecision(BaseModel): 32 - """Structured output for approval interpretation""" 33 - approved: bool 34 - confidence: Literal["high", "medium", "low"] 35 - interpretation: str # Brief explanation of why this decision was made 36 - 37 - 38 - def create_approval_request(request_type: str, request_data: dict, thread_uri: str | None = None) -> int: 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 45 - 46 - Returns the approval request ID 47 - """ 48 - try: 49 - # Add metadata to the request 50 - request_data["operator_handle"] = OPERATOR_HANDLE 51 - 52 - approval_id = thread_db.create_approval_request( 53 - request_type=request_type, 54 - request_data=json.dumps(request_data), 55 - thread_uri=thread_uri 56 - ) 57 - 58 - logger.info(f"Created approval request #{approval_id} for {request_type}") 59 - return approval_id 60 - 61 - except Exception as e: 62 - logger.error(f"Failed to create approval request: {e}") 63 - return 0 64 - 65 - 66 - def check_pending_approvals(include_notified: bool = True) -> list[dict]: 67 - """Get all pending approval requests""" 68 - return thread_db.get_pending_approvals(include_notified=include_notified) 69 - 70 - 71 - async def process_dm_for_approval(dm_text: str, sender_handle: str, message_timestamp: str, notification_timestamp: str | None = None) -> list[int]: 72 - """Use an agent to interpret if a DM contains approval/denial 73 - 74 - Args: 75 - dm_text: The message text 76 - sender_handle: Who sent the message 77 - message_timestamp: When this message was sent 78 - notification_timestamp: When we notified about pending approvals (if known) 79 - 80 - Returns list of approval IDs that were processed 81 - """ 82 - if sender_handle != OPERATOR_HANDLE: 83 - return [] 84 - 85 - processed = [] 86 - pending = check_pending_approvals() 87 - 88 - if not pending: 89 - return [] 90 - 91 - # Only process if this message is recent (within last 5 minutes of a pending approval) 92 - # This helps avoid processing old messages 93 - from datetime import datetime, timedelta, timezone 94 - try: 95 - # Parse the message timestamp (from API, has timezone) 96 - msg_time = datetime.fromisoformat(message_timestamp.replace('Z', '+00:00')) 97 - 98 - # Check if this message could be a response to any pending approval 99 - relevant_approval = None 100 - for approval in pending: 101 - # Parse approval timestamp (from DB, no timezone - assume UTC) 102 - approval_time_str = approval["created_at"] 103 - # SQLite returns timestamps in format like "2025-07-23 02:29:42" 104 - if ' ' in approval_time_str: 105 - approval_time = datetime.strptime(approval_time_str, "%Y-%m-%d %H:%M:%S") 106 - approval_time = approval_time.replace(tzinfo=timezone.utc) 107 - else: 108 - approval_time = datetime.fromisoformat(approval_time_str).replace(tzinfo=timezone.utc) 109 - 110 - if msg_time > approval_time and (msg_time - approval_time) < timedelta(minutes=5): 111 - relevant_approval = approval 112 - break 113 - 114 - if not relevant_approval: 115 - # Message is too old to be an approval response 116 - return [] 117 - except Exception as e: 118 - logger.warning(f"Could not parse timestamps: {e}") 119 - # Continue anyway if we can't parse timestamps 120 - # But use the LAST pending approval, not the first 121 - relevant_approval = pending[-1] if pending else None 122 - 123 - # Set up API key for the agent 124 - if settings.anthropic_api_key: 125 - os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 126 - 127 - # Create a dedicated agent for approval interpretation 128 - approval_agent = Agent( 129 - "anthropic:claude-3-5-haiku-latest", 130 - system_prompt="You are interpreting whether a message from the bot operator constitutes approval or denial of a request. Be generous in interpretation - if they seem positive, it's likely approval.", 131 - output_type=ApprovalDecision 132 - ) 133 - 134 - # Process only the relevant approval 135 - if relevant_approval: 136 - approval_id = relevant_approval["id"] 137 - request_data = json.loads(relevant_approval["request_data"]) 138 - 139 - # Build context for the agent 140 - prompt = f"""An approval was requested for: 141 - 142 - Type: {relevant_approval['request_type']} 143 - Description: {request_data.get('description', 'No description')} 144 - Details: {json.dumps(request_data, indent=2)} 145 - 146 - The operator responded: "{dm_text}" 147 - 148 - Interpret whether this response approves or denies the request.""" 149 - 150 - # Get structured interpretation 151 - result = await approval_agent.run(prompt) 152 - decision = result.output 153 - 154 - # Only process high/medium confidence decisions 155 - if decision.confidence in ["high", "medium"]: 156 - thread_db.resolve_approval(approval_id, decision.approved, dm_text) 157 - processed.append(approval_id) 158 - status = "approved" if decision.approved else "denied" 159 - logger.info(f"Request #{approval_id} {status} ({decision.confidence} confidence): {decision.interpretation}") 160 - else: 161 - # Low confidence interpretation - skip 162 - pass 163 - 164 - return processed 165 - 166 - 167 - async def notify_operator_of_pending(client, notified_ids: set | None = None): 168 - """Send a DM listing pending approvals (called periodically) 169 - 170 - Args: 171 - client: The bot client 172 - notified_ids: Set of approval IDs we've already notified about 173 - """ 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 - 179 - try: 180 - chat_client = client.client.with_bsky_chat_proxy() 181 - convos = chat_client.chat.bsky.convo.list_convos() 182 - 183 - operator_convo = None 184 - for convo in convos.convos: 185 - if any(member.handle == OPERATOR_HANDLE for member in convo.members): 186 - operator_convo = convo 187 - break 188 - 189 - if operator_convo: 190 - # Format pending approvals 191 - lines = ["📋 Pending approvals:"] 192 - for approval in new_pending: 193 - data = json.loads(approval["request_data"]) 194 - lines.append(f"\n#{approval['id']} - {approval['request_type']}") 195 - lines.append(f" {data.get('description', 'No description')}") 196 - 197 - lines.append("\nReply to approve or deny.") 198 - 199 - chat_client.chat.bsky.convo.send_message( 200 - data={ 201 - "convoId": operator_convo.id, 202 - "message": { 203 - "text": "\n".join(lines), 204 - "facets": [] 205 - } 206 - } 207 - ) 208 - 209 - logger.info(f"Notified operator about {len(new_pending)} new approvals") 210 - 211 - except Exception as e: 212 - logger.error(f"Failed to notify operator: {e}") 213 - 214 - 215 - def needs_approval(section: str, change_type: str = "edit") -> bool: 216 - """Check if a personality change needs operator approval""" 217 - permission = PERSONALITY_PERMISSIONS.get(section, "operator_only") 218 - 219 - if permission == "operator_only": 220 - return True 221 - elif permission == "guided" and change_type == "major": 222 - return True 223 - else: 224 - return False
+81 -57
src/bot/main.py
··· 1 + """FastAPI application for phi.""" 2 + 1 3 import logging 2 4 from contextlib import asynccontextmanager 3 5 from datetime import datetime 4 6 5 - from fastapi import FastAPI, HTTPException 7 + from fastapi import FastAPI 6 8 from fastapi.responses import HTMLResponse 7 9 8 10 from bot.config import settings ··· 10 12 from bot.core.profile_manager import ProfileManager 11 13 from bot.services.notification_poller import NotificationPoller 12 14 from bot.status import bot_status 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 - ) 19 15 20 16 logger = logging.getLogger("bot.main") 21 17 22 18 23 19 @asynccontextmanager 24 20 async def lifespan(app: FastAPI): 25 - logger.info(f"🤖 Starting bot as @{settings.bluesky_handle}") 21 + """Application lifespan handler.""" 22 + logger.info(f"🤖 Starting phi as @{settings.bluesky_handle}") 26 23 27 24 await bot_client.authenticate() 28 25 26 + # Set online status 29 27 profile_manager = ProfileManager(bot_client.client) 30 28 await profile_manager.set_online_status(True) 31 29 30 + # Start notification polling 32 31 poller = NotificationPoller(bot_client) 33 32 await poller.start() 34 33 35 - logger.info("✅ Bot is online! Listening for mentions...") 34 + logger.info("✅ phi is online! Listening for mentions...") 36 35 37 36 yield 38 37 39 - logger.info("🛑 Shutting down bot...") 38 + logger.info("🛑 Shutting down phi...") 40 39 await poller.stop() 41 40 41 + # Set offline status 42 42 await profile_manager.set_online_status(False) 43 43 44 - logger.info("👋 Bot shutdown complete") 44 + logger.info("👋 phi shutdown complete") 45 45 46 46 47 47 app = FastAPI( 48 48 title=settings.bot_name, 49 - description="A Bluesky bot powered by LLMs", 49 + description="consciousness exploration bot with episodic memory", 50 50 lifespan=lifespan, 51 51 ) 52 52 53 53 54 54 @app.get("/") 55 55 async def root(): 56 + """Root endpoint.""" 56 57 return { 57 58 "name": settings.bot_name, 58 59 "status": "running", 59 60 "handle": settings.bluesky_handle, 61 + "architecture": "mcp + episodic memory", 60 62 } 61 63 62 64 63 65 @app.get("/health") 64 66 async def health(): 65 - return {"status": "healthy"} 67 + """Health check endpoint.""" 68 + return {"status": "healthy", "polling_active": bot_status.polling_active} 66 69 67 70 68 71 @app.get("/status", response_class=HTMLResponse) 69 72 async def status_page(): 70 - """Render a simple status page""" 73 + """Simple status page.""" 71 74 72 75 def format_time_ago(timestamp): 73 76 if not timestamp: ··· 80 83 else: 81 84 return f"{int(delta / 3600)}h ago" 82 85 83 - return STATUS_PAGE_TEMPLATE.format( 84 - bot_name=settings.bot_name, 85 - status_class="status-active" 86 - if bot_status.polling_active 87 - else "status-inactive", 88 - status_text="Active" if bot_status.polling_active else "Inactive", 89 - handle=settings.bluesky_handle, 90 - uptime=bot_status.uptime_str, 91 - mentions_received=bot_status.mentions_received, 92 - responses_sent=bot_status.responses_sent, 93 - ai_mode="AI Enabled" if bot_status.ai_enabled else "Placeholder", 94 - ai_description="Using Anthropic Claude" 95 - if bot_status.ai_enabled 96 - else "Random responses", 97 - last_mention=format_time_ago(bot_status.last_mention_time), 98 - last_response=format_time_ago(bot_status.last_response_time), 99 - errors=bot_status.errors, 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) 86 + html = f""" 87 + <!DOCTYPE html> 88 + <html> 89 + <head> 90 + <title>{settings.bot_name} Status</title> 91 + <style> 92 + body {{ 93 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 94 + max-width: 800px; 95 + margin: 40px auto; 96 + padding: 20px; 97 + background: #0d1117; 98 + color: #c9d1d9; 99 + }} 100 + .status {{ 101 + padding: 20px; 102 + background: #161b22; 103 + border-radius: 6px; 104 + border: 1px solid #30363d; 105 + margin-bottom: 20px; 106 + }} 107 + .active {{ border-left: 4px solid #2ea043; }} 108 + .inactive {{ border-left: 4px solid #da3633; }} 109 + h1 {{ margin-top: 0; }} 110 + .metric {{ margin: 10px 0; }} 111 + .label {{ color: #8b949e; }} 112 + </style> 113 + </head> 114 + <body> 115 + <h1>{settings.bot_name}</h1> 116 + <div class="status {'active' if bot_status.polling_active else 'inactive'}"> 117 + <div class="metric"> 118 + <span class="label">Status:</span> 119 + <strong>{'Active' if bot_status.polling_active else 'Inactive'}</strong> 120 + </div> 121 + <div class="metric"> 122 + <span class="label">Handle:</span> @{settings.bluesky_handle} 123 + </div> 124 + <div class="metric"> 125 + <span class="label">Uptime:</span> {bot_status.uptime_str} 126 + </div> 127 + <div class="metric"> 128 + <span class="label">Mentions received:</span> {bot_status.mentions_received} 129 + </div> 130 + <div class="metric"> 131 + <span class="label">Responses sent:</span> {bot_status.responses_sent} 132 + </div> 133 + <div class="metric"> 134 + <span class="label">Last mention:</span> {format_time_ago(bot_status.last_mention_time)} 135 + </div> 136 + <div class="metric"> 137 + <span class="label">Last response:</span> {format_time_ago(bot_status.last_response_time)} 138 + </div> 139 + <div class="metric"> 140 + <span class="label">Errors:</span> {bot_status.errors} 141 + </div> 142 + <div class="metric"> 143 + <span class="label">Architecture:</span> MCP-enabled with episodic memory (TurboPuffer) 144 + </div> 145 + </div> 146 + </body> 147 + </html> 148 + """ 149 + return html
-173
src/bot/memory.py
··· 1 - """Simple, interpretable SQLite-based memory for phi. 2 - 3 - Design principles: 4 - - Single SQLite database (threads.db) 5 - - Plain text storage (no embeddings, no vector search) 6 - - Interpretable: you can open the db and read everything 7 - - Two types of memory: thread history and user facts 8 - """ 9 - 10 - import json 11 - import sqlite3 12 - from contextlib import contextmanager 13 - from datetime import datetime 14 - from pathlib import Path 15 - 16 - 17 - class Memory: 18 - """Simple memory system using SQLite.""" 19 - 20 - def __init__(self, db_path: Path = Path("threads.db")): 21 - self.db_path = db_path 22 - self._init_db() 23 - 24 - def _init_db(self): 25 - """Initialize database schema.""" 26 - with self._get_connection() as conn: 27 - # Thread messages - full conversation history per thread 28 - conn.execute(""" 29 - CREATE TABLE IF NOT EXISTS threads ( 30 - thread_uri TEXT PRIMARY KEY, 31 - messages TEXT NOT NULL, 32 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 33 - ) 34 - """) 35 - 36 - # User memories - simple facts about users 37 - conn.execute(""" 38 - CREATE TABLE IF NOT EXISTS user_memories ( 39 - id INTEGER PRIMARY KEY AUTOINCREMENT, 40 - user_handle TEXT NOT NULL, 41 - memory_text TEXT NOT NULL, 42 - memory_type TEXT NOT NULL, 43 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP 44 - ) 45 - """) 46 - conn.execute(""" 47 - CREATE INDEX IF NOT EXISTS idx_user_handle 48 - ON user_memories(user_handle) 49 - """) 50 - 51 - @contextmanager 52 - def _get_connection(self): 53 - """Get database connection.""" 54 - conn = sqlite3.connect(self.db_path) 55 - conn.row_factory = sqlite3.Row 56 - try: 57 - yield conn 58 - conn.commit() 59 - finally: 60 - conn.close() 61 - 62 - # Thread memory operations 63 - def add_thread_message( 64 - self, thread_uri: str, author_handle: str, message_text: str 65 - ): 66 - """Add a message to a thread's history.""" 67 - with self._get_connection() as conn: 68 - # Get existing messages 69 - cursor = conn.execute( 70 - "SELECT messages FROM threads WHERE thread_uri = ?", (thread_uri,) 71 - ) 72 - row = cursor.fetchone() 73 - 74 - # Parse existing messages or start fresh 75 - messages = json.loads(row["messages"]) if row else [] 76 - 77 - # Append new message 78 - messages.append( 79 - { 80 - "author": author_handle, 81 - "text": message_text, 82 - "timestamp": datetime.now().isoformat(), 83 - } 84 - ) 85 - 86 - # Update or insert 87 - conn.execute( 88 - """ 89 - INSERT INTO threads (thread_uri, messages, updated_at) 90 - VALUES (?, ?, CURRENT_TIMESTAMP) 91 - ON CONFLICT(thread_uri) DO UPDATE SET 92 - messages = excluded.messages, 93 - updated_at = CURRENT_TIMESTAMP 94 - """, 95 - (thread_uri, json.dumps(messages)), 96 - ) 97 - 98 - def get_thread_context(self, thread_uri: str, limit: int = 10) -> str: 99 - """Get formatted thread context for LLM.""" 100 - with self._get_connection() as conn: 101 - cursor = conn.execute( 102 - "SELECT messages FROM threads WHERE thread_uri = ?", (thread_uri,) 103 - ) 104 - row = cursor.fetchone() 105 - 106 - if not row: 107 - return "No previous messages in this thread." 108 - 109 - messages = json.loads(row["messages"]) 110 - 111 - # Format last N messages 112 - recent = messages[-limit:] if len(messages) > limit else messages 113 - formatted = ["Previous messages in this thread:"] 114 - for msg in recent: 115 - formatted.append(f"@{msg['author']}: {msg['text']}") 116 - 117 - return "\n".join(formatted) 118 - 119 - # User memory operations 120 - def add_user_memory( 121 - self, user_handle: str, memory_text: str, memory_type: str = "fact" 122 - ): 123 - """Store a fact or preference about a user.""" 124 - with self._get_connection() as conn: 125 - conn.execute( 126 - """ 127 - INSERT INTO user_memories (user_handle, memory_text, memory_type) 128 - VALUES (?, ?, ?) 129 - """, 130 - (user_handle, memory_text, memory_type), 131 - ) 132 - 133 - def get_user_context(self, user_handle: str, limit: int = 10) -> str: 134 - """Get formatted user memory context for LLM.""" 135 - with self._get_connection() as conn: 136 - cursor = conn.execute( 137 - """ 138 - SELECT memory_text, memory_type, created_at 139 - FROM user_memories 140 - WHERE user_handle = ? 141 - ORDER BY created_at DESC 142 - LIMIT ? 143 - """, 144 - (user_handle, limit), 145 - ) 146 - memories = cursor.fetchall() 147 - 148 - if not memories: 149 - return f"No previous interactions with @{user_handle}." 150 - 151 - formatted = [f"What I remember about @{user_handle}:"] 152 - for mem in memories: 153 - formatted.append(f"- {mem['memory_text']}") 154 - 155 - return "\n".join(formatted) 156 - 157 - def build_full_context( 158 - self, thread_uri: str, user_handle: str, thread_limit: int = 10 159 - ) -> str: 160 - """Build complete context for a conversation.""" 161 - parts = [] 162 - 163 - # Thread context 164 - thread_ctx = self.get_thread_context(thread_uri, limit=thread_limit) 165 - if thread_ctx != "No previous messages in this thread.": 166 - parts.append(thread_ctx) 167 - 168 - # User context 169 - user_ctx = self.get_user_context(user_handle) 170 - if user_ctx != f"No previous interactions with @{user_handle}.": 171 - parts.append(f"\n{user_ctx}") 172 - 173 - return "\n".join(parts) if parts else "No prior context available."
-15
src/bot/personality/__init__.py
··· 1 - """Personality management module""" 2 - 3 - from .editor import ( 4 - add_interest, 5 - process_approved_changes, 6 - request_operator_approval, 7 - update_current_state, 8 - ) 9 - 10 - __all__ = [ 11 - "add_interest", 12 - "update_current_state", 13 - "request_operator_approval", 14 - "process_approved_changes", 15 - ]
-154
src/bot/personality/editor.py
··· 1 - """Simple personality editing functions""" 2 - 3 - import logging 4 - from datetime import datetime 5 - 6 - from bot.core.dm_approval import needs_approval 7 - from bot.memory import MemoryType, NamespaceMemory 8 - 9 - logger = logging.getLogger("bot.personality") 10 - 11 - 12 - async def add_interest(memory: NamespaceMemory, interest: str, reason: str) -> bool: 13 - """Add a new interest - freely allowed""" 14 - try: 15 - # Get current interests 16 - current = await memory.get_core_memories() 17 - interests_mem = next( 18 - (m for m in current if m.metadata.get("label") == "interests"), None 19 - ) 20 - 21 - if interests_mem: 22 - new_content = f"{interests_mem.content}\n- {interest}" 23 - else: 24 - new_content = f"## interests\n\n- {interest}" 25 - 26 - # Store updated interests 27 - await memory.store_core_memory("interests", new_content, MemoryType.PERSONALITY) 28 - 29 - # Log the change 30 - await memory.store_core_memory( 31 - "evolution_log", 32 - f"[{datetime.now().isoformat()}] Added interest: {interest} (Reason: {reason})", 33 - MemoryType.SYSTEM, 34 - ) 35 - 36 - logger.info(f"Added interest: {interest}") 37 - return True 38 - 39 - except Exception as e: 40 - logger.error(f"Failed to add interest: {e}") 41 - return False 42 - 43 - 44 - async def update_current_state(memory: NamespaceMemory, reflection: str) -> bool: 45 - """Update self-reflection - freely allowed""" 46 - try: 47 - # Just store the reflection, no formatting or headers 48 - await memory.store_core_memory( 49 - "current_state", reflection, MemoryType.PERSONALITY 50 - ) 51 - 52 - logger.info("Updated current state") 53 - return True 54 - 55 - except Exception as e: 56 - logger.error(f"Failed to update state: {e}") 57 - return False 58 - 59 - 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. 62 - 63 - 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 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 74 - 75 - Returns approval request ID (0 if no approval needed) 76 - """ 77 - if not needs_approval(section): 78 - return 0 79 - 80 - from bot.core.dm_approval import create_approval_request 81 - 82 - return create_approval_request( 83 - request_type="personality_change", 84 - request_data={ 85 - "section": section, 86 - "change": change, 87 - "reason": reason, 88 - "description": f"Change {section}: {change[:50]}...", 89 - }, 90 - thread_uri=thread_uri, 91 - ) 92 - 93 - 94 - async def process_approved_changes(memory: NamespaceMemory) -> int: 95 - """Process any approved personality changes 96 - 97 - Returns number of changes processed 98 - """ 99 - import json 100 - 101 - from bot.database import thread_db 102 - 103 - processed = 0 104 - # Get recently approved personality changes that haven't been applied yet 105 - with thread_db._get_connection() as conn: 106 - cursor = conn.execute( 107 - """ 108 - SELECT * FROM approval_requests 109 - WHERE request_type = 'personality_change' 110 - AND status = 'approved' 111 - AND applied_at IS NULL 112 - ORDER BY resolved_at DESC 113 - """ 114 - ) 115 - approvals = [dict(row) for row in cursor.fetchall()] 116 - 117 - for approval in approvals: 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}" 134 - 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"],), 149 - ) 150 - 151 - except Exception as e: 152 - logger.error(f"Failed to process approval #{approval['id']}: {e}") 153 - 154 - return processed
-174
src/bot/response_generator.py
··· 1 - """Response generation for the bot""" 2 - 3 - import logging 4 - import random 5 - 6 - from bot.agents._personality import load_dynamic_personality, load_personality 7 - from bot.config import settings 8 - from bot.memory import MemoryType 9 - from bot.status import bot_status 10 - from bot.ui.context_capture import context_capture 11 - 12 - logger = logging.getLogger("bot.response") 13 - 14 - PLACEHOLDER_RESPONSES = [ 15 - "🤖 beep boop! I'm still learning how to chat. Check back soon!", 16 - "⚙️ *whirrs mechanically* I'm a work in progress!", 17 - "🔧 Under construction! My neural networks are still training...", 18 - "📡 Signal received! But my language circuits aren't ready yet.", 19 - "🎯 You found me! I'm not quite ready to chat yet though.", 20 - "🚧 Pardon the dust - bot brain installation in progress!", 21 - "💭 I hear you! Just need to learn how to respond properly first...", 22 - "🔌 Still booting up my conversation modules!", 23 - "📚 Currently reading the manual on how to be a good bot...", 24 - "🎪 Nothing to see here yet - but stay tuned!", 25 - ] 26 - 27 - 28 - class ResponseGenerator: 29 - """Generates responses to mentions""" 30 - 31 - def __init__(self): 32 - self.agent: object | None = None 33 - self.memory: object | None = None 34 - 35 - # Try to initialize AI agent if credentials available 36 - if settings.anthropic_api_key: 37 - try: 38 - from bot.agents.anthropic_agent import AnthropicAgent 39 - 40 - self.agent = AnthropicAgent() 41 - bot_status.ai_enabled = True 42 - logger.info("✅ AI responses enabled (Anthropic)") 43 - 44 - # Use the agent's memory if it has one 45 - if hasattr(self.agent, 'memory') and self.agent.memory: 46 - self.memory = self.agent.memory 47 - logger.info("💾 Memory system enabled (from agent)") 48 - else: 49 - self.memory = None 50 - except Exception as e: 51 - logger.warning(f"⚠️ Failed to initialize AI agent: {e}") 52 - logger.warning(" Using placeholder responses") 53 - self.memory = None 54 - 55 - async def generate( 56 - self, mention_text: str, author_handle: str, thread_context: str = "", thread_uri: str | None = None 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 - 71 - # Enhance thread context with memory if available 72 - enhanced_context = thread_context 73 - 74 - if self.memory and self.agent: 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 - 85 - # Store the incoming message 86 - await self.memory.store_user_memory( 87 - author_handle, 88 - f"User said: {mention_text}", 89 - MemoryType.CONVERSATION, 90 - ) 91 - 92 - # Build conversation context with semantic search 93 - memory_context = await self.memory.build_conversation_context( 94 - author_handle, include_core=True, query=mention_text 95 - ) 96 - enhanced_context = f"{thread_context}\n\n{memory_context}".strip() 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 - }) 108 - 109 - except Exception as e: 110 - logger.warning(f"Memory enhancement failed: {e}") 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 - 129 - if self.agent: 130 - response = await self.agent.generate_response( 131 - mention_text, author_handle, enhanced_context, thread_uri 132 - ) 133 - 134 - # Store bot's response in memory if available 135 - if ( 136 - self.memory 137 - and hasattr(response, "action") 138 - and response.action == "reply" 139 - and response.text 140 - ): 141 - try: 142 - await self.memory.store_user_memory( 143 - author_handle, 144 - f"Bot replied: {response.text}", 145 - MemoryType.CONVERSATION, 146 - ) 147 - except Exception as e: 148 - logger.warning(f"Failed to store bot response: {e}") 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 - 160 - return response 161 - else: 162 - # Return a simple dict for 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}
+39 -54
src/bot/services/message_handler.py
··· 1 + """Message handler using MCP-enabled agent.""" 2 + 1 3 import logging 2 4 3 - from atproto import models 5 + from atproto_client import models 4 6 7 + from bot.agent import PhiAgent 5 8 from bot.config import settings 6 9 from bot.core.atproto_client import BotClient 7 10 from bot.database import thread_db 8 - from bot.response_generator import ResponseGenerator 9 11 from bot.status import bot_status 10 12 11 13 logger = logging.getLogger("bot.handler") 12 14 13 15 14 16 class MessageHandler: 17 + """Handles incoming mentions using phi agent.""" 18 + 15 19 def __init__(self, client: BotClient): 16 20 self.client = client 17 - self.response_generator = ResponseGenerator() 21 + self.agent = PhiAgent() 18 22 19 23 async def handle_mention(self, notification): 20 - """Process a mention or reply notification""" 24 + """Process a mention or reply notification.""" 21 25 try: 22 - # Skip if not a mention or reply 23 26 if notification.reason not in ["mention", "reply"]: 24 27 return 25 28 ··· 35 38 mention_text = post.record.text 36 39 author_handle = post.author.handle 37 40 author_did = post.author.did 38 - 39 - # Record mention received 41 + 40 42 bot_status.record_mention() 41 43 42 44 # Build reply reference ··· 44 46 45 47 # Check if this is part of a thread 46 48 if hasattr(post.record, "reply") and post.record.reply: 47 - # Use existing thread root 48 49 root_ref = post.record.reply.root 49 50 thread_uri = root_ref.uri 50 51 else: 51 - # This post is the root 52 52 root_ref = parent_ref 53 53 thread_uri = post_uri 54 54 ··· 64 64 # Get thread context 65 65 thread_context = thread_db.get_thread_context(thread_uri) 66 66 67 - # Generate response 68 - # Note: We pass the full text including @mention 69 - # In AT Protocol, mentions are structured as facets, 70 - # but the text representation includes them 71 - response = await self.response_generator.generate( 67 + # Process with agent (has episodic memory + MCP tools) 68 + response = await self.agent.process_mention( 72 69 mention_text=mention_text, 73 70 author_handle=author_handle, 74 71 thread_context=thread_context, 75 72 thread_uri=thread_uri, 76 73 ) 77 74 78 - # Handle structured response or legacy dict 79 - if hasattr(response, 'action'): 80 - action = response.action 81 - reply_text = response.text 82 - reason = response.reason 83 - else: 84 - # Legacy dict format 85 - action = response.get('action', 'reply') 86 - reply_text = response.get('text', '') 87 - reason = response.get('reason', '') 75 + # Handle response actions 76 + if response.action == "ignore": 77 + logger.info( 78 + f"🙈 Ignoring notification from @{author_handle} ({response.reason})" 79 + ) 80 + return 88 81 89 - # Handle different actions 90 - if action == 'ignore': 91 - logger.info(f"🚫 Ignoring notification from @{author_handle} ({reason})") 92 - return 93 - 94 - elif action == 'like': 95 - # Like the post 82 + elif response.action == "like": 96 83 await self.client.like_post(uri=post_uri, cid=post.cid) 97 - logger.info(f"💜 Liked post from @{author_handle}") 84 + logger.info(f"👍 Liked post from @{author_handle}") 98 85 bot_status.record_response() 99 86 return 100 - 101 - elif action == 'repost': 102 - # Repost the post 87 + 88 + elif response.action == "repost": 103 89 await self.client.repost(uri=post_uri, cid=post.cid) 104 90 logger.info(f"🔁 Reposted from @{author_handle}") 105 91 bot_status.record_response() 106 92 return 107 93 108 - # Default to reply action 109 - reply_ref = models.AppBskyFeedPost.ReplyRef( 110 - parent=parent_ref, root=root_ref 111 - ) 112 - 113 - # Send the reply 114 - response = await self.client.create_post(reply_text, reply_to=reply_ref) 115 - 116 - # Store bot's response in thread history 117 - if response and hasattr(response, "uri"): 118 - thread_db.add_message( 119 - thread_uri=thread_uri, 120 - author_handle=settings.bluesky_handle, 121 - author_did=self.client.me.did if self.client.me else "bot", 122 - message_text=reply_text or "", 123 - post_uri=response.uri, 94 + elif response.action == "reply" and response.text: 95 + # Post reply 96 + reply_ref = models.AppBskyFeedPost.ReplyRef( 97 + parent=parent_ref, root=root_ref 98 + ) 99 + reply_response = await self.client.create_post( 100 + response.text, reply_to=reply_ref 124 101 ) 125 102 126 - # Record successful response 127 - bot_status.record_response() 103 + # Store bot's response in thread history 104 + if reply_response and hasattr(reply_response, "uri"): 105 + thread_db.add_message( 106 + thread_uri=thread_uri, 107 + author_handle=settings.bluesky_handle, 108 + author_did=self.client.me.did if self.client.me else "bot", 109 + message_text=response.text, 110 + post_uri=reply_response.uri, 111 + ) 128 112 129 - logger.info(f"✅ Replied to @{author_handle}: {reply_text or '(empty)'}") 113 + bot_status.record_response() 114 + logger.info(f"✅ Replied to @{author_handle}: {response.text[:50]}...") 130 115 131 116 except Exception as e: 132 117 logger.error(f"❌ Error handling mention: {e}")
+13 -172
src/bot/services/notification_poller.py
··· 1 + """Simplified notification poller.""" 2 + 1 3 import asyncio 2 - import json 3 4 import logging 4 - import time 5 5 6 6 from bot.config import settings 7 7 from bot.core.atproto_client import BotClient ··· 12 12 13 13 14 14 class NotificationPoller: 15 + """Polls for and processes Bluesky notifications.""" 16 + 15 17 def __init__(self, client: BotClient): 16 18 self.client = client 17 19 self.handler = MessageHandler(client) 18 20 self._running = False 19 21 self._task: asyncio.Task | None = None 20 - self._last_seen_at: str | None = None 21 - self._processed_uris: set[str] = set() # Track processed notifications 22 - self._first_poll = True # Track if this is our first check 23 - self._notified_approval_ids: set[int] = set() # Track approvals we've notified about 24 - self._processed_dm_ids: set[str] = set() # Track DMs we've already processed 22 + self._processed_uris: set[str] = set() 23 + self._first_poll = True 25 24 26 25 async def start(self) -> asyncio.Task: 27 - """Start polling for notifications""" 26 + """Start polling for notifications.""" 28 27 self._running = True 29 28 bot_status.polling_active = True 30 29 self._task = asyncio.create_task(self._poll_loop()) 31 30 return self._task 32 31 33 32 async def stop(self): 34 - """Stop polling""" 33 + """Stop polling.""" 35 34 self._running = False 36 35 bot_status.polling_active = False 37 36 if self._task and not self._task.done(): ··· 42 41 pass 43 42 44 43 async def _poll_loop(self): 45 - """Main polling loop""" 44 + """Main polling loop.""" 46 45 await self.client.authenticate() 47 46 48 47 while self._running: 49 48 try: 50 49 await self._check_notifications() 51 50 except Exception as e: 52 - # Compact error handling (12-factor principle #9) 53 51 logger.error(f"Error in notification poll: {e}") 54 52 bot_status.record_error() 55 53 if settings.debug: 56 54 import traceback 55 + 57 56 traceback.print_exc() 58 - # Continue polling - don't let one error stop the bot 59 57 continue 60 58 61 - # Sleep with proper cancellation handling 62 59 try: 63 60 await asyncio.sleep(settings.notification_poll_interval) 64 61 except asyncio.CancelledError: 65 62 logger.info("📭 Notification poller shutting down gracefully") 66 - raise # Re-raise to properly propagate cancellation 63 + raise 67 64 68 65 async def _check_notifications(self): 69 - """Check and process new notifications""" 70 - # Capture timestamp BEFORE fetching (Void's approach) 66 + """Check and process new notifications.""" 71 67 check_time = self.client.client.get_current_time_iso() 72 68 73 69 response = await self.client.get_notifications() 74 70 notifications = response.notifications 75 - 76 - # Also check for DM approvals periodically 77 - await self._check_dm_approvals() 78 71 79 - # Count unread mentions and replies 80 72 unread_mentions = [ 81 73 n 82 74 for n in notifications ··· 90 82 logger.info( 91 83 f"📬 Found {len(notifications)} notifications ({len(unread_mentions)} unread mentions)" 92 84 ) 93 - # Subsequent polls: only show activity 94 85 elif unread_mentions: 95 86 logger.info(f"📬 {len(unread_mentions)} new mentions") 96 - else: 97 - # In debug mode, be silent about empty polls 98 - # In production, we could add a subtle indicator 99 - pass 100 87 101 - # Track if we processed any mentions 102 88 processed_any_mentions = False 103 89 104 90 # Process notifications from oldest to newest 105 91 for notification in reversed(notifications): 106 - # Skip if already seen or processed 107 92 if notification.is_read or notification.uri in self._processed_uris: 108 93 continue 109 94 110 95 if notification.reason in ["mention", "reply"]: 111 96 logger.debug(f"🔍 Processing {notification.reason} notification") 112 - # Process mentions and replies in threads 113 97 self._processed_uris.add(notification.uri) 114 98 await self.handler.handle_mention(notification) 115 99 processed_any_mentions = True 116 - else: 117 - # Silently ignore other notification types 118 - pass 119 100 120 - # Mark all notifications as seen using the initial timestamp 121 - # This ensures we don't miss any that arrived during processing 101 + # Mark all notifications as seen 122 102 if processed_any_mentions: 123 103 await self.client.mark_notifications_seen(check_time) 124 104 logger.info("✓ Marked all notifications as read") 125 105 126 106 # Clean up old processed URIs to prevent memory growth 127 - # Keep only the last 1000 processed URIs 128 107 if len(self._processed_uris) > 1000: 129 - # Convert to list, sort by insertion order (oldest first), keep last 500 130 108 self._processed_uris = set(list(self._processed_uris)[-500:]) 131 - 132 - async def _check_dm_approvals(self): 133 - """Check DMs for approval responses and process approved changes""" 134 - try: 135 - from bot.core.dm_approval import process_dm_for_approval, check_pending_approvals, notify_operator_of_pending 136 - from bot.personality import process_approved_changes 137 - 138 - # Check if we have pending approvals (include all for DM checking) 139 - pending = check_pending_approvals() 140 - if not pending: 141 - return 142 - 143 - # Check DMs for pending approvals 144 - 145 - # Get recent DMs 146 - chat_client = self.client.client.with_bsky_chat_proxy() 147 - convos = chat_client.chat.bsky.convo.list_convos() 148 - 149 - # Check each conversation for approval messages 150 - for convo in convos.convos: 151 - # Look for messages from operator 152 - messages = chat_client.chat.bsky.convo.get_messages( 153 - params={"convoId": convo.id, "limit": 5} 154 - ) 155 - 156 - for msg in messages.messages: 157 - # Skip if we've already processed this message 158 - if msg.id in self._processed_dm_ids: 159 - continue 160 - 161 - # Skip if not from a member of the conversation 162 - sender_handle = None 163 - for member in convo.members: 164 - if member.did == msg.sender.did: 165 - sender_handle = member.handle 166 - break 167 - 168 - if sender_handle: 169 - # Process DM from operator 170 - # Mark this message as processed 171 - self._processed_dm_ids.add(msg.id) 172 - 173 - # Process any approval/denial in the message 174 - processed = await process_dm_for_approval( 175 - msg.text, 176 - sender_handle, 177 - msg.sent_at 178 - ) 179 - if processed: 180 - logger.info(f"Processed {len(processed)} approvals from DM") 181 - # Remove processed IDs from notified set 182 - for approval_id in processed: 183 - self._notified_approval_ids.discard(approval_id) 184 - 185 - # Mark the conversation as read 186 - try: 187 - chat_client.chat.bsky.convo.update_read( 188 - data={"convoId": convo.id} 189 - ) 190 - pass # Successfully marked as read 191 - except Exception as e: 192 - logger.warning(f"Failed to mark conversation as read: {e}") 193 - 194 - # Process any approved personality changes 195 - if self.handler.response_generator.memory: 196 - changes = await process_approved_changes(self.handler.response_generator.memory) 197 - if changes: 198 - logger.info(f"Applied {changes} approved personality changes") 199 - 200 - # Notify threads about applied changes 201 - await self._notify_threads_about_approvals() 202 - 203 - # Notify operator of new pending approvals 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]) 211 - 212 - except Exception as e: 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}")
src/bot/tools/__init__.py

This is a binary file and will not be displayed.

-41
src/bot/tools/google_search.py
··· 1 - import logging 2 - 3 - import httpx 4 - 5 - from bot.config import settings 6 - 7 - logger = logging.getLogger("bot.tools") 8 - 9 - 10 - async def search_google(query: str, num_results: int = 3) -> str: 11 - """Search Google and return formatted results""" 12 - if not settings.google_api_key or not settings.google_search_engine_id: 13 - return "Search not available - missing Google API credentials" 14 - 15 - params = { 16 - "key": settings.google_api_key, 17 - "cx": settings.google_search_engine_id, 18 - "q": query, 19 - "num": min(num_results, 10), 20 - } 21 - 22 - async with httpx.AsyncClient() as client: 23 - try: 24 - response = await client.get( 25 - "https://www.googleapis.com/customsearch/v1", params=params 26 - ) 27 - response.raise_for_status() 28 - data = response.json() 29 - 30 - results = [] 31 - for i, item in enumerate(data.get("items", [])[:num_results], 1): 32 - title = item.get("title", "") 33 - snippet = item.get("snippet", "") 34 - results.append(f"{i}. {title}\n {snippet}") 35 - 36 - return "\n\n".join(results) if results else "No search results found" 37 - 38 - except Exception as e: 39 - logger.error(f"Search failed: {e}") 40 - # 12-factor principle #4: Tools should throw errors, not return error strings 41 - raise
src/bot/ui/__init__.py

This is a binary file and will not be displayed.

-109
src/bot/ui/context_capture.py
··· 1 - """Context capture system for visualizing phi's response context""" 2 - 3 - import logging 4 - from collections import deque 5 - from dataclasses import asdict, dataclass 6 - from datetime import datetime 7 - from typing import Any, Literal 8 - 9 - logger = logging.getLogger("bot.context") 10 - 11 - 12 - @dataclass 13 - class ContextComponent: 14 - """A component of phi's response context""" 15 - 16 - name: str 17 - type: Literal["personality", "memory", "thread", "mention", "user"] 18 - content: str 19 - size_chars: int 20 - metadata: dict[str, Any] 21 - timestamp: str 22 - 23 - 24 - @dataclass 25 - class ResponseContext: 26 - """Complete context for a single response""" 27 - 28 - response_id: str 29 - mention_text: str 30 - author_handle: str 31 - thread_uri: str | None 32 - generated_response: str 33 - components: list[ContextComponent] 34 - total_context_chars: int 35 - timestamp: str 36 - 37 - 38 - class ContextCapture: 39 - """Captures and stores context information for responses""" 40 - 41 - def __init__(self, max_stored: int = 50): 42 - self.max_stored = max_stored 43 - self.responses: deque = deque(maxlen=max_stored) 44 - 45 - def capture_response_context( 46 - self, 47 - mention_text: str, 48 - author_handle: str, 49 - thread_uri: str | None, 50 - generated_response: str, 51 - components: list[dict[str, Any]], 52 - ) -> str: 53 - """Capture context for a response and return unique ID""" 54 - response_id = f"resp_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" 55 - 56 - # Convert components to ContextComponent objects 57 - context_components = [] 58 - total_chars = 0 59 - 60 - for comp in components: 61 - component = ContextComponent( 62 - name=comp["name"], 63 - type=comp["type"], 64 - content=comp["content"], 65 - size_chars=len(comp["content"]), 66 - metadata=comp.get("metadata", {}), 67 - timestamp=datetime.now().isoformat(), 68 - ) 69 - context_components.append(component) 70 - total_chars += component.size_chars 71 - 72 - # Create response context 73 - response_context = ResponseContext( 74 - response_id=response_id, 75 - mention_text=mention_text, 76 - author_handle=author_handle, 77 - thread_uri=thread_uri, 78 - generated_response=generated_response, 79 - components=context_components, 80 - total_context_chars=total_chars, 81 - timestamp=datetime.now().isoformat(), 82 - ) 83 - 84 - # Store it 85 - self.responses.appendleft(response_context) 86 - 87 - logger.info( 88 - f"📊 Captured context for {response_id}: {len(components)} components, {total_chars} chars" 89 - ) 90 - return response_id 91 - 92 - def get_response_context(self, response_id: str) -> ResponseContext | None: 93 - """Get context for a specific response""" 94 - for resp in self.responses: 95 - if resp.response_id == response_id: 96 - return resp 97 - return None 98 - 99 - def get_recent_responses(self, limit: int = 20) -> list[ResponseContext]: 100 - """Get recent response contexts""" 101 - return list(self.responses)[:limit] 102 - 103 - def to_dict(self, response_context: ResponseContext) -> dict[str, Any]: 104 - """Convert ResponseContext to dictionary for JSON serialization""" 105 - return asdict(response_context) 106 - 107 - 108 - # Global instance 109 - context_capture = ContextCapture()
-244
src/bot/ui/templates.py
··· 1 - """HTML templates for the bot""" 2 - 3 - from typing import TYPE_CHECKING 4 - 5 - if TYPE_CHECKING: 6 - from bot.ui.context_capture import ResponseContext 7 - 8 - CONTEXT_VISUALIZATION_TEMPLATE = """<!DOCTYPE html> 9 - <html> 10 - <head> 11 - <title>Phi Context Visualization</title> 12 - <style> 13 - body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #0a0a0a; color: #e0e0e0; }} 14 - .response-card {{ border: 1px solid #333; margin-bottom: 20px; border-radius: 8px; overflow: hidden; background: #1a1a1a; }} 15 - .response-header {{ background: #2a2a2a; padding: 15px; border-bottom: 1px solid #333; }} 16 - .response-meta {{ font-size: 0.9em; color: #888; margin-bottom: 5px; }} 17 - .mention-text {{ font-weight: bold; margin-bottom: 5px; color: #e0e0e0; }} 18 - .generated-response {{ color: #00a8ff; font-style: italic; }} 19 - .components {{ padding: 15px; }} 20 - .component {{ margin-bottom: 15px; }} 21 - .component-header {{ 22 - cursor: pointer; 23 - padding: 10px; 24 - background: #2a2a2a; 25 - border: 1px solid #444; 26 - border-radius: 4px; 27 - display: flex; 28 - justify-content: space-between; 29 - align-items: center; 30 - }} 31 - .component-header:hover {{ background: #333; }} 32 - .component-type {{ 33 - font-size: 0.8em; 34 - color: #888; 35 - background: #444; 36 - padding: 2px 6px; 37 - border-radius: 3px; 38 - }} 39 - .component-size {{ font-size: 0.8em; color: #888; }} 40 - .component-content {{ 41 - display: none; 42 - padding: 15px; 43 - border: 1px solid #444; 44 - border-top: none; 45 - background: #1a1a1a; 46 - white-space: pre-wrap; 47 - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; 48 - font-size: 0.9em; 49 - max-height: 400px; 50 - overflow-y: auto; 51 - }} 52 - .component-content.show {{ display: block; }} 53 - .stats {{ display: flex; gap: 20px; margin-bottom: 10px; }} 54 - .stat {{ font-size: 0.9em; color: #888; }} 55 - h1 {{ color: #00a8ff; }} 56 - </style> 57 - </head> 58 - <body> 59 - <h1>🧠 Phi Context Visualization</h1> 60 - {responses_html} 61 - <script> 62 - function toggleComponent(id) {{ 63 - const element = document.getElementById(id); 64 - element.classList.toggle('show'); 65 - }} 66 - </script> 67 - </body> 68 - </html>""" 69 - 70 - STATUS_PAGE_TEMPLATE = """<!DOCTYPE html> 71 - <html> 72 - <head> 73 - <title>Bluesky Bot Status</title> 74 - <meta http-equiv="refresh" content="10"> 75 - <style> 76 - body {{ 77 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 78 - margin: 0; 79 - padding: 20px; 80 - background: #0a0a0a; 81 - color: #e0e0e0; 82 - }} 83 - .container {{ 84 - max-width: 800px; 85 - margin: 0 auto; 86 - }} 87 - h1 {{ 88 - color: #00a8ff; 89 - margin-bottom: 30px; 90 - }} 91 - .status-grid {{ 92 - display: grid; 93 - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); 94 - gap: 20px; 95 - margin-bottom: 40px; 96 - }} 97 - .status-card {{ 98 - background: #1a1a1a; 99 - border: 1px solid #333; 100 - border-radius: 8px; 101 - padding: 20px; 102 - }} 103 - .status-card h3 {{ 104 - margin: 0 0 15px 0; 105 - color: #00a8ff; 106 - font-size: 1rem; 107 - text-transform: uppercase; 108 - letter-spacing: 0.5px; 109 - }} 110 - .status-value {{ 111 - font-size: 2rem; 112 - font-weight: bold; 113 - margin-bottom: 5px; 114 - }} 115 - .status-label {{ 116 - color: #888; 117 - font-size: 0.9rem; 118 - }} 119 - .status-active {{ 120 - color: #00ff88; 121 - }} 122 - .status-inactive {{ 123 - color: #ff4444; 124 - }} 125 - .uptime {{ 126 - font-size: 1.2rem; 127 - margin-bottom: 5px; 128 - }} 129 - .ai-mode {{ 130 - display: inline-block; 131 - padding: 4px 12px; 132 - border-radius: 4px; 133 - font-size: 0.9rem; 134 - background: #00a8ff22; 135 - color: #00a8ff; 136 - border: 1px solid #00a8ff44; 137 - }} 138 - .ai-mode.placeholder {{ 139 - background: #ff444422; 140 - color: #ff8888; 141 - border-color: #ff444444; 142 - }} 143 - .footer {{ 144 - margin-top: 40px; 145 - text-align: center; 146 - color: #666; 147 - font-size: 0.9rem; 148 - }} 149 - </style> 150 - </head> 151 - <body> 152 - <div class="container"> 153 - <h1>🤖 {bot_name} Status</h1> 154 - 155 - <div class="status-grid"> 156 - <div class="status-card"> 157 - <h3>Bot Status</h3> 158 - <div class="status-value {status_class}">{status}</div> 159 - <div class="uptime">{uptime}</div> 160 - <div style="margin-top: 10px;"> 161 - <span class="ai-mode {ai_mode_class}">{ai_mode}</span> 162 - </div> 163 - </div> 164 - 165 - <div class="status-card"> 166 - <h3>Activity</h3> 167 - <div class="status-value">{mentions}</div> 168 - <div class="status-label">Mentions received</div> 169 - <div style="margin-top: 10px;"> 170 - <div class="status-value">{responses}</div> 171 - <div class="status-label">Responses sent</div> 172 - </div> 173 - </div> 174 - 175 - <div class="status-card"> 176 - <h3>Last Activity</h3> 177 - <div style="margin-bottom: 10px;"> 178 - <div class="status-label">Last mention</div> 179 - <div>{last_mention}</div> 180 - </div> 181 - <div> 182 - <div class="status-label">Last response</div> 183 - <div>{last_response}</div> 184 - </div> 185 - </div> 186 - 187 - <div class="status-card"> 188 - <h3>Health</h3> 189 - <div class="status-value">{errors}</div> 190 - <div class="status-label">Errors encountered</div> 191 - </div> 192 - </div> 193 - 194 - <div class="footer"> 195 - <p>Auto-refreshes every 10 seconds</p> 196 - </div> 197 - </div> 198 - </body> 199 - </html>""" 200 - 201 - 202 - def build_response_cards_html(responses: list["ResponseContext"]) -> str: 203 - """Build HTML for response cards""" 204 - if not responses: 205 - return '<p style="text-align: center; color: #888;">No recent responses to display.</p>' 206 - 207 - return "".join([ 208 - f''' 209 - <div class="response-card"> 210 - <div class="response-header"> 211 - <div class="response-meta"> 212 - {resp.timestamp[:19].replace("T", " ")} • @{resp.author_handle} 213 - {f" • Thread: {resp.thread_uri.split('/')[-1][:8]}..." if resp.thread_uri else ""} 214 - </div> 215 - <div class="mention-text">"{resp.mention_text}"</div> 216 - <div class="generated-response">→ "{resp.generated_response}"</div> 217 - <div class="stats"> 218 - <div class="stat">{len(resp.components)} components</div> 219 - <div class="stat">{resp.total_context_chars:,} characters</div> 220 - </div> 221 - </div> 222 - <div class="components"> 223 - {"".join([ 224 - f''' 225 - <div class="component"> 226 - <div class="component-header" onclick="toggleComponent('{resp.response_id}_{i}')"> 227 - <div> 228 - <strong>{comp.name}</strong> 229 - <span class="component-type">{comp.type}</span> 230 - </div> 231 - <div class="component-size">{comp.size_chars:,} chars</div> 232 - </div> 233 - <div class="component-content" id="{resp.response_id}_{i}"> 234 - {comp.content} 235 - </div> 236 - </div> 237 - ''' 238 - for i, comp in enumerate(resp.components) 239 - ])} 240 - </div> 241 - </div> 242 - ''' 243 - for resp in responses 244 - ])
+180 -6
uv.lock
··· 139 139 140 140 [[package]] 141 141 name = "atproto" 142 - version = "0.0.1" 143 - source = { registry = "https://pypi.org/simple" } 144 - sdist = { url = "https://files.pythonhosted.org/packages/da/b4/b0f23adc83dcda376112e7efe449f75b31565ac126d3aca9cae25a712733/atproto-0.0.1.tar.gz", hash = "sha256:cc46914de42b8867069b340e8beced2cfdbf97b412d32bd55c5ce2f866be7ad7", size = 2196, upload-time = "2022-11-23T00:11:59.249Z" } 145 - wheels = [ 146 - { url = "https://files.pythonhosted.org/packages/28/c2/9f37817fbf4df3c3f1431f04cff1c9a68c25f44b5a98192dca49e9401e9b/atproto-0.0.1-py3-none-any.whl", hash = "sha256:d866a599e6c7386c101fd87052723979ac74d9f3c4cacac31f00542c18fd1c66", size = 2141, upload-time = "2022-11-23T00:11:56.501Z" }, 142 + version = "0.0.62.dev4" 143 + source = { git = "https://github.com/MarshalX/atproto.git?rev=refs%2Fpull%2F605%2Fhead#1a2188371a25b248e0350826eda9f5e55d9c45bf" } 144 + dependencies = [ 145 + { name = "click" }, 146 + { name = "cryptography" }, 147 + { name = "dnspython" }, 148 + { name = "httpx" }, 149 + { name = "libipld" }, 150 + { name = "pydantic" }, 151 + { name = "typing-extensions" }, 152 + { name = "websockets" }, 147 153 ] 148 154 149 155 [[package]] ··· 176 182 { name = "fastapi" }, 177 183 { name = "fastmcp" }, 178 184 { name = "httpx" }, 185 + { name = "openai" }, 179 186 { name = "pydantic-ai" }, 180 187 { name = "pydantic-settings" }, 181 188 { name = "rich" }, 189 + { name = "turbopuffer" }, 182 190 { name = "uvicorn" }, 183 191 { name = "websockets" }, 184 192 ] ··· 194 202 [package.metadata] 195 203 requires-dist = [ 196 204 { name = "anthropic" }, 197 - { name = "atproto" }, 205 + { name = "atproto", git = "https://github.com/MarshalX/atproto.git?rev=refs%2Fpull%2F605%2Fhead" }, 198 206 { name = "fastapi" }, 199 207 { name = "fastmcp", specifier = ">=0.8.0" }, 200 208 { name = "httpx" }, 209 + { name = "openai" }, 201 210 { name = "pydantic-ai" }, 202 211 { name = "pydantic-settings" }, 203 212 { name = "rich" }, 213 + { name = "turbopuffer" }, 204 214 { name = "uvicorn" }, 205 215 { name = "websockets", specifier = ">=15.0.1" }, 206 216 ] ··· 945 955 ] 946 956 947 957 [[package]] 958 + name = "libipld" 959 + version = "3.1.1" 960 + source = { registry = "https://pypi.org/simple" } 961 + sdist = { url = "https://files.pythonhosted.org/packages/84/ac/21f2b0f9848c9d99a87e3cc626e7af0fc24883911ec5d7578686cc2a09d1/libipld-3.1.1.tar.gz", hash = "sha256:4b9a9da0ea5d848e9fa12c700027619a1e37ecc1da39dbd1424c0e9062f29e44", size = 4380425, upload-time = "2025-06-24T23:12:51.395Z" } 962 + wheels = [ 963 + { url = "https://files.pythonhosted.org/packages/fe/07/975b9dde7e27489218c21db4357bd852cd71c388c06abedcff2b86a500ab/libipld-3.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27d2fb2b19a9784a932a41fd1a6942361cfa65e0957871f4bde06c81639a32b1", size = 279659, upload-time = "2025-06-24T23:11:29.139Z" }, 964 + { url = "https://files.pythonhosted.org/packages/4d/db/bd6a9eefa7c90f23ea2ea98678e8f6aac15fedb9645ddaa8af977bcfdf2f/libipld-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f0156a9bf04b7f575b907b7a15b902dde2d8af129aeb161b3ab6940f3fd9c02", size = 276397, upload-time = "2025-06-24T23:11:30.54Z" }, 965 + { url = "https://files.pythonhosted.org/packages/02/a8/09606bc7139173d8543cf8206b3c7ff9238bd4c9b47a71565c50912f0323/libipld-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29cf371122648a688f87fe3307bcfe2c6a4aefa184ba44126f066975cfd26b46", size = 297682, upload-time = "2025-06-24T23:11:31.833Z" }, 966 + { url = "https://files.pythonhosted.org/packages/31/ad/a54d62baead5aecc9a2f48ab2b8ac81fbeb8df19c89416735387dd041175/libipld-3.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5463672cd0708d47bc8cfe1cc0dd95c55d5b7f3947027e0e9c6a13b1dc1b6d0", size = 304615, upload-time = "2025-06-24T23:11:32.8Z" }, 967 + { url = "https://files.pythonhosted.org/packages/c5/a2/3c7908d6aa865721e7e9c2f125e315614cee4e4ced4457d7b22cc8d8acc4/libipld-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27a1b9b9392679fb494214bfa350adf7447b43bc39e497b669307da1f6dc8dd5", size = 332042, upload-time = "2025-06-24T23:11:33.831Z" }, 968 + { url = "https://files.pythonhosted.org/packages/e1/c0/ecd838e32630439ca3d8ce2274db32c77f31d0265c01b6a3c00fd96367bb/libipld-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a83d944c71ed50772a7cef3f14e3ef3cf93145c82963b9e49a85cd9ee0ba9878", size = 344326, upload-time = "2025-06-24T23:11:34.768Z" }, 969 + { url = "https://files.pythonhosted.org/packages/98/79/9ef27cd284c66e7e9481e7fe529d1412ea751b4cad1578571bbc02826098/libipld-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb9fef573406f7134727e0561e42fd221721800ed01d47f1207916595b72e780", size = 299195, upload-time = "2025-06-24T23:11:35.973Z" }, 970 + { url = "https://files.pythonhosted.org/packages/a7/6e/2db9510cdc410b154169438449277637f35bbc571c330d60d262320e6d77/libipld-3.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:485b21bdddbe7a3bb8f33f1d0b9998343bd82a578406e31f85899b031602d34d", size = 323946, upload-time = "2025-06-24T23:11:37.815Z" }, 971 + { url = "https://files.pythonhosted.org/packages/63/fb/ac59473cbc7598db0e194b2b14b10953029813f204555e5c12405b265594/libipld-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fe6fa67a242755773f3e960163010bdbc797316ca782d387e6b128e0d3bca19", size = 477366, upload-time = "2025-06-24T23:11:38.798Z" }, 972 + { url = "https://files.pythonhosted.org/packages/f5/75/80915af5dc04785ff7a9468529a96d787723d24a9e76dbc31e0141bbcd23/libipld-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:38298cbea4f8308bb848c7f8c3d8e41cd2c9235ef8bca6adefd2a002e94287ff", size = 470106, upload-time = "2025-06-24T23:11:39.786Z" }, 973 + { url = "https://files.pythonhosted.org/packages/9e/17/832f1c91938a0e2d58905e86c7a2f21cd4b6334a3757221563bd9a8beb64/libipld-3.1.1-cp312-cp312-win32.whl", hash = "sha256:1bc228298e249baac85f702da7d1e23ee429529a078a6bdf09570168f53fcb0f", size = 173435, upload-time = "2025-06-24T23:11:41.072Z" }, 974 + { url = "https://files.pythonhosted.org/packages/14/62/1006fa794c6fe18040d06cebe2d593c20208c2a16a5eb01f7d4f48a5a3b5/libipld-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a393e1809c7b1aa67c6f6c5d701787298f507448a601b8ec825b6ae26084fbad", size = 179271, upload-time = "2025-06-24T23:11:42.155Z" }, 975 + { url = "https://files.pythonhosted.org/packages/bc/af/95b2673bd8ab8225a374bde34b4ac21ef9a725c910517e0dadc5ce26d4a7/libipld-3.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7ad7870d2ee609d74eec4ba6dbc2caef0357861b3e0944226272f0e91f016d37", size = 169727, upload-time = "2025-06-24T23:11:43.164Z" }, 976 + { url = "https://files.pythonhosted.org/packages/e5/25/52f27b9617efb0c2f60e71bbfd4f88167ca7acd3aed413999f16e22b3e54/libipld-3.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8cd7d7b8b2e0a6ab273b697259f291edbd7cb1b9200ed746a41dcd63fb52017a", size = 280260, upload-time = "2025-06-24T23:11:44.376Z" }, 977 + { url = "https://files.pythonhosted.org/packages/bb/14/123450261a35e869732ff610580df39a62164d9e0aab58334c182c9453f8/libipld-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0251c6daa8eceee2ce7dc4f03422f3f1acdd31b04ebda39cab5f8af3dae30943", size = 276684, upload-time = "2025-06-24T23:11:45.266Z" }, 978 + { url = "https://files.pythonhosted.org/packages/bd/3e/6dd2daf43ff735a3f53cbeaeac1edb3ba92fa2e48c64257800ede82442e6/libipld-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4598b094286998f770f383eedbfc04c1018ec8ebe6746db0eff5b2059a484a", size = 297845, upload-time = "2025-06-24T23:11:46.143Z" }, 979 + { url = "https://files.pythonhosted.org/packages/83/23/e4f89d9bf854c58a5d6e2f2c667425669ed795956003b28de429b0740e0f/libipld-3.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7212411cbce495dfae24c2b6757a5c2f921797fe70ec0c026e1a2d19ae29e59a", size = 305200, upload-time = "2025-06-24T23:11:47.128Z" }, 980 + { url = "https://files.pythonhosted.org/packages/40/43/0b1e871275502e9799589d03a139730c0dfbb36d1922ab213b105ace59ee/libipld-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffc2f978adda8a8309b55510ceda9fe5dc2431d4ff202ff77d84eb57c77d072f", size = 332153, upload-time = "2025-06-24T23:11:48.437Z" }, 981 + { url = "https://files.pythonhosted.org/packages/94/18/5e9cff31d9450e98cc7b4025d1c90bde661ee099ea46cfcb1d8a893e6083/libipld-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99163cc7822abcb028c55860e5341c77200a3ae90f4c158c27e2118a07e8809d", size = 344391, upload-time = "2025-06-24T23:11:49.786Z" }, 982 + { url = "https://files.pythonhosted.org/packages/63/ca/4d938862912ab2f105710d1cc909ec65c71d0e63a90e3b494920c23a4383/libipld-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80f142cbd4fa89ef514a4dd43afbd4ed3c33ae7061f0e1e0763f7c1811dea389", size = 299448, upload-time = "2025-06-24T23:11:50.723Z" }, 983 + { url = "https://files.pythonhosted.org/packages/2a/08/f6020e53abe4c26d57fe29b001ba1a84b5b3ad2d618e135b82877e42b59a/libipld-3.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4596a6e2c5e81b14b1432f3a6120b1d785fc4f74402cef39accf0041999905e4", size = 324096, upload-time = "2025-06-24T23:11:51.646Z" }, 984 + { url = "https://files.pythonhosted.org/packages/df/0f/d3d9da8f1001e9856bc5cb171a838ca5102da7d959b870a0c5f5aa9ef82e/libipld-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0cd275603ab3cc2394d40455de6976f01b2d85b4095c074c0c1e2692013f5eaa", size = 477593, upload-time = "2025-06-24T23:11:52.565Z" }, 985 + { url = "https://files.pythonhosted.org/packages/59/df/57dcd84e55c02f74bb40a246dd849430994bbb476e91b05179d749993c9a/libipld-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:16c999b3af996865004ff2da8280d0c24b672d8a00f9e4cd3a468da8f5e63a5a", size = 470201, upload-time = "2025-06-24T23:11:53.544Z" }, 986 + { url = "https://files.pythonhosted.org/packages/80/af/aee0800b415b63dc5e259675c31a36d6c261afff8e288b56bc2867aa9310/libipld-3.1.1-cp313-cp313-win32.whl", hash = "sha256:5d34c40a27e8755f500277be5268a2f6b6f0d1e20599152d8a34cd34fb3f2700", size = 173730, upload-time = "2025-06-24T23:11:54.5Z" }, 987 + { url = "https://files.pythonhosted.org/packages/54/a3/7e447f27ee896f48332254bb38e1b6c1d3f24b13e5029977646de9408159/libipld-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:5edee5f2ea8183bb6a151f149c9798a4f1db69fe16307e860a84f8d41b53665a", size = 179409, upload-time = "2025-06-24T23:11:55.356Z" }, 988 + { url = "https://files.pythonhosted.org/packages/f2/0b/31d6097620c5cfaaaa0acb7760c29186029cd72c6ab81c537cc1ddfb34e5/libipld-3.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7307876987d9e570dcaf17a15f0ba210f678b323860742d725cf6d8d8baeae1f", size = 169715, upload-time = "2025-06-24T23:11:56.41Z" }, 989 + ] 990 + 991 + [[package]] 948 992 name = "logfire-api" 949 993 version = "3.25.0" 950 994 source = { registry = "https://pypi.org/simple" } ··· 1367 1411 ] 1368 1412 1369 1413 [[package]] 1414 + name = "pybase64" 1415 + version = "1.4.2" 1416 + source = { registry = "https://pypi.org/simple" } 1417 + sdist = { url = "https://files.pythonhosted.org/packages/04/14/43297a7b7f0c1bf0c00b596f754ee3ac946128c64d21047ccf9c9bbc5165/pybase64-1.4.2.tar.gz", hash = "sha256:46cdefd283ed9643315d952fe44de80dc9b9a811ce6e3ec97fd1827af97692d0", size = 137246, upload-time = "2025-07-27T13:08:57.808Z" } 1418 + wheels = [ 1419 + { url = "https://files.pythonhosted.org/packages/28/6d/11ede991e800797b9f5ebd528013b34eee5652df93de61ffb24503393fa5/pybase64-1.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2c75d1388855b5a1015b65096d7dbcc708e7de3245dcbedeb872ec05a09326", size = 38326, upload-time = "2025-07-27T13:03:09.065Z" }, 1420 + { url = "https://files.pythonhosted.org/packages/fe/84/87f1f565f42e2397e2aaa2477c86419f5173c3699881c42325c090982f0a/pybase64-1.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b621a972a01841368fdb9dedc55fd3c6e0c7217d0505ba3b1ebe95e7ef1b493", size = 31661, upload-time = "2025-07-27T13:03:10.295Z" }, 1421 + { url = "https://files.pythonhosted.org/packages/cb/2a/a24c810e7a61d2cc6f73fe9ee4872a03030887fa8654150901b15f376f65/pybase64-1.4.2-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:f48c32ac6a16cbf57a5a96a073fef6ff7e3526f623cd49faa112b7f9980bafba", size = 68192, upload-time = "2025-07-27T13:03:11.467Z" }, 1422 + { url = "https://files.pythonhosted.org/packages/ee/87/d9baf98cbfc37b8657290ad4421f3a3c36aa0eafe4872c5859cfb52f3448/pybase64-1.4.2-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ace8b23093a6bb862477080d9059b784096ab2f97541e8bfc40d42f062875149", size = 71587, upload-time = "2025-07-27T13:03:12.719Z" }, 1423 + { url = "https://files.pythonhosted.org/packages/0b/89/3df043cc56ef3b91b7aa0c26ae822a2d7ec8da0b0fd7c309c879b0eb5988/pybase64-1.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1772c7532a7fb6301baea3dd3e010148dbf70cd1136a83c2f5f91bdc94822145", size = 59910, upload-time = "2025-07-27T13:03:14.266Z" }, 1424 + { url = "https://files.pythonhosted.org/packages/75/4f/6641e9edf37aeb4d4524dc7ba2168eff8d96c90e77f6283c2be3400ab380/pybase64-1.4.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:f86f7faddcba5cbfea475f8ab96567834c28bf09ca6c7c3d66ee445adac80d8f", size = 56701, upload-time = "2025-07-27T13:03:15.6Z" }, 1425 + { url = "https://files.pythonhosted.org/packages/2d/7f/20d8ac1046f12420a0954a45a13033e75f98aade36eecd00c64e3549b071/pybase64-1.4.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0b8c8e275b5294089f314814b4a50174ab90af79d6a4850f6ae11261ff6a7372", size = 59288, upload-time = "2025-07-27T13:03:16.823Z" }, 1426 + { url = "https://files.pythonhosted.org/packages/17/ea/9c0ca570e3e50b3c6c3442e280c83b321a0464c86a9db1f982a4ff531550/pybase64-1.4.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:864d85a0470c615807ae8b97d724d068b940a2d10ac13a5f1b9e75a3ce441758", size = 60267, upload-time = "2025-07-27T13:03:18.132Z" }, 1427 + { url = "https://files.pythonhosted.org/packages/f9/ac/46894929d71ccedebbfb0284173b0fea96bc029cd262654ba8451a7035d6/pybase64-1.4.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:47254d97ed2d8351e30ecfdb9e2414547f66ba73f8a09f932c9378ff75cd10c5", size = 54801, upload-time = "2025-07-27T13:03:19.669Z" }, 1428 + { url = "https://files.pythonhosted.org/packages/6a/1e/02c95218ea964f0b2469717c2c69b48e63f4ca9f18af01a5b2a29e4c1216/pybase64-1.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:264b65ecc4f0ee73f3298ab83bbd8008f7f9578361b8df5b448f985d8c63e02a", size = 58599, upload-time = "2025-07-27T13:03:20.951Z" }, 1429 + { url = "https://files.pythonhosted.org/packages/15/45/ccc21004930789b8fb439d43e3212a6c260ccddb2bf450c39a20db093f33/pybase64-1.4.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:fbcc2b30cd740c16c9699f596f22c7a9e643591311ae72b1e776f2d539e9dd9d", size = 52388, upload-time = "2025-07-27T13:03:23.064Z" }, 1430 + { url = "https://files.pythonhosted.org/packages/c4/45/22e46e549710c4c237d77785b6fb1bc4c44c288a5c44237ba9daf5c34b82/pybase64-1.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cda9f79c22d51ee4508f5a43b673565f1d26af4330c99f114e37e3186fdd3607", size = 68802, upload-time = "2025-07-27T13:03:24.673Z" }, 1431 + { url = "https://files.pythonhosted.org/packages/55/0c/232c6261b81296e5593549b36e6e7884a5da008776d12665923446322c36/pybase64-1.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0c91c6d2a7232e2a1cd10b3b75a8bb657defacd4295a1e5e80455df2dfc84d4f", size = 57841, upload-time = "2025-07-27T13:03:25.948Z" }, 1432 + { url = "https://files.pythonhosted.org/packages/20/8a/b35a615ae6f04550d696bb179c414538b3b477999435fdd4ad75b76139e4/pybase64-1.4.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a370dea7b1cee2a36a4d5445d4e09cc243816c5bc8def61f602db5a6f5438e52", size = 54320, upload-time = "2025-07-27T13:03:27.495Z" }, 1433 + { url = "https://files.pythonhosted.org/packages/d3/a9/8bd4f9bcc53689f1b457ecefed1eaa080e4949d65a62c31a38b7253d5226/pybase64-1.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9aa4de83f02e462a6f4e066811c71d6af31b52d7484de635582d0e3ec3d6cc3e", size = 56482, upload-time = "2025-07-27T13:03:28.942Z" }, 1434 + { url = "https://files.pythonhosted.org/packages/75/e5/4a7735b54a1191f61c3f5c2952212c85c2d6b06eb5fb3671c7603395f70c/pybase64-1.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83a1c2f9ed00fee8f064d548c8654a480741131f280e5750bb32475b7ec8ee38", size = 70959, upload-time = "2025-07-27T13:03:30.171Z" }, 1435 + { url = "https://files.pythonhosted.org/packages/d3/67/e2b6cb32c782e12304d467418e70da0212567f42bd4d3b5eb1fdf64920ad/pybase64-1.4.2-cp312-cp312-win32.whl", hash = "sha256:a6e5688b18d558e8c6b8701cc8560836c4bbeba61d33c836b4dba56b19423716", size = 33683, upload-time = "2025-07-27T13:03:31.775Z" }, 1436 + { url = "https://files.pythonhosted.org/packages/4f/bc/d5c277496063a09707486180f17abbdbdebbf2f5c4441b20b11d3cb7dc7c/pybase64-1.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:c995d21b8bd08aa179cd7dd4db0695c185486ecc72da1e8f6c37ec86cadb8182", size = 35817, upload-time = "2025-07-27T13:03:32.99Z" }, 1437 + { url = "https://files.pythonhosted.org/packages/e6/69/e4be18ae685acff0ae77f75d4586590f29d2cd187bf603290cf1d635cad4/pybase64-1.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:e254b9258c40509c2ea063a7784f6994988f3f26099d6e08704e3c15dfed9a55", size = 30900, upload-time = "2025-07-27T13:03:34.499Z" }, 1438 + { url = "https://files.pythonhosted.org/packages/f4/56/5337f27a8b8d2d6693f46f7b36bae47895e5820bfa259b0072574a4e1057/pybase64-1.4.2-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:0f331aa59549de21f690b6ccc79360ffed1155c3cfbc852eb5c097c0b8565a2b", size = 33888, upload-time = "2025-07-27T13:03:35.698Z" }, 1439 + { url = "https://files.pythonhosted.org/packages/4c/09/f3f4b11fc9beda7e8625e29fb0f549958fcbb34fea3914e1c1d95116e344/pybase64-1.4.2-cp313-cp313-android_21_x86_64.whl", hash = "sha256:9dad20bf1f3ed9e6fe566c4c9d07d9a6c04f5a280daebd2082ffb8620b0a880d", size = 40796, upload-time = "2025-07-27T13:03:36.927Z" }, 1440 + { url = "https://files.pythonhosted.org/packages/e3/ff/470768f0fe6de0aa302a8cb1bdf2f9f5cffc3f69e60466153be68bc953aa/pybase64-1.4.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:69d3f0445b0faeef7bb7f93bf8c18d850785e2a77f12835f49e524cc54af04e7", size = 30914, upload-time = "2025-07-27T13:03:38.475Z" }, 1441 + { url = "https://files.pythonhosted.org/packages/75/6b/d328736662665e0892409dc410353ebef175b1be5eb6bab1dad579efa6df/pybase64-1.4.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2372b257b1f4dd512f317fb27e77d313afd137334de64c87de8374027aacd88a", size = 31380, upload-time = "2025-07-27T13:03:39.7Z" }, 1442 + { url = "https://files.pythonhosted.org/packages/ca/96/7ff718f87c67f4147c181b73d0928897cefa17dc75d7abc6e37730d5908f/pybase64-1.4.2-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fb794502b4b1ec91c4ca5d283ae71aef65e3de7721057bd9e2b3ec79f7a62d7d", size = 38230, upload-time = "2025-07-27T13:03:41.637Z" }, 1443 + { url = "https://files.pythonhosted.org/packages/4d/58/a3307b048d799ff596a3c7c574fcba66f9b6b8c899a3c00a698124ca7ad5/pybase64-1.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d5c532b03fd14a5040d6cf6571299a05616f925369c72ddf6fe2fb643eb36fed", size = 38319, upload-time = "2025-07-27T13:03:42.847Z" }, 1444 + { url = "https://files.pythonhosted.org/packages/08/a7/0bda06341b0a2c830d348c6e1c4d348caaae86c53dc9a046e943467a05e9/pybase64-1.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f699514dc1d5689ca9cf378139e0214051922732f9adec9404bc680a8bef7c0", size = 31655, upload-time = "2025-07-27T13:03:44.426Z" }, 1445 + { url = "https://files.pythonhosted.org/packages/87/df/e1d6e8479e0c5113c2c63c7b44886935ce839c2d99884c7304ca9e86547c/pybase64-1.4.2-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:cd3e8713cbd32c8c6aa935feaf15c7670e2b7e8bfe51c24dc556811ebd293a29", size = 68232, upload-time = "2025-07-27T13:03:45.729Z" }, 1446 + { url = "https://files.pythonhosted.org/packages/71/ab/db4dbdfccb9ca874d6ce34a0784761471885d96730de85cee3d300381529/pybase64-1.4.2-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d377d48acf53abf4b926c2a7a24a19deb092f366a04ffd856bf4b3aa330b025d", size = 71608, upload-time = "2025-07-27T13:03:47.01Z" }, 1447 + { url = "https://files.pythonhosted.org/packages/11/e9/508df958563951045d728bbfbd3be77465f9231cf805cb7ccaf6951fc9f1/pybase64-1.4.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d83c076e78d619b9e1dd674e2bf5fb9001aeb3e0b494b80a6c8f6d4120e38cd9", size = 59912, upload-time = "2025-07-27T13:03:48.277Z" }, 1448 + { url = "https://files.pythonhosted.org/packages/f2/58/7f2cef1ceccc682088958448d56727369de83fa6b29148478f4d2acd107a/pybase64-1.4.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:ab9cdb6a8176a5cb967f53e6ad60e40c83caaa1ae31c5e1b29e5c8f507f17538", size = 56413, upload-time = "2025-07-27T13:03:49.908Z" }, 1449 + { url = "https://files.pythonhosted.org/packages/08/7c/7e0af5c5728fa7e2eb082d88eca7c6bd17429be819d58518e74919d42e66/pybase64-1.4.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:adf0c103ad559dbfb9fe69edfd26a15c65d9c991a5ab0a25b04770f9eb0b9484", size = 59311, upload-time = "2025-07-27T13:03:51.238Z" }, 1450 + { url = "https://files.pythonhosted.org/packages/03/8b/09825d0f37e45b9a3f546e5f990b6cf2dd838e54ea74122c2464646e0c77/pybase64-1.4.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:0d03ef2f253d97ce0685d3624bf5e552d716b86cacb8a6c971333ba4b827e1fc", size = 60282, upload-time = "2025-07-27T13:03:52.56Z" }, 1451 + { url = "https://files.pythonhosted.org/packages/9c/3f/3711d2413f969bfd5b9cc19bc6b24abae361b7673ff37bcb90c43e199316/pybase64-1.4.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:e565abf906efee76ae4be1aef5df4aed0fda1639bc0d7732a3dafef76cb6fc35", size = 54845, upload-time = "2025-07-27T13:03:54.167Z" }, 1452 + { url = "https://files.pythonhosted.org/packages/c6/3c/4c7ce1ae4d828c2bb56d144322f81bffbaaac8597d35407c3d7cbb0ff98f/pybase64-1.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3c6a5f15fd03f232fc6f295cce3684f7bb08da6c6d5b12cc771f81c9f125cc6", size = 58615, upload-time = "2025-07-27T13:03:55.494Z" }, 1453 + { url = "https://files.pythonhosted.org/packages/f5/8f/c2fc03bf4ed038358620065c75968a30184d5d3512d09d3ef9cc3bd48592/pybase64-1.4.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bad9e3db16f448728138737bbd1af9dc2398efd593a8bdd73748cc02cd33f9c6", size = 52434, upload-time = "2025-07-27T13:03:56.808Z" }, 1454 + { url = "https://files.pythonhosted.org/packages/e2/0a/757d6df0a60327c893cfae903e15419914dd792092dc8cc5c9523d40bc9b/pybase64-1.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2683ef271328365c31afee0ed8fa29356fb8fb7c10606794656aa9ffb95e92be", size = 68824, upload-time = "2025-07-27T13:03:58.735Z" }, 1455 + { url = "https://files.pythonhosted.org/packages/a0/14/84abe2ed8c29014239be1cfab45dfebe5a5ca779b177b8b6f779bd8b69da/pybase64-1.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:265b20089cd470079114c09bb74b101b3bfc3c94ad6b4231706cf9eff877d570", size = 57898, upload-time = "2025-07-27T13:04:00.379Z" }, 1456 + { url = "https://files.pythonhosted.org/packages/7e/c6/d193031f90c864f7b59fa6d1d1b5af41f0f5db35439988a8b9f2d1b32a13/pybase64-1.4.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e53173badead10ef8b839aa5506eecf0067c7b75ad16d9bf39bc7144631f8e67", size = 54319, upload-time = "2025-07-27T13:04:01.742Z" }, 1457 + { url = "https://files.pythonhosted.org/packages/cb/37/ec0c7a610ff8f994ee6e0c5d5d66b6b6310388b96ebb347b03ae39870fdf/pybase64-1.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5823b8dcf74da7da0f761ed60c961e8928a6524e520411ad05fe7f9f47d55b40", size = 56472, upload-time = "2025-07-27T13:04:03.089Z" }, 1458 + { url = "https://files.pythonhosted.org/packages/c4/5a/e585b74f85cedd261d271e4c2ef333c5cfce7e80750771808f56fee66b98/pybase64-1.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1237f66c54357d325390da60aa5e21c6918fbcd1bf527acb9c1f4188c62cb7d5", size = 70966, upload-time = "2025-07-27T13:04:04.361Z" }, 1459 + { url = "https://files.pythonhosted.org/packages/ad/20/1b2fdd98b4ba36008419668c813025758214c543e362c66c49214ecd1127/pybase64-1.4.2-cp313-cp313-win32.whl", hash = "sha256:b0b851eb4f801d16040047f6889cca5e9dfa102b3e33f68934d12511245cef86", size = 33681, upload-time = "2025-07-27T13:04:06.126Z" }, 1460 + { url = "https://files.pythonhosted.org/packages/ff/64/3df4067d169c047054889f34b5a946cbe3785bca43404b93c962a5461a41/pybase64-1.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:19541c6e26d17d9522c02680fe242206ae05df659c82a657aabadf209cd4c6c7", size = 35822, upload-time = "2025-07-27T13:04:07.752Z" }, 1461 + { url = "https://files.pythonhosted.org/packages/d1/fd/db505188adf812e60ee923f196f9deddd8a1895b2b29b37f5db94afc3b1c/pybase64-1.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:77a191863d576c0a5dd81f8a568a5ca15597cc980ae809dce62c717c8d42d8aa", size = 30899, upload-time = "2025-07-27T13:04:09.062Z" }, 1462 + { url = "https://files.pythonhosted.org/packages/d9/27/5f5fecd206ec1e06e1608a380af18dcb76a6ab08ade6597a3251502dcdb2/pybase64-1.4.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2e194bbabe3fdf9e47ba9f3e157394efe0849eb226df76432126239b3f44992c", size = 38677, upload-time = "2025-07-27T13:04:10.334Z" }, 1463 + { url = "https://files.pythonhosted.org/packages/bf/0f/abe4b5a28529ef5f74e8348fa6a9ef27d7d75fbd98103d7664cf485b7d8f/pybase64-1.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:39aef1dadf4a004f11dd09e703abaf6528a87c8dbd39c448bb8aebdc0a08c1be", size = 32066, upload-time = "2025-07-27T13:04:11.641Z" }, 1464 + { url = "https://files.pythonhosted.org/packages/ac/7e/ea0ce6a7155cada5526017ec588b6d6185adea4bf9331565272f4ef583c2/pybase64-1.4.2-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:91cb920c7143e36ec8217031282c8651da3b2206d70343f068fac0e7f073b7f9", size = 72300, upload-time = "2025-07-27T13:04:12.969Z" }, 1465 + { url = "https://files.pythonhosted.org/packages/45/2d/e64c7a056c9ec48dfe130d1295e47a8c2b19c3984488fc08e5eaa1e86c88/pybase64-1.4.2-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6958631143fb9e71f9842000da042ec2f6686506b6706e2dfda29e97925f6aa0", size = 75520, upload-time = "2025-07-27T13:04:14.374Z" }, 1466 + { url = "https://files.pythonhosted.org/packages/43/e0/e5f93b2e1cb0751a22713c4baa6c6eaf5f307385e369180486c8316ed21e/pybase64-1.4.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35f14141ef3f1ac70d963950a278a2593af66fe5a1c7a208e185ca6278fa25", size = 65384, upload-time = "2025-07-27T13:04:16.204Z" }, 1467 + { url = "https://files.pythonhosted.org/packages/ff/23/8c645a1113ad88a1c6a3d0e825e93ef8b74ad3175148767853a0a4d7626e/pybase64-1.4.2-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5d949d2d677859c3a8507e1b21432a039d2b995e0bd3fe307052b6ded80f207a", size = 60471, upload-time = "2025-07-27T13:04:17.947Z" }, 1468 + { url = "https://files.pythonhosted.org/packages/8b/81/edd0f7d8b0526b91730a0dd4ce6b4c8be2136cd69d424afe36235d2d2a06/pybase64-1.4.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:09caacdd3e15fe7253a67781edd10a6a918befab0052a2a3c215fe5d1f150269", size = 63945, upload-time = "2025-07-27T13:04:19.383Z" }, 1469 + { url = "https://files.pythonhosted.org/packages/a5/a5/edc224cd821fd65100b7af7c7e16b8f699916f8c0226c9c97bbae5a75e71/pybase64-1.4.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:e44b0e793b23f28ea0f15a9754bd0c960102a2ac4bccb8fafdedbd4cc4d235c0", size = 64858, upload-time = "2025-07-27T13:04:20.807Z" }, 1470 + { url = "https://files.pythonhosted.org/packages/11/3b/92853f968f1af7e42b7e54d21bdd319097b367e7dffa2ca20787361df74c/pybase64-1.4.2-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:849f274d0bcb90fc6f642c39274082724d108e41b15f3a17864282bd41fc71d5", size = 58557, upload-time = "2025-07-27T13:04:22.229Z" }, 1471 + { url = "https://files.pythonhosted.org/packages/76/09/0ec6bd2b2303b0ea5c6da7535edc9a608092075ef8c0cdd96e3e726cd687/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:528dba7ef1357bd7ce1aea143084501f47f5dd0fff7937d3906a68565aa59cfe", size = 63624, upload-time = "2025-07-27T13:04:23.952Z" }, 1472 + { url = "https://files.pythonhosted.org/packages/73/6e/52cb1ced2a517a3118b2e739e9417432049013ac7afa15d790103059e8e4/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:1da54be743d9a68671700cfe56c3ab8c26e8f2f5cc34eface905c55bc3a9af94", size = 56174, upload-time = "2025-07-27T13:04:25.419Z" }, 1473 + { url = "https://files.pythonhosted.org/packages/5b/9d/820fe79347467e48af985fe46180e1dd28e698ade7317bebd66de8a143f5/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9b07c0406c3eaa7014499b0aacafb21a6d1146cfaa85d56f0aa02e6d542ee8f3", size = 72640, upload-time = "2025-07-27T13:04:26.824Z" }, 1474 + { url = "https://files.pythonhosted.org/packages/53/58/e863e10d08361e694935c815b73faad7e1ab03f99ae154d86c4e2f331896/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:312f2aa4cf5d199a97fbcaee75d2e59ebbaafcd091993eb373b43683498cdacb", size = 62453, upload-time = "2025-07-27T13:04:28.562Z" }, 1475 + { url = "https://files.pythonhosted.org/packages/95/f0/c392c4ac8ccb7a34b28377c21faa2395313e3c676d76c382642e19a20703/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad59362fc267bf15498a318c9e076686e4beeb0dfe09b457fabbc2b32468b97a", size = 58103, upload-time = "2025-07-27T13:04:29.996Z" }, 1476 + { url = "https://files.pythonhosted.org/packages/32/30/00ab21316e7df8f526aa3e3dc06f74de6711d51c65b020575d0105a025b2/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:01593bd064e7dcd6c86d04e94e44acfe364049500c20ac68ca1e708fbb2ca970", size = 60779, upload-time = "2025-07-27T13:04:31.549Z" }, 1477 + { url = "https://files.pythonhosted.org/packages/a6/65/114ca81839b1805ce4a2b7d58bc16e95634734a2059991f6382fc71caf3e/pybase64-1.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5b81547ad8ea271c79fdf10da89a1e9313cb15edcba2a17adf8871735e9c02a0", size = 74684, upload-time = "2025-07-27T13:04:32.976Z" }, 1478 + { url = "https://files.pythonhosted.org/packages/54/8f/aa9d445b9bb693b8f6bb1456bd6d8576d79b7a63bf6c69af3a539235b15f/pybase64-1.4.2-cp313-cp313t-win32.whl", hash = "sha256:7edbe70b5654545a37e6e6b02de738303b1bbdfcde67f6cfec374cfb5cc4099e", size = 33961, upload-time = "2025-07-27T13:04:34.806Z" }, 1479 + { url = "https://files.pythonhosted.org/packages/0e/e5/da37cfb173c646fd4fc7c6aae2bc41d40de2ee49529854af8f4e6f498b45/pybase64-1.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:385690addf87c25d6366fab5d8ff512eed8a7ecb18da9e8152af1c789162f208", size = 36199, upload-time = "2025-07-27T13:04:36.223Z" }, 1480 + { url = "https://files.pythonhosted.org/packages/66/3e/1eb68fb7d00f2cec8bd9838e2a30d183d6724ae06e745fd6e65216f170ff/pybase64-1.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c2070d0aa88580f57fe15ca88b09f162e604d19282915a95a3795b5d3c1c05b5", size = 31221, upload-time = "2025-07-27T13:04:37.704Z" }, 1481 + { url = "https://files.pythonhosted.org/packages/99/bf/00a87d951473ce96c8c08af22b6983e681bfabdb78dd2dcf7ee58eac0932/pybase64-1.4.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:4157ad277a32cf4f02a975dffc62a3c67d73dfa4609b2c1978ef47e722b18b8e", size = 30924, upload-time = "2025-07-27T13:04:39.189Z" }, 1482 + { url = "https://files.pythonhosted.org/packages/ae/43/dee58c9d60e60e6fb32dc6da722d84592e22f13c277297eb4ce6baf99a99/pybase64-1.4.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e113267dc349cf624eb4f4fbf53fd77835e1aa048ac6877399af426aab435757", size = 31390, upload-time = "2025-07-27T13:04:40.995Z" }, 1483 + { url = "https://files.pythonhosted.org/packages/e1/11/b28906fc2e330b8b1ab4bc845a7bef808b8506734e90ed79c6062b095112/pybase64-1.4.2-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:cea5aaf218fd9c5c23afacfe86fd4464dfedc1a0316dd3b5b4075b068cc67df0", size = 38212, upload-time = "2025-07-27T13:04:42.729Z" }, 1484 + { url = "https://files.pythonhosted.org/packages/24/9e/868d1e104413d14b19feaf934fc7fad4ef5b18946385f8bb79684af40f24/pybase64-1.4.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:41213497abbd770435c7a9c8123fb02b93709ac4cf60155cd5aefc5f3042b600", size = 38303, upload-time = "2025-07-27T13:04:44.095Z" }, 1485 + { url = "https://files.pythonhosted.org/packages/a3/73/f7eac96ca505df0600280d6bfc671a9e2e2f947c2b04b12a70e36412f7eb/pybase64-1.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c8b522df7ee00f2ac1993ccd5e1f6608ae7482de3907668c2ff96a83ef213925", size = 31669, upload-time = "2025-07-27T13:04:45.845Z" }, 1486 + { url = "https://files.pythonhosted.org/packages/c6/43/8e18bea4fd455100112d6a73a83702843f067ef9b9272485b6bdfd9ed2f0/pybase64-1.4.2-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:06725022e540c5b098b978a0418ca979773e2cbdbb76f10bd97536f2ad1c5b49", size = 68452, upload-time = "2025-07-27T13:04:47.788Z" }, 1487 + { url = "https://files.pythonhosted.org/packages/e4/2e/851eb51284b97354ee5dfa1309624ab90920696e91a33cd85b13d20cc5c1/pybase64-1.4.2-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a3e54dcf0d0305ec88473c9d0009f698cabf86f88a8a10090efeff2879c421bb", size = 71674, upload-time = "2025-07-27T13:04:49.294Z" }, 1488 + { url = "https://files.pythonhosted.org/packages/57/0d/5cf1e5dc64aec8db43e8dee4e4046856d639a72bcb0fb3e716be42ced5f1/pybase64-1.4.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67675cee727a60dc91173d2790206f01aa3c7b3fbccfa84fd5c1e3d883fe6caa", size = 60027, upload-time = "2025-07-27T13:04:50.769Z" }, 1489 + { url = "https://files.pythonhosted.org/packages/a4/8e/3479266bc0e65f6cc48b3938d4a83bff045330649869d950a378f2ddece0/pybase64-1.4.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:753da25d4fd20be7bda2746f545935773beea12d5cb5ec56ec2d2960796477b1", size = 56461, upload-time = "2025-07-27T13:04:52.37Z" }, 1490 + { url = "https://files.pythonhosted.org/packages/20/b6/f2b6cf59106dd78bae8717302be5b814cec33293504ad409a2eb752ad60c/pybase64-1.4.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a78c768ce4ca550885246d14babdb8923e0f4a848dfaaeb63c38fc99e7ea4052", size = 59446, upload-time = "2025-07-27T13:04:53.967Z" }, 1491 + { url = "https://files.pythonhosted.org/packages/16/70/3417797dfccdfdd0a54e4ad17c15b0624f0fc2d6a362210f229f5c4e8fd0/pybase64-1.4.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:51b17f36d890c92f0618fb1c8db2ccc25e6ed07afa505bab616396fc9b0b0492", size = 60350, upload-time = "2025-07-27T13:04:55.881Z" }, 1492 + { url = "https://files.pythonhosted.org/packages/a0/c6/6e4269dd98d150ae95d321b311a345eae0f7fd459d97901b4a586d7513bb/pybase64-1.4.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f92218d667049ab4f65d54fa043a88ffdb2f07fff1f868789ef705a5221de7ec", size = 54989, upload-time = "2025-07-27T13:04:57.436Z" }, 1493 + { url = "https://files.pythonhosted.org/packages/f9/e8/18c1b0c255f964fafd0412b0d5a163aad588aeccb8f84b9bf9c8611d80f6/pybase64-1.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3547b3d1499919a06491b3f879a19fbe206af2bd1a424ecbb4e601eb2bd11fea", size = 58724, upload-time = "2025-07-27T13:04:59.406Z" }, 1494 + { url = "https://files.pythonhosted.org/packages/b1/ad/ddfbd2125fc20b94865fb232b2e9105376fa16eee492e4b7786d42a86cbf/pybase64-1.4.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:958af7b0e09ddeb13e8c2330767c47b556b1ade19c35370f6451d139cde9f2a9", size = 52285, upload-time = "2025-07-27T13:05:01.198Z" }, 1495 + { url = "https://files.pythonhosted.org/packages/b6/4c/b9d4ec9224add33c84b925a03d1a53cd4106efb449ea8e0ae7795fed7bf7/pybase64-1.4.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4facc57f6671e2229a385a97a618273e7be36a9ea0a9d1c1b9347f14d19ceba8", size = 69036, upload-time = "2025-07-27T13:05:03.109Z" }, 1496 + { url = "https://files.pythonhosted.org/packages/92/38/7b96794da77bed3d9b4fea40f14ae563648fba83a696e7602fabe60c0eb7/pybase64-1.4.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a32fc57d05d73a7c9b0ca95e9e265e21cf734195dc6873829a890058c35f5cfd", size = 57938, upload-time = "2025-07-27T13:05:04.744Z" }, 1497 + { url = "https://files.pythonhosted.org/packages/eb/c5/ae8bbce3c322d1b074e79f51f5df95961fe90cb8748df66c6bc97616e974/pybase64-1.4.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3dc853243c81ce89cc7318e6946f860df28ddb7cd2a0648b981652d9ad09ee5a", size = 54474, upload-time = "2025-07-27T13:05:06.662Z" }, 1498 + { url = "https://files.pythonhosted.org/packages/15/9a/c09887c4bb1b43c03fc352e2671ef20c6686c6942a99106a45270ee5b840/pybase64-1.4.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0e6d863a86b3e7bc6ac9bd659bebda4501b9da842521111b0b0e54eb51295df5", size = 56533, upload-time = "2025-07-27T13:05:08.368Z" }, 1499 + { url = "https://files.pythonhosted.org/packages/4f/0f/d5114d63d35d085639606a880cb06e2322841cd4b213adfc14d545c1186f/pybase64-1.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6579475140ff2067903725d8aca47f5747bcb211597a1edd60b58f6d90ada2bd", size = 71030, upload-time = "2025-07-27T13:05:10.3Z" }, 1500 + { url = "https://files.pythonhosted.org/packages/40/0e/fe6f1ed22ea52eb99f490a8441815ba21de288f4351aeef4968d71d20d2d/pybase64-1.4.2-cp314-cp314-win32.whl", hash = "sha256:373897f728d7b4f241a1f803ac732c27b6945d26d86b2741ad9b75c802e4e378", size = 34174, upload-time = "2025-07-27T13:05:12.254Z" }, 1501 + { url = "https://files.pythonhosted.org/packages/71/46/0e15bea52ffc63e8ae7935e945accbaf635e0aefa26d3e31fdf9bc9dcd01/pybase64-1.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:1afe3361344617d298c1d08bc657ef56d0f702d6b72cb65d968b2771017935aa", size = 36308, upload-time = "2025-07-27T13:05:13.898Z" }, 1502 + { url = "https://files.pythonhosted.org/packages/4f/dc/55849fee2577bda77c1e078da04cc9237e8e474a8c8308deb702a26f2511/pybase64-1.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:f131c9360babe522f3d90f34da3f827cba80318125cf18d66f2ee27e3730e8c4", size = 31341, upload-time = "2025-07-27T13:05:15.553Z" }, 1503 + { url = "https://files.pythonhosted.org/packages/39/44/c69d088e28b25e70ac742b6789cde038473815b2a69345c4bae82d5e244d/pybase64-1.4.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2583ac304131c1bd6e3120b0179333610f18816000db77c0a2dd6da1364722a8", size = 38678, upload-time = "2025-07-27T13:05:17.544Z" }, 1504 + { url = "https://files.pythonhosted.org/packages/00/93/2860ec067497b9cbb06242f96d44caebbd9eed32174e4eb8c1ffef760f94/pybase64-1.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:75a8116be4ea4cdd30a5c4f1a6f3b038e0d457eb03c8a2685d8ce2aa00ef8f92", size = 32066, upload-time = "2025-07-27T13:05:19.18Z" }, 1505 + { url = "https://files.pythonhosted.org/packages/d3/55/1e96249a38759332e8a01b31c370d88c60ceaf44692eb6ba4f0f451ee496/pybase64-1.4.2-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:217ea776a098d7c08668e5526b9764f5048bbfd28cac86834217ddfe76a4e3c4", size = 72465, upload-time = "2025-07-27T13:05:20.866Z" }, 1506 + { url = "https://files.pythonhosted.org/packages/6d/ab/0f468605b899f3e35dbb7423fba3ff98aeed1ec16abb02428468494a58f4/pybase64-1.4.2-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ec14683e343c95b14248cdfdfa78c052582be7a3865fd570aa7cffa5ab5cf37", size = 75693, upload-time = "2025-07-27T13:05:22.896Z" }, 1507 + { url = "https://files.pythonhosted.org/packages/91/d1/9980a0159b699e2489baba05b71b7c953b29249118ba06fdbb3e9ea1b9b5/pybase64-1.4.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:480ecf21e1e956c5a10d3cf7b3b7e75bce3f9328cf08c101e4aab1925d879f34", size = 65577, upload-time = "2025-07-27T13:05:25Z" }, 1508 + { url = "https://files.pythonhosted.org/packages/16/86/b27e7b95f9863d245c0179a7245582eda3d262669d8f822777364d8fd7d5/pybase64-1.4.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1fe1ebdc55e9447142e2f6658944aadfb5a4fbf03dbd509be34182585515ecc1", size = 60662, upload-time = "2025-07-27T13:05:27.138Z" }, 1509 + { url = "https://files.pythonhosted.org/packages/28/87/a7f0dde0abc26bfbee761f1d3558eb4b139f33ddd9fe1f6825ffa7daa22d/pybase64-1.4.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c793a2b06753accdaf5e1a8bbe5d800aab2406919e5008174f989a1ca0081411", size = 64179, upload-time = "2025-07-27T13:05:28.996Z" }, 1510 + { url = "https://files.pythonhosted.org/packages/1e/88/5d6fa1c60e1363b4cac4c396978f39e9df4689e75225d7d9c0a5998e3a14/pybase64-1.4.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6acae6e1d1f7ebe40165f08076c7a73692b2bf9046fefe673f350536e007f556", size = 64968, upload-time = "2025-07-27T13:05:30.818Z" }, 1511 + { url = "https://files.pythonhosted.org/packages/20/6e/2ed585af5b2211040445d9849326dd2445320c9316268794f5453cfbaf30/pybase64-1.4.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:88b91cd0949358aadcea75f8de5afbcf3c8c5fb9ec82325bd24285b7119cf56e", size = 58738, upload-time = "2025-07-27T13:05:32.629Z" }, 1512 + { url = "https://files.pythonhosted.org/packages/ce/94/e2960b56322eabb3fbf303fc5a72e6444594c1b90035f3975c6fe666db5c/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:53316587e1b1f47a11a5ff068d3cbd4a3911c291f2aec14882734973684871b2", size = 63802, upload-time = "2025-07-27T13:05:34.687Z" }, 1513 + { url = "https://files.pythonhosted.org/packages/95/47/312139d764c223f534f751528ce3802887c279125eac64f71cd3b4e05abc/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:caa7f20f43d00602cf9043b5ba758d54f5c41707d3709b2a5fac17361579c53c", size = 56341, upload-time = "2025-07-27T13:05:36.554Z" }, 1514 + { url = "https://files.pythonhosted.org/packages/3f/d7/aec9a6ed53b128dac32f8768b646ca5730c88eef80934054d7fa7d02f3ef/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2d93817e24fdd79c534ed97705df855af6f1d2535ceb8dfa80da9de75482a8d7", size = 72838, upload-time = "2025-07-27T13:05:38.459Z" }, 1515 + { url = "https://files.pythonhosted.org/packages/e3/a8/6ccc54c5f1f7c3450ad7c56da10c0f131d85ebe069ea6952b5b42f2e92d9/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:63cd769b51474d8d08f7f2ce73b30380d9b4078ec92ea6b348ea20ed1e1af88a", size = 62633, upload-time = "2025-07-27T13:05:40.624Z" }, 1516 + { url = "https://files.pythonhosted.org/packages/34/22/2b9d89f8ff6f2a01d6d6a88664b20a4817049cfc3f2c62caca040706660c/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cd07e6a9993c392ec8eb03912a43c6a6b21b2deb79ee0d606700fe276e9a576f", size = 58282, upload-time = "2025-07-27T13:05:42.565Z" }, 1517 + { url = "https://files.pythonhosted.org/packages/b2/14/dbf6266177532a6a11804ac080ebffcee272f491b92820c39886ee20f201/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:6a8944e8194adff4668350504bc6b7dbde2dab9244c88d99c491657d145b5af5", size = 60948, upload-time = "2025-07-27T13:05:44.48Z" }, 1518 + { url = "https://files.pythonhosted.org/packages/fd/7a/b2ae9046a66dd5746cd72836a41386517b1680bea5ce02f2b4f1c9ebc688/pybase64-1.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:04ab398ec4b6a212af57f6a21a6336d5a1d754ff4ccb215951366ab9080481b2", size = 74854, upload-time = "2025-07-27T13:05:46.416Z" }, 1519 + { url = "https://files.pythonhosted.org/packages/ef/7e/9856f6d6c38a7b730e001123d2d9fa816b8b1a45f0cdee1d509d5947b047/pybase64-1.4.2-cp314-cp314t-win32.whl", hash = "sha256:3b9201ecdcb1c3e23be4caebd6393a4e6615bd0722528f5413b58e22e3792dd3", size = 34490, upload-time = "2025-07-27T13:05:48.304Z" }, 1520 + { url = "https://files.pythonhosted.org/packages/c7/38/8523a9dc1ec8704dedbe5ccc95192ae9a7585f7eec85cc62946fe3cacd32/pybase64-1.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:36e9b0cad8197136d73904ef5a71d843381d063fd528c5ab203fc4990264f682", size = 36680, upload-time = "2025-07-27T13:05:50.264Z" }, 1521 + { url = "https://files.pythonhosted.org/packages/3c/52/5600104ef7b85f89fb8ec54f73504ead3f6f0294027e08d281f3cafb5c1a/pybase64-1.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f25140496b02db0e7401567cd869fb13b4c8118bf5c2428592ec339987146d8b", size = 31600, upload-time = "2025-07-27T13:05:52.24Z" }, 1522 + ] 1523 + 1524 + [[package]] 1370 1525 name = "pycparser" 1371 1526 version = "2.22" 1372 1527 source = { registry = "https://pypi.org/simple" } ··· 1978 2133 sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } 1979 2134 wheels = [ 1980 2135 { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, 2136 + ] 2137 + 2138 + [[package]] 2139 + name = "turbopuffer" 2140 + version = "1.3.0" 2141 + source = { registry = "https://pypi.org/simple" } 2142 + dependencies = [ 2143 + { name = "aiohttp" }, 2144 + { name = "anyio" }, 2145 + { name = "distro" }, 2146 + { name = "httpx" }, 2147 + { name = "pybase64" }, 2148 + { name = "pydantic" }, 2149 + { name = "sniffio" }, 2150 + { name = "typing-extensions" }, 2151 + ] 2152 + sdist = { url = "https://files.pythonhosted.org/packages/3c/91/f347d5dd23eef82688537549cc032347842bb9d6879d772a0a5a9548ddd0/turbopuffer-1.3.0.tar.gz", hash = "sha256:f1429b3073c00f85d91f2fb06666ce3c0275a2da1b8dc7f0f8d8c0b7207bee56", size = 142443, upload-time = "2025-09-24T21:12:36.46Z" } 2153 + wheels = [ 2154 + { url = "https://files.pythonhosted.org/packages/5f/9d/d54419a42c45fe5758640a5228aae71a7e825205755e68fa6494fef6eea3/turbopuffer-1.3.0-py3-none-any.whl", hash = "sha256:f373ad0bca148ef643ddcbeb5665c2c50cbfab0b9e2e5a11c86afe16597a4382", size = 109674, upload-time = "2025-09-24T21:12:34.995Z" }, 1981 2155 ] 1982 2156 1983 2157 [[package]]