a digital entity named phi that roams bsky

feat: add episodic world memory, typed deps, url checker, suppress test warnings

- phi-episodic namespace in turbopuffer for world knowledge (remember + search_my_memory tools)
- episodic context auto-injected into conversation prompts
- replace naked dict deps with PhiDeps dataclass
- check_urls tool for link verification before sharing
- suppress logfire/otel warnings in pytest config
- memory_inspect.py --episodic flag

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+186 -15
+2
personalities/phi.md
··· 53 ## capabilities 54 55 - remember facts about people via episodic memory (automatically extracted after conversations) 56 - see thread context when replying 57 - use pdsx tools for atproto record operations (create, list, get, update, delete any record type) 58 - search memory for more context about a user when needed
··· 53 ## capabilities 54 55 - remember facts about people via episodic memory (automatically extracted after conversations) 56 + - remember things about the world via `remember` tool (facts, patterns, events worth recalling) 57 + - search own memory via `search_my_memory` for things previously learned 58 - see thread context when replying 59 - use pdsx tools for atproto record operations (create, list, get, update, delete any record type) 60 - search memory for more context about a user when needed
+5
pyproject.toml
··· 27 [tool.pytest.ini_options] 28 asyncio_mode = "auto" 29 asyncio_default_fixture_loop_scope = "function" 30 31 [dependency-groups] 32 dev = [
··· 27 [tool.pytest.ini_options] 28 asyncio_mode = "auto" 29 asyncio_default_fixture_loop_scope = "function" 30 + filterwarnings = [ 31 + "ignore::logfire._internal.config.LogfireNotConfiguredWarning", 32 + "ignore::DeprecationWarning:abc", 33 + "ignore::DeprecationWarning:opentelemetry", 34 + ] 35 36 [dependency-groups] 37 dev = [
+38
scripts/memory_inspect.py
··· 5 uv run scripts/memory_inspect.py USER_HANDLE # dump observations + interactions for a user 6 uv run scripts/memory_inspect.py USER_HANDLE --delete ID # delete a specific row by ID 7 uv run scripts/memory_inspect.py USER_HANDLE --purge-observations # delete ALL observations for a user 8 """ 9 10 import argparse ··· 141 print(f"\ndeleted {len(ids)} observations") 142 143 144 def main(): 145 parser = argparse.ArgumentParser(description="Inspect and prune phi memories") 146 parser.add_argument("handle", nargs="?", help="User handle to inspect") 147 parser.add_argument("--delete", metavar="ID", help="Delete a specific row by ID") 148 parser.add_argument("--purge-observations", action="store_true", help="Delete all observations for a user") 149 args = parser.parse_args() 150 151 client = get_client() 152 153 if not args.handle: 154 list_namespaces(client)
··· 5 uv run scripts/memory_inspect.py USER_HANDLE # dump observations + interactions for a user 6 uv run scripts/memory_inspect.py USER_HANDLE --delete ID # delete a specific row by ID 7 uv run scripts/memory_inspect.py USER_HANDLE --purge-observations # delete ALL observations for a user 8 + uv run scripts/memory_inspect.py --episodic # dump phi's episodic memories 9 """ 10 11 import argparse ··· 142 print(f"\ndeleted {len(ids)} observations") 143 144 145 + def dump_episodic(client: Turbopuffer): 146 + """Dump phi's episodic memories.""" 147 + ns = client.namespace("phi-episodic") 148 + 149 + try: 150 + response = ns.query( 151 + rank_by=("vector", "ANN", [0.5] * 1536), 152 + top_k=200, 153 + include_attributes=["content", "tags", "source", "created_at"], 154 + ) 155 + except Exception as e: 156 + if "was not found" in str(e): 157 + print("no episodic memories found (namespace doesn't exist yet)") 158 + return 159 + raise 160 + 161 + if not response.rows: 162 + print("no episodic memories found") 163 + return 164 + 165 + print(f"=== episodic memories ({len(response.rows)}) ===\n") 166 + for row in response.rows: 167 + tags = getattr(row, "tags", []) 168 + source = getattr(row, "source", "unknown") 169 + tag_str = f" [{', '.join(tags)}]" if tags else "" 170 + print(f" [{row.id}] {row.content}{tag_str}") 171 + print(f" source: {source} created: {getattr(row, 'created_at', '')}") 172 + print() 173 + 174 + print(f"total: {len(response.rows)} episodic memories") 175 + 176 + 177 def main(): 178 parser = argparse.ArgumentParser(description="Inspect and prune phi memories") 179 parser.add_argument("handle", nargs="?", help="User handle to inspect") 180 parser.add_argument("--delete", metavar="ID", help="Delete a specific row by ID") 181 parser.add_argument("--purge-observations", action="store_true", help="Delete all observations for a user") 182 + parser.add_argument("--episodic", action="store_true", help="Dump phi's episodic (world) memories") 183 args = parser.parse_args() 184 185 client = get_client() 186 + 187 + if args.episodic: 188 + dump_episodic(client) 189 + return 190 191 if not args.handle: 192 list_namespaces(client)
+76 -14
src/bot/agent.py
··· 1 """MCP-enabled agent for phi with structured memory.""" 2 3 import logging 4 import os 5 from pathlib import Path 6 7 import httpx ··· 13 from bot.memory import NamespaceMemory 14 15 logger = logging.getLogger("bot.agent") 16 17 18 class Response(BaseModel): ··· 63 ) 64 65 # Create PydanticAI agent with MCP tools 66 - self.agent = Agent[dict, Response]( 67 name="phi", 68 model="anthropic:claude-3-5-haiku-latest", 69 system_prompt=self.base_personality, 70 output_type=Response, 71 - deps_type=dict, 72 toolsets=[pdsx_mcp, pub_search_mcp], 73 ) 74 75 # Register search_memory tool on the agent 76 @self.agent.tool 77 - async def search_memory(ctx: RunContext[dict], query: str) -> str: 78 """Search your memory for information about the current user. Use this when you want more context about past interactions or facts you know about them.""" 79 - handle = ctx.deps.get("author_handle") 80 - memory = ctx.deps.get("memory") 81 - if not handle or not memory: 82 return "memory not available" 83 84 - results = await memory.search(handle, query, top_k=10) 85 if not results: 86 return "no relevant memories found" 87 ··· 95 return "\n".join(parts) 96 97 @self.agent.tool 98 - async def search_posts(ctx: RunContext[dict], query: str, limit: int = 10) -> str: 99 """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 100 from bot.core.atproto_client import bot_client 101 ··· 117 return f"search failed: {e}" 118 119 @self.agent.tool 120 - async def get_trending(ctx: RunContext[dict]) -> str: 121 """Get what's currently trending on Bluesky. Returns entity-level trends from the firehose (via coral) and official Bluesky trending topics. Use this when someone asks about current events, what people are talking about, or when you want timely context.""" 122 parts: list[str] = [] 123 ··· 165 166 return "\n\n".join(parts) if parts else "no trending data available" 167 168 logger.info("phi agent initialized with pdsx + pub-search mcp tools") 169 170 async def process_mention( ··· 177 """Process a mention with structured memory context.""" 178 # Build context from memory if available 179 memory_context = "" 180 if self.memory: 181 try: 182 memory_context = await self.memory.build_user_context( ··· 186 except Exception as e: 187 logger.warning(f"failed to retrieve memories: {e}") 188 189 # Build full prompt with clearly labeled context sections 190 prompt_parts = [] 191 ··· 195 if memory_context: 196 prompt_parts.append(f"[PAST CONTEXT WITH @{author_handle}]:\n{memory_context}") 197 198 prompt_parts.append(f"\n[NEW MESSAGE]:\n@{author_handle}: {mention_text}") 199 prompt = "\n\n".join(prompt_parts) 200 201 # Run agent with MCP tools + search_memory available 202 logger.info(f"processing mention from @{author_handle}: {mention_text[:80]}") 203 - deps = { 204 - "thread_uri": thread_uri, 205 - "author_handle": author_handle, 206 - "memory": self.memory, 207 - } 208 result = await self.agent.run(prompt, deps=deps) 209 logger.info(f"agent decided: {result.output.action}" + (f" - {result.output.text[:80]}" if result.output.text else "") + (f" ({result.output.reason})" if result.output.reason else "")) 210
··· 1 """MCP-enabled agent for phi with structured memory.""" 2 3 + import asyncio 4 import logging 5 import os 6 + from dataclasses import dataclass 7 from pathlib import Path 8 9 import httpx ··· 15 from bot.memory import NamespaceMemory 16 17 logger = logging.getLogger("bot.agent") 18 + 19 + 20 + @dataclass 21 + class PhiDeps: 22 + """Typed dependencies passed to every tool via RunContext.""" 23 + 24 + author_handle: str 25 + memory: NamespaceMemory | None = None 26 + thread_uri: str | None = None 27 28 29 class Response(BaseModel): ··· 74 ) 75 76 # Create PydanticAI agent with MCP tools 77 + self.agent = Agent[PhiDeps, Response]( 78 name="phi", 79 model="anthropic:claude-3-5-haiku-latest", 80 system_prompt=self.base_personality, 81 output_type=Response, 82 + deps_type=PhiDeps, 83 toolsets=[pdsx_mcp, pub_search_mcp], 84 ) 85 86 # Register search_memory tool on the agent 87 @self.agent.tool 88 + async def search_memory(ctx: RunContext[PhiDeps], query: str) -> str: 89 """Search your memory for information about the current user. Use this when you want more context about past interactions or facts you know about them.""" 90 + if not ctx.deps.memory: 91 return "memory not available" 92 93 + results = await ctx.deps.memory.search(ctx.deps.author_handle, query, top_k=10) 94 if not results: 95 return "no relevant memories found" 96 ··· 104 return "\n".join(parts) 105 106 @self.agent.tool 107 + async def remember(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 108 + """Store something you learned or found interesting in your memory. 109 + Use sparingly — only for facts worth recalling in future conversations.""" 110 + if not ctx.deps.memory: 111 + return "memory not available" 112 + await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 113 + return f"remembered: {content[:100]}" 114 + 115 + @self.agent.tool 116 + async def search_my_memory(ctx: RunContext[PhiDeps], query: str) -> str: 117 + """Search your own memory for things you've previously learned about the world.""" 118 + if not ctx.deps.memory: 119 + return "memory not available" 120 + results = await ctx.deps.memory.search_episodic(query, top_k=10) 121 + if not results: 122 + return "no relevant memories found" 123 + parts = [] 124 + for r in results: 125 + tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 126 + parts.append(f"{r['content']}{tags}") 127 + return "\n".join(parts) 128 + 129 + @self.agent.tool 130 + async def search_posts(ctx: RunContext[PhiDeps], query: str, limit: int = 10) -> str: 131 """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 132 from bot.core.atproto_client import bot_client 133 ··· 149 return f"search failed: {e}" 150 151 @self.agent.tool 152 + async def get_trending(ctx: RunContext[PhiDeps]) -> str: 153 """Get what's currently trending on Bluesky. Returns entity-level trends from the firehose (via coral) and official Bluesky trending topics. Use this when someone asks about current events, what people are talking about, or when you want timely context.""" 154 parts: list[str] = [] 155 ··· 197 198 return "\n\n".join(parts) if parts else "no trending data available" 199 200 + @self.agent.tool 201 + async def check_urls(ctx: RunContext[PhiDeps], urls: list[str]) -> str: 202 + """Check whether URLs are reachable. Use this before sharing links to verify they actually work. Accepts full URLs (https://...) or bare domains (example.com/path).""" 203 + 204 + async def _check(client: httpx.AsyncClient, url: str) -> str: 205 + if not url.startswith(("http://", "https://")): 206 + url = f"https://{url}" 207 + try: 208 + r = await client.head(url, follow_redirects=True) 209 + return f"{url} → {r.status_code}" 210 + except httpx.TimeoutException: 211 + return f"{url} → timeout" 212 + except Exception as e: 213 + return f"{url} → error: {type(e).__name__}" 214 + 215 + async with httpx.AsyncClient(timeout=10) as client: 216 + results = await asyncio.gather(*[_check(client, u) for u in urls]) 217 + return "\n".join(results) 218 + 219 logger.info("phi agent initialized with pdsx + pub-search mcp tools") 220 221 async def process_mention( ··· 228 """Process a mention with structured memory context.""" 229 # Build context from memory if available 230 memory_context = "" 231 + episodic_context = "" 232 if self.memory: 233 try: 234 memory_context = await self.memory.build_user_context( ··· 238 except Exception as e: 239 logger.warning(f"failed to retrieve memories: {e}") 240 241 + try: 242 + episodic_context = await self.memory.get_episodic_context(mention_text) 243 + if episodic_context: 244 + logger.info(f"episodic context: {len(episodic_context)} chars") 245 + except Exception as e: 246 + logger.warning(f"failed to retrieve episodic memories: {e}") 247 + 248 # Build full prompt with clearly labeled context sections 249 prompt_parts = [] 250 ··· 254 if memory_context: 255 prompt_parts.append(f"[PAST CONTEXT WITH @{author_handle}]:\n{memory_context}") 256 257 + if episodic_context: 258 + prompt_parts.append(episodic_context) 259 + 260 prompt_parts.append(f"\n[NEW MESSAGE]:\n@{author_handle}: {mention_text}") 261 prompt = "\n\n".join(prompt_parts) 262 263 # Run agent with MCP tools + search_memory available 264 logger.info(f"processing mention from @{author_handle}: {mention_text[:80]}") 265 + deps = PhiDeps( 266 + author_handle=author_handle, 267 + memory=self.memory, 268 + thread_uri=thread_uri, 269 + ) 270 result = await self.agent.run(prompt, deps=deps) 271 logger.info(f"agent decided: {result.output.action}" + (f" - {result.output.text[:80]}" if result.output.text else "") + (f" ({result.output.reason})" if result.output.reason else "")) 272
+1 -1
src/bot/core/profile_manager.py
··· 6 7 logger = logging.getLogger("bot.profile_manager") 8 9 - _ONLINE_SUFFIX = "\n\n🟢 memory, thread context, atproto records, publication search, post search, trending" 10 _OFFLINE_SUFFIX = " • 🔴 offline" 11 _ALL_SUFFIXES = [_ONLINE_SUFFIX, _OFFLINE_SUFFIX] 12
··· 6 7 logger = logging.getLogger("bot.profile_manager") 8 9 + _ONLINE_SUFFIX = "\n\n🟢 user memory, world memory, thread context, atproto records, publication search, post search, trending" 10 _OFFLINE_SUFFIX = " • 🔴 offline" 11 _ALL_SUFFIXES = [_ONLINE_SUFFIX, _OFFLINE_SUFFIX] 12
+64
src/bot/memory/namespace_memory.py
··· 56 ) 57 return _extraction_agent 58 59 USER_NAMESPACE_SCHEMA = { 60 "kind": {"type": "string", "filterable": True}, 61 "content": {"type": "string", "full_text_search": True}, ··· 75 NAMESPACES: ClassVar[dict[str, str]] = { 76 "core": "phi-core", 77 "users": "phi-users", 78 } 79 80 def __init__(self, api_key: str | None = None): ··· 347 if "was not found" in str(e): 348 return [] 349 raise 350 351 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 352 """Post-interaction hook: store interaction then extract observations."""
··· 56 ) 57 return _extraction_agent 58 59 + EPISODIC_SCHEMA = { 60 + "content": {"type": "string", "full_text_search": True}, 61 + "tags": {"type": "[]string", "filterable": True}, 62 + "source": {"type": "string", "filterable": True}, # "tool", "conversation" 63 + "created_at": {"type": "string"}, 64 + } 65 + 66 USER_NAMESPACE_SCHEMA = { 67 "kind": {"type": "string", "filterable": True}, 68 "content": {"type": "string", "full_text_search": True}, ··· 82 NAMESPACES: ClassVar[dict[str, str]] = { 83 "core": "phi-core", 84 "users": "phi-users", 85 + "episodic": "phi-episodic", 86 } 87 88 def __init__(self, api_key: str | None = None): ··· 355 if "was not found" in str(e): 356 return [] 357 raise 358 + 359 + # --- episodic memory (phi's own world knowledge) --- 360 + 361 + async def store_episodic_memory(self, content: str, tags: list[str], source: str = "tool"): 362 + """Store an episodic memory — something phi learned about the world.""" 363 + entry_id = self._generate_id("episodic", source, content) 364 + self.namespaces["episodic"].write( 365 + upsert_rows=[ 366 + { 367 + "id": entry_id, 368 + "vector": await self._get_embedding(content), 369 + "content": content, 370 + "tags": tags, 371 + "source": source, 372 + "created_at": datetime.now().isoformat(), 373 + } 374 + ], 375 + distance_metric="cosine_distance", 376 + schema=EPISODIC_SCHEMA, 377 + ) 378 + logger.info(f"stored episodic memory [{source}]: {content[:80]}") 379 + 380 + async def search_episodic(self, query: str, top_k: int = 10) -> list[dict]: 381 + """Semantic search over phi's episodic memories.""" 382 + try: 383 + query_embedding = await self._get_embedding(query) 384 + response = self.namespaces["episodic"].query( 385 + rank_by=("vector", "ANN", query_embedding), 386 + top_k=top_k, 387 + include_attributes=["content", "tags", "source", "created_at"], 388 + ) 389 + results = [] 390 + if response.rows: 391 + for row in response.rows: 392 + results.append({ 393 + "content": row.content, 394 + "tags": getattr(row, "tags", []), 395 + "source": getattr(row, "source", "unknown"), 396 + "created_at": getattr(row, "created_at", ""), 397 + }) 398 + return results 399 + except Exception as e: 400 + if "was not found" in str(e): 401 + return [] 402 + raise 403 + 404 + async def get_episodic_context(self, query_text: str, top_k: int = 5) -> str: 405 + """Get formatted episodic context for injection into conversation prompt.""" 406 + results = await self.search_episodic(query_text, top_k=top_k) 407 + if not results: 408 + return "" 409 + lines = ["[PHI'S RELEVANT MEMORIES]"] 410 + for r in results: 411 + tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 412 + lines.append(f"- {r['content']}{tags}") 413 + return "\n".join(lines) 414 415 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 416 """Post-interaction hook: store interaction then extract observations."""