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 53 ## capabilities 54 54 55 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 56 58 - see thread context when replying 57 59 - use pdsx tools for atproto record operations (create, list, get, update, delete any record type) 58 60 - search memory for more context about a user when needed
+5
pyproject.toml
··· 27 27 [tool.pytest.ini_options] 28 28 asyncio_mode = "auto" 29 29 asyncio_default_fixture_loop_scope = "function" 30 + filterwarnings = [ 31 + "ignore::logfire._internal.config.LogfireNotConfiguredWarning", 32 + "ignore::DeprecationWarning:abc", 33 + "ignore::DeprecationWarning:opentelemetry", 34 + ] 30 35 31 36 [dependency-groups] 32 37 dev = [
+38
scripts/memory_inspect.py
··· 5 5 uv run scripts/memory_inspect.py USER_HANDLE # dump observations + interactions for a user 6 6 uv run scripts/memory_inspect.py USER_HANDLE --delete ID # delete a specific row by ID 7 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 8 9 """ 9 10 10 11 import argparse ··· 141 142 print(f"\ndeleted {len(ids)} observations") 142 143 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 + 144 177 def main(): 145 178 parser = argparse.ArgumentParser(description="Inspect and prune phi memories") 146 179 parser.add_argument("handle", nargs="?", help="User handle to inspect") 147 180 parser.add_argument("--delete", metavar="ID", help="Delete a specific row by ID") 148 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") 149 183 args = parser.parse_args() 150 184 151 185 client = get_client() 186 + 187 + if args.episodic: 188 + dump_episodic(client) 189 + return 152 190 153 191 if not args.handle: 154 192 list_namespaces(client)
+76 -14
src/bot/agent.py
··· 1 1 """MCP-enabled agent for phi with structured memory.""" 2 2 3 + import asyncio 3 4 import logging 4 5 import os 6 + from dataclasses import dataclass 5 7 from pathlib import Path 6 8 7 9 import httpx ··· 13 15 from bot.memory import NamespaceMemory 14 16 15 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 16 27 17 28 18 29 class Response(BaseModel): ··· 63 74 ) 64 75 65 76 # Create PydanticAI agent with MCP tools 66 - self.agent = Agent[dict, Response]( 77 + self.agent = Agent[PhiDeps, Response]( 67 78 name="phi", 68 79 model="anthropic:claude-3-5-haiku-latest", 69 80 system_prompt=self.base_personality, 70 81 output_type=Response, 71 - deps_type=dict, 82 + deps_type=PhiDeps, 72 83 toolsets=[pdsx_mcp, pub_search_mcp], 73 84 ) 74 85 75 86 # Register search_memory tool on the agent 76 87 @self.agent.tool 77 - async def search_memory(ctx: RunContext[dict], query: str) -> str: 88 + async def search_memory(ctx: RunContext[PhiDeps], query: str) -> str: 78 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.""" 79 - handle = ctx.deps.get("author_handle") 80 - memory = ctx.deps.get("memory") 81 - if not handle or not memory: 90 + if not ctx.deps.memory: 82 91 return "memory not available" 83 92 84 - results = await memory.search(handle, query, top_k=10) 93 + results = await ctx.deps.memory.search(ctx.deps.author_handle, query, top_k=10) 85 94 if not results: 86 95 return "no relevant memories found" 87 96 ··· 95 104 return "\n".join(parts) 96 105 97 106 @self.agent.tool 98 - async def search_posts(ctx: RunContext[dict], query: str, limit: int = 10) -> str: 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: 99 131 """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 100 132 from bot.core.atproto_client import bot_client 101 133 ··· 117 149 return f"search failed: {e}" 118 150 119 151 @self.agent.tool 120 - async def get_trending(ctx: RunContext[dict]) -> str: 152 + async def get_trending(ctx: RunContext[PhiDeps]) -> str: 121 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.""" 122 154 parts: list[str] = [] 123 155 ··· 165 197 166 198 return "\n\n".join(parts) if parts else "no trending data available" 167 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 + 168 219 logger.info("phi agent initialized with pdsx + pub-search mcp tools") 169 220 170 221 async def process_mention( ··· 177 228 """Process a mention with structured memory context.""" 178 229 # Build context from memory if available 179 230 memory_context = "" 231 + episodic_context = "" 180 232 if self.memory: 181 233 try: 182 234 memory_context = await self.memory.build_user_context( ··· 186 238 except Exception as e: 187 239 logger.warning(f"failed to retrieve memories: {e}") 188 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 + 189 248 # Build full prompt with clearly labeled context sections 190 249 prompt_parts = [] 191 250 ··· 195 254 if memory_context: 196 255 prompt_parts.append(f"[PAST CONTEXT WITH @{author_handle}]:\n{memory_context}") 197 256 257 + if episodic_context: 258 + prompt_parts.append(episodic_context) 259 + 198 260 prompt_parts.append(f"\n[NEW MESSAGE]:\n@{author_handle}: {mention_text}") 199 261 prompt = "\n\n".join(prompt_parts) 200 262 201 263 # Run agent with MCP tools + search_memory available 202 264 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 - } 265 + deps = PhiDeps( 266 + author_handle=author_handle, 267 + memory=self.memory, 268 + thread_uri=thread_uri, 269 + ) 208 270 result = await self.agent.run(prompt, deps=deps) 209 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 "")) 210 272
+1 -1
src/bot/core/profile_manager.py
··· 6 6 7 7 logger = logging.getLogger("bot.profile_manager") 8 8 9 - _ONLINE_SUFFIX = "\n\n🟢 memory, thread context, atproto records, publication search, post search, trending" 9 + _ONLINE_SUFFIX = "\n\n🟢 user memory, world memory, thread context, atproto records, publication search, post search, trending" 10 10 _OFFLINE_SUFFIX = " • 🔴 offline" 11 11 _ALL_SUFFIXES = [_ONLINE_SUFFIX, _OFFLINE_SUFFIX] 12 12
+64
src/bot/memory/namespace_memory.py
··· 56 56 ) 57 57 return _extraction_agent 58 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 + 59 66 USER_NAMESPACE_SCHEMA = { 60 67 "kind": {"type": "string", "filterable": True}, 61 68 "content": {"type": "string", "full_text_search": True}, ··· 75 82 NAMESPACES: ClassVar[dict[str, str]] = { 76 83 "core": "phi-core", 77 84 "users": "phi-users", 85 + "episodic": "phi-episodic", 78 86 } 79 87 80 88 def __init__(self, api_key: str | None = None): ··· 347 355 if "was not found" in str(e): 348 356 return [] 349 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) 350 414 351 415 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 352 416 """Post-interaction hook: store interaction then extract observations."""