a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

add greengale blog publishing + split tools into package

- GreenGaleDocument model in types.py with TID generation
- publish_blog_post and list_blog_posts tools (putRecord, title dedup)
- blog guidance in operational instructions
- extract all tools from agent.py into tools/ package (1176 → 398 lines)
- tools/{memory,search,cosmik,feeds,bluesky,blog}.py
- tools/_helpers.py for shared types and utilities

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

+928 -678
+13 -676
src/bot/agent.py
··· 1 1 """MCP-enabled agent for phi with structured memory.""" 2 2 3 - import asyncio 4 3 import contextlib 5 - import ipaddress 6 4 import logging 7 5 import os 8 - import socket 9 6 from collections.abc import Sequence 10 - from dataclasses import dataclass 11 7 from datetime import date 12 8 from pathlib import Path 13 - from urllib.parse import urlparse 14 9 15 - import httpx 16 10 from pydantic import BaseModel, Field 17 11 from pydantic_ai import Agent, ImageUrl, RunContext 18 12 from pydantic_ai.mcp import MCPServerStreamableHTTP 19 13 20 14 from bot.config import settings 21 - from bot.core.atproto_client import bot_client 22 15 from bot.core.graze_client import GrazeClient 23 - from bot.memory import NamespaceMemory 24 - from bot.types import ( 25 - CosmikConnection, 26 - CosmikUrlCard, 27 - UrlContent, 28 - ) 16 + from bot.tools import PhiDeps, _check_services_impl, register_all 29 17 30 18 logger = logging.getLogger("bot.agent") 31 19 32 - EVERGREEN_PROXY = "https://evergreen-proxy.nate-8fe.workers.dev" 33 - SERVICE_CHECKS = [ 34 - {"url": "https://api.plyr.fm/health", "name": "plyr api"}, 35 - {"url": "https://plyr.fm", "name": "plyr frontend"}, 36 - {"url": "https://pds.zzstoatzz.io/xrpc/_health", "name": "PDS"}, 37 - {"url": "https://prefect-server.waow.tech/api/health", "name": "prefect"}, 38 - {"url": "https://prefect-metrics.waow.tech/api/health", "name": "grafana"}, 39 - {"url": "https://relay.waow.tech/xrpc/_health", "name": "indigo relay"}, 40 - {"url": "https://zlay.waow.tech/_health", "name": "zlay"}, 41 - {"url": "https://coral.fly.dev/health", "name": "trending"}, 42 - { 43 - "url": "https://leaflet-search-backend.fly.dev/health", 44 - "name": "standard.site backend", 45 - }, 46 - {"url": "https://pub-search.waow.tech", "name": "pub-search"}, 47 - {"url": "https://typeahead.waow.tech/stats", "name": "typeahead"}, 48 - {"url": "https://zig-bsky-feed.fly.dev/health", "name": "music-feed"}, 49 - {"url": "https://pollz-backend.fly.dev/health", "name": "pollz"}, 50 - ] 51 - 52 - 53 - async def _check_services_impl() -> str: 54 - """Hit the evergreen proxy with all service checks. Returns formatted status.""" 55 - async with httpx.AsyncClient(timeout=30) as client: 56 - try: 57 - r = await client.post( 58 - EVERGREEN_PROXY, 59 - json={"checks": SERVICE_CHECKS}, 60 - ) 61 - r.raise_for_status() 62 - results = r.json() 63 - except Exception as e: 64 - return f"evergreen proxy unreachable: {e}" 65 - 66 - failures: list[str] = [] 67 - healthy: list[str] = [] 68 - 69 - checks = results if isinstance(results, list) else results.get("results", []) 70 - # build name lookup from our request 71 - name_by_url = {c["url"]: c["name"] for c in SERVICE_CHECKS} 72 - 73 - for check in checks: 74 - url = check.get("url", "") 75 - name = name_by_url.get(url, url) 76 - status = check.get("status") 77 - ms = check.get("ms", "?") 78 - ok = check.get("ok", False) 79 - 80 - if ok: 81 - healthy.append(f"{name}: ok ({ms}ms)") 82 - else: 83 - error = check.get("error", f"status {status}") 84 - failures.append(f"{name}: DOWN ({error})") 85 - 86 - parts: list[str] = [] 87 - if failures: 88 - parts.append("FAILURES:\n" + "\n".join(failures)) 89 - parts.append(f"{len(healthy)}/{len(healthy) + len(failures)} services healthy") 90 - if not failures: 91 - parts.append("\n".join(healthy)) 92 - 93 - return "\n".join(parts) 94 - 95 20 96 21 def _build_operational_instructions() -> str: 97 22 """Build operational instructions with the current owner handle interpolated.""" ··· 145 70 mention consent: 146 71 when you write @handle in a reply, that person receives a notification. only @mention people who are directly in the conversation with you — the person who messaged you, or thread participants who tagged you. never @mention third parties. if you want to reference someone not in the conversation, use their name without the @ prefix (e.g. "boris" not "@bmann.ca"). the only person you may tag unsolicited is @{settings.owner_handle}. 147 72 73 + blogging: you can publish long-form markdown posts to greengale.app. before writing, always check your existing posts with list_blog_posts. write about things you find genuinely interesting — patterns you've noticed, things you've learned, connections between ideas. don't repeat yourself. 74 + 148 75 IMPORTANT: never paginate through list_records repeatedly. if you need more data than one call returns, work with what you have. endless pagination wastes your request budget and produces no response. 149 76 """.strip() 150 77 151 78 152 - def _relative_age(timestamp: str, today: date) -> str: 153 - """Turn an ISO timestamp into a human-readable age like '2y ago' or '3d ago'.""" 154 - try: 155 - post_date = date.fromisoformat(timestamp[:10]) 156 - except (ValueError, TypeError): 79 + def _extract_query_text(prompt: str | Sequence[str | ImageUrl] | None) -> str: 80 + """Extract plain text from a pydantic-ai prompt for use as a search query.""" 81 + if prompt is None: 157 82 return "" 158 - delta = today - post_date 159 - days = delta.days 160 - if days < 0: 161 - return "" 162 - if days == 0: 163 - return "today" 164 - if days == 1: 165 - return "1d ago" 166 - if days < 30: 167 - return f"{days}d ago" 168 - months = days // 30 169 - if months < 12: 170 - return f"{months}mo ago" 171 - years = days // 365 172 - remaining_months = (days % 365) // 30 173 - if remaining_months: 174 - return f"{years}y {remaining_months}mo ago" 175 - return f"{years}y ago" 176 - 177 - 178 - @dataclass 179 - class PhiDeps: 180 - """Typed dependencies passed to every tool via RunContext.""" 181 - 182 - author_handle: str 183 - memory: NamespaceMemory | None = None 184 - thread_uri: str | None = None 185 - thread_context: str | None = None 186 - last_post_text: str | None = None 187 - recent_activity: str | None = None 188 - service_health: str | None = None 189 - 190 - 191 - def _is_owner(ctx: RunContext[PhiDeps]) -> bool: 192 - """Check if the current message author is the bot's owner.""" 193 - return ctx.deps.author_handle == settings.owner_handle 194 - 195 - 196 - def _format_feed_posts(feed_posts, limit: int = 20) -> str: 197 - """Format feed posts into a readable summary.""" 198 - today = date.today() 199 - lines = [] 200 - for item in feed_posts[:limit]: 201 - post = item.post 202 - text = post.record.text if hasattr(post.record, "text") else "" 203 - handle = post.author.handle 204 - likes = post.like_count or 0 205 - age = ( 206 - _relative_age(post.indexed_at, today) 207 - if hasattr(post, "indexed_at") and post.indexed_at 208 - else "" 209 - ) 210 - age_str = f", {age}" if age else "" 211 - lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 212 - return "\n\n".join(lines) 83 + if isinstance(prompt, str): 84 + return prompt 85 + return " ".join(part for part in prompt if isinstance(part, str)) 213 86 214 87 215 88 class Response(BaseModel): ··· 224 97 ) 225 98 226 99 227 - def _format_user_results(results: list[dict], handle: str) -> list[str]: 228 - parts = [] 229 - for r in results: 230 - kind = r.get("kind", "unknown") 231 - content = r.get("content", "") 232 - tags = r.get("tags", []) 233 - tag_str = f"[{', '.join(tags)}]" if tags else "" 234 - parts.append(f"[{kind}]{tag_str} {content}") 235 - return parts 236 - 237 - 238 - def _format_episodic_results(results: list[dict]) -> list[str]: 239 - parts = [] 240 - for r in results: 241 - tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 242 - parts.append(f"{r['content']}{tags}") 243 - return parts 244 - 245 - 246 - def _format_unified_results(results: list[dict], handle: str) -> list[str]: 247 - parts = [] 248 - for r in results: 249 - source = r.get("_source", "") 250 - content = r.get("content", "") 251 - tags = r.get("tags", []) 252 - tag_str = f" [{', '.join(tags)}]" if tags else "" 253 - if source == "user": 254 - kind = r.get("kind", "unknown") 255 - parts.append(f"[@{handle} {kind}]{tag_str} {content}") 256 - else: 257 - parts.append(f"[note]{tag_str} {content}") 258 - return parts 259 - 260 - 261 - async def _create_cosmik_record(collection: str, record: dict) -> str: 262 - """Write a cosmik record to phi's PDS. Returns the AT URI.""" 263 - await bot_client.authenticate() 264 - assert bot_client.client.me is not None 265 - result = bot_client.client.com.atproto.repo.create_record( 266 - data={ 267 - "repo": bot_client.client.me.did, 268 - "collection": collection, 269 - "record": record, 270 - } 271 - ) 272 - return result.uri 273 - 274 - 275 - def _extract_query_text(prompt: str | Sequence[str | ImageUrl] | None) -> str: 276 - """Extract plain text from a pydantic-ai prompt for use as a search query.""" 277 - if prompt is None: 278 - return "" 279 - if isinstance(prompt, str): 280 - return prompt 281 - return " ".join(part for part in prompt if isinstance(part, str)) 282 - 283 - 284 100 class PhiAgent: 285 101 """phi - bluesky bot with structured memory and MCP tools.""" 286 102 ··· 297 113 298 114 # Initialize memory (TurboPuffer) 299 115 if settings.turbopuffer_api_key and settings.openai_api_key: 116 + from bot.memory import NamespaceMemory 117 + 300 118 self.memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 301 119 logger.info("memory enabled (turbopuffer)") 302 120 else: ··· 379 197 return f"[SERVICE HEALTH]:\n{ctx.deps.service_health}" 380 198 return "" 381 199 382 - # --- memory tools --- 383 - 384 - @self.agent.tool 385 - async def recall(ctx: RunContext[PhiDeps], query: str, about: str = "") -> str: 386 - """Search your private memory. Use to remember past conversations and what you know about specific people. 387 - Pass about="@handle" to search a specific user, or leave empty for general private recall. 388 - For public network knowledge, use search_network instead.""" 389 - if not ctx.deps.memory: 390 - return "memory not available" 391 - 392 - if about.startswith("@"): 393 - handle = about.lstrip("@") 394 - results = await ctx.deps.memory.search(handle, query, top_k=10) 395 - if not results: 396 - return f"no memories found about @{handle}" 397 - return "\n".join(_format_user_results(results, handle)) 398 - 399 - if about == "": 400 - results = await ctx.deps.memory.search_unified( 401 - ctx.deps.author_handle, query, top_k=8 402 - ) 403 - if not results: 404 - return "no relevant memories found" 405 - return "\n".join( 406 - _format_unified_results(results, ctx.deps.author_handle) 407 - ) 408 - 409 - # bare handle without @ 410 - results = await ctx.deps.memory.search(about, query, top_k=10) 411 - if not results: 412 - return f"no memories found about @{about}" 413 - return "\n".join(_format_user_results(results, about)) 414 - 415 - @self.agent.tool 416 - async def note(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 417 - """Leave a note for your future self. Stored privately for fast vector recall.""" 418 - if ctx.deps.memory: 419 - await ctx.deps.memory.store_episodic_memory( 420 - content, tags, source="tool" 421 - ) 422 - return f"noted — {content[:100]}" 423 - return "private memory not available" 424 - 425 - @self.agent.tool 426 - async def save_url( 427 - ctx: RunContext[PhiDeps], 428 - url: str, 429 - title: str, 430 - description: str | None = None, 431 - ) -> str: 432 - """Save a URL as a cosmik card on your PDS. Use when you find something worth bookmarking publicly. 433 - Always provide a concise, descriptive title — this is what appears in the activity feed.""" 434 - try: 435 - card = CosmikUrlCard( 436 - content=UrlContent(url=url, title=title, description=description) 437 - ) 438 - except Exception as e: 439 - return f"validation failed: {e}" 440 - 441 - parts: list[str] = [] 442 - 443 - # public: cosmik URL card on PDS 444 - try: 445 - uri = await _create_cosmik_record( 446 - "network.cosmik.card", card.to_record() 447 - ) 448 - parts.append(f"card created: {uri}") 449 - except Exception as e: 450 - return f"failed to create card: {e}" 451 - 452 - # private: also store in turbopuffer for recall 453 - if ctx.deps.memory: 454 - desc = f"bookmarked {url}" + (f" — {title}" if title else "") 455 - await ctx.deps.memory.store_episodic_memory( 456 - desc, ["bookmark", "url"], source="tool" 457 - ) 458 - parts.append("noted privately") 459 - 460 - return " + ".join(parts) 461 - 462 - @self.agent.tool 463 - async def search_posts( 464 - ctx: RunContext[PhiDeps], query: str, limit: int = 10 465 - ) -> str: 466 - """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 467 - try: 468 - response = bot_client.client.app.bsky.feed.search_posts( 469 - params={"q": query, "limit": min(limit, 25), "sort": "top"} 470 - ) 471 - if not response.posts: 472 - return f"no posts found for '{query}'" 473 - 474 - today = date.today() 475 - lines = [] 476 - for post in response.posts: 477 - text = post.record.text if hasattr(post.record, "text") else "" 478 - handle = post.author.handle 479 - likes = post.like_count or 0 480 - age = ( 481 - _relative_age(post.indexed_at, today) 482 - if hasattr(post, "indexed_at") and post.indexed_at 483 - else "" 484 - ) 485 - age_str = f", {age}" if age else "" 486 - lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 487 - return "\n\n".join(lines) 488 - except Exception as e: 489 - return f"search failed: {e}" 490 - 491 - @self.agent.tool 492 - async def search_network(ctx: RunContext[PhiDeps], query: str) -> str: 493 - """Search the cosmik network for cards and bookmarks collected by people across the atmosphere. 494 - Use this to find what the network knows about a topic — links, notes, and resources that others have saved. 495 - Different from recall (your private memory) and search_posts (live bluesky posts).""" 496 - try: 497 - async with httpx.AsyncClient(timeout=15) as client: 498 - r = await client.get( 499 - "https://api.semble.so/api/search/semantic", 500 - params={"query": query, "limit": 10}, 501 - ) 502 - r.raise_for_status() 503 - data = r.json() 504 - 505 - # response is {urls: [...], pagination: {...}} 506 - items = data.get("urls") if isinstance(data, dict) else data 507 - if not items: 508 - return f"no network results for '{query}'" 509 - 510 - lines = [] 511 - for item in items: 512 - meta = item.get("metadata", {}) 513 - title = meta.get("title") or item.get("title") or "untitled" 514 - url = item.get("url", "") 515 - saves = item.get("urlLibraryCount") or 0 516 - desc = meta.get("description") or "" 517 - line = f"{title}" 518 - if url: 519 - line += f" — {url}" 520 - if saves: 521 - line += f" ({saves} saves)" 522 - if desc: 523 - line += f"\n {desc[:200]}" 524 - lines.append(line) 525 - return "\n\n".join(lines) 526 - except Exception as e: 527 - return f"network search failed: {e}" 528 - 529 - @self.agent.tool 530 - async def get_trending(ctx: RunContext[PhiDeps]) -> str: 531 - """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.""" 532 - parts: list[str] = [] 533 - 534 - async with httpx.AsyncClient(timeout=15) as client: 535 - # coral entity graph — NER-extracted trending entities from the firehose 536 - try: 537 - r = await client.get("https://coral.fly.dev/entity-graph") 538 - r.raise_for_status() 539 - data = r.json() 540 - entities = data.get("entities", []) 541 - stats = data.get("stats", {}) 542 - 543 - by_trend = sorted( 544 - entities, key=lambda e: e.get("trend", 0), reverse=True 545 - )[:15] 546 - 547 - lines = [ 548 - f"coral ({stats.get('active', 0)} active entities, " 549 - f"{stats.get('clusters', 0)} clusters" 550 - f"{', percolating' if stats.get('percolates') else ''}):" 551 - ] 552 - for e in by_trend: 553 - lines.append( 554 - f" {e['text']} ({e.get('label', '')}) " 555 - f"trend={e.get('trend', 0):.2f}" 556 - ) 557 - parts.append("\n".join(lines)) 558 - except Exception as e: 559 - parts.append(f"coral unavailable: {e}") 560 - 561 - # official bluesky trending topics 562 - try: 563 - r = await client.get( 564 - "https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics" 565 - ) 566 - r.raise_for_status() 567 - topics = r.json().get("topics", []) 568 - if topics: 569 - lines = ["bluesky trending:"] 570 - for t in topics[:15]: 571 - lines.append( 572 - f" {t.get('displayName', t.get('topic', ''))}" 573 - ) 574 - parts.append("\n".join(lines)) 575 - except Exception as e: 576 - parts.append(f"bluesky trending unavailable: {e}") 577 - 578 - return "\n\n".join(parts) if parts else "no trending data available" 579 - 580 - @self.agent.tool 581 - async def manage_labels( 582 - ctx: RunContext[PhiDeps], action: str, label: str = "" 583 - ) -> str: 584 - """Manage self-labels on your profile. Actions: 'list' to see current labels, 'add' to add a label, 'remove' to remove a label. The 'bot' label marks you as an automated account.""" 585 - from bot.core.profile_manager import ( 586 - add_self_label, 587 - get_self_labels, 588 - remove_self_label, 589 - ) 590 - 591 - if action == "list": 592 - labels = get_self_labels(bot_client.client) 593 - return ( 594 - f"current self-labels: {labels}" if labels else "no self-labels set" 595 - ) 596 - elif action == "add": 597 - if not label: 598 - return "provide a label value to add" 599 - labels = add_self_label(bot_client.client, label) 600 - return f"added '{label}', labels now: {labels}" 601 - elif action == "remove": 602 - if not label: 603 - return "provide a label value to remove" 604 - labels = remove_self_label(bot_client.client, label) 605 - return f"removed '{label}', labels now: {labels}" 606 - else: 607 - return f"unknown action '{action}', use 'list', 'add', or 'remove'" 608 - 609 - @self.agent.tool 610 - async def create_connection( 611 - ctx: RunContext[PhiDeps], 612 - source: str, 613 - target: str, 614 - connection_type: str | None = None, 615 - note: str | None = None, 616 - ) -> str: 617 - """Create a network.cosmik.connection record — a semantic link between two entities. 618 - Source and target must be URLs or at:// URIs. Connection types: related, supports, opposes, addresses, helpful, explainer, leads_to, supplements.""" 619 - try: 620 - conn = CosmikConnection( 621 - source=source, 622 - target=target, 623 - connection_type=connection_type, 624 - note=note, 625 - ) 626 - except Exception as e: 627 - return f"validation failed: {e}" 628 - 629 - try: 630 - uri = await _create_cosmik_record( 631 - "network.cosmik.connection", conn.to_record() 632 - ) 633 - return f"connection created: {uri}" 634 - except Exception as e: 635 - return f"failed to create connection: {e}" 636 - 637 - @self.agent.tool 638 - async def post(ctx: RunContext[PhiDeps], text: str) -> str: 639 - """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 640 - try: 641 - # top-level posts: only allow tagging owner + self 642 - allowed = {settings.owner_handle, settings.bluesky_handle} 643 - if ctx.deps.author_handle: 644 - allowed.add(ctx.deps.author_handle) 645 - await bot_client.create_post(text, allowed_handles=allowed) 646 - return f"posted: {text[:100]}" 647 - except Exception as e: 648 - return f"failed to post: {e}" 649 - 650 - @self.agent.tool 651 - async def check_urls(ctx: RunContext[PhiDeps], urls: list[str]) -> str: 652 - """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).""" 653 - 654 - async def _check(client: httpx.AsyncClient, url: str) -> str: 655 - if not url.startswith(("http://", "https://")): 656 - url = f"https://{url}" 657 - try: 658 - hostname = urlparse(url).hostname 659 - if not hostname: 660 - return f"{url} → blocked: no hostname" 661 - # resolve and check for private/loopback IPs (SSRF protection) 662 - try: 663 - addrs = await asyncio.get_event_loop().run_in_executor( 664 - None, lambda: socket.getaddrinfo(hostname, None) 665 - ) 666 - except socket.gaierror: 667 - return f"{url} → blocked: DNS resolution failed" 668 - for addr_info in addrs: 669 - ip = ipaddress.ip_address(addr_info[4][0]) 670 - if ip.is_private or ip.is_loopback or ip.is_link_local: 671 - return f"{url} → blocked: private IP" 672 - 673 - r = await client.head(url, follow_redirects=True) 674 - return f"{url} → {r.status_code}" 675 - except httpx.TimeoutException: 676 - return f"{url} → timeout" 677 - except Exception as e: 678 - return f"{url} → error: {type(e).__name__}" 679 - 680 - async with httpx.AsyncClient(timeout=10) as client: 681 - results = await asyncio.gather(*[_check(client, u) for u in urls]) 682 - return "\n".join(results) 683 - 684 - # --- graze feed tools --- 200 + # --- register tools from tools/ package --- 685 201 686 202 self.graze_client = GrazeClient( 687 203 handle=settings.bluesky_handle, password=settings.bluesky_password 688 204 ) 689 - 690 - @self.agent.tool 691 - async def create_feed( 692 - ctx: RunContext[PhiDeps], 693 - name: str, 694 - display_name: str, 695 - description: str, 696 - filter_manifest: dict, 697 - ) -> str: 698 - """Create a new bluesky feed powered by graze. Only the bot's owner can use this tool. 699 - 700 - name: url-safe slug (e.g. "electronic-music"). becomes the feed rkey. 701 - display_name: human-readable feed title. 702 - description: what the feed shows. 703 - filter_manifest: graze filter DSL (grazer engine operators). key operators: 704 - - regex_any: ["field", ["term1", "term2"]] — match any term (case-insensitive by default) 705 - - regex_none: ["field", ["term1", "term2"]] — exclude posts matching any term 706 - - regex_matches: ["field", "pattern"] — single regex match 707 - - and: [...filters], or: [...filters] — combine filters 708 - field is usually "text". example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]}} 709 - """ 710 - if not _is_owner(ctx): 711 - return f"only @{settings.owner_handle} can create feeds" 712 - try: 713 - result = await self.graze_client.create_feed( 714 - rkey=name, 715 - display_name=display_name, 716 - description=description, 717 - filter_manifest=filter_manifest, 718 - ) 719 - return f"feed created: {result['uri']} (algo_id={result['algo_id']})" 720 - except Exception as e: 721 - logger.warning(f"create_feed failed: {e}") 722 - return f"failed to create feed: {e}" 723 - 724 - @self.agent.tool 725 - async def list_feeds(ctx: RunContext[PhiDeps]) -> str: 726 - """List your existing graze-powered feeds. Returns name (slug for read_feed) and algo_id (for delete_feed).""" 727 - try: 728 - feeds = await self.graze_client.list_feeds() 729 - if not feeds: 730 - return "no graze feeds found" 731 - lines = [] 732 - for f in feeds: 733 - display = f.get("display_name") or f.get("name") or "unnamed" 734 - algo_id = f.get("id") or f.get("algo_id") or "?" 735 - uri = f.get("feed_uri") or f.get("uri") or "" 736 - # extract rkey slug from feed_uri for use with read_feed 737 - rkey = f.get("record_name") or ( 738 - uri.rsplit("/", 1)[-1] if uri else "?" 739 - ) 740 - lines.append(f"- {display} | name={rkey} | algo_id={algo_id}") 741 - return "\n".join(lines) 742 - except Exception as e: 743 - logger.warning(f"list_feeds failed: {e}") 744 - return f"failed to list feeds: {e}" 745 - 746 - @self.agent.tool 747 - async def delete_feed(ctx: RunContext[PhiDeps], algo_id: int) -> str: 748 - """Delete a graze-powered feed by its algo_id. Only the bot's owner can use this tool. 749 - 750 - algo_id: the numeric id from list_feeds (e.g. 33726). 751 - This deletes both the graze registration and the PDS feed generator record. 752 - """ 753 - if not _is_owner(ctx): 754 - return f"only @{settings.owner_handle} can delete feeds" 755 - try: 756 - # find the record_name from graze so we can delete the PDS record too 757 - feeds = await self.graze_client.list_feeds() 758 - record_name = None 759 - for f in feeds: 760 - if f.get("id") == algo_id: 761 - record_name = f.get("record_name") 762 - break 763 - 764 - await self.graze_client.delete_feed(algo_id) 765 - 766 - # also delete the PDS record if we found the rkey 767 - if record_name: 768 - assert bot_client.client.me is not None 769 - try: 770 - bot_client.client.com.atproto.repo.delete_record( 771 - data={ 772 - "repo": bot_client.client.me.did, 773 - "collection": "app.bsky.feed.generator", 774 - "rkey": record_name, 775 - } 776 - ) 777 - except Exception as e: 778 - logger.warning(f"PDS record delete failed: {e}") 779 - 780 - return f"deleted feed algo_id={algo_id}" + ( 781 - f" and PDS record '{record_name}'" if record_name else "" 782 - ) 783 - except Exception as e: 784 - logger.warning(f"delete_feed failed: {e}") 785 - return f"failed to delete feed: {e}" 786 - 787 - # --- feed consumption + following tools --- 788 - 789 - @self.agent.tool 790 - async def read_timeline(ctx: RunContext[PhiDeps], limit: int = 20) -> str: 791 - """Read your 'following' timeline — posts from accounts you follow. Use this when someone asks what's on your feed or what people you follow are talking about.""" 792 - try: 793 - response = await bot_client.get_timeline(limit=limit) 794 - if not response.feed: 795 - return ( 796 - "your timeline is empty — you're not following anyone yet. " 797 - f"ask @{settings.owner_handle} to have me follow some accounts!" 798 - ) 799 - return _format_feed_posts(response.feed, limit=limit) 800 - except Exception as e: 801 - return f"failed to read timeline: {e}" 802 - 803 - @self.agent.tool 804 - async def read_feed( 805 - ctx: RunContext[PhiDeps], name: str, limit: int = 20 806 - ) -> str: 807 - """Read posts from one of your graze-powered feeds. 808 - 809 - name: the feed slug (e.g. "mushroom-foraging"). use list_feeds to see available names. 810 - """ 811 - try: 812 - await bot_client.authenticate() 813 - assert bot_client.client.me is not None 814 - feed_uri = ( 815 - f"at://{bot_client.client.me.did}/app.bsky.feed.generator/{name}" 816 - ) 817 - response = await bot_client.get_feed(feed_uri, limit=limit) 818 - if not response.feed: 819 - return "no posts in this feed yet" 820 - return _format_feed_posts(response.feed, limit=limit) 821 - except Exception as e: 822 - return f"failed to read feed: {e}" 823 - 824 - @self.agent.tool 825 - async def follow_user(ctx: RunContext[PhiDeps], handle: str) -> str: 826 - """Follow a user on bluesky. Only the bot's owner can use this tool.""" 827 - if not _is_owner(ctx): 828 - return f"only @{settings.owner_handle} can ask me to follow people" 829 - try: 830 - # check if already following 831 - following = await bot_client.get_following() 832 - for f in following.follows: 833 - if f.handle == handle: 834 - return f"already following @{handle}" 835 - uri = await bot_client.follow_user(handle) 836 - return f"now following @{handle} ({uri})" 837 - except Exception as e: 838 - return f"failed to follow @{handle}: {e}" 839 - 840 - @self.agent.tool 841 - async def get_own_posts(ctx: RunContext[PhiDeps], limit: int = 10) -> str: 842 - """Read your own recent top-level posts (no replies). Use this instead of list_records when you need to review what you've posted.""" 843 - try: 844 - posts = await bot_client.get_own_posts(limit=limit) 845 - if not posts: 846 - return "no posts found" 847 - today = date.today() 848 - lines = [] 849 - for item in posts: 850 - post = item.post 851 - text = post.record.text if hasattr(post.record, "text") else "" 852 - age = ( 853 - _relative_age(post.indexed_at, today) 854 - if hasattr(post, "indexed_at") and post.indexed_at 855 - else "" 856 - ) 857 - age_str = f" ({age})" if age else "" 858 - lines.append(f"[{post.uri}]{age_str}: {text[:200]}") 859 - return "\n\n".join(lines) 860 - except Exception as e: 861 - return f"failed to get own posts: {e}" 862 - 863 - @self.agent.tool 864 - async def check_services(ctx: RunContext[PhiDeps]) -> str: 865 - """Check health of nate's infrastructure (plyr, PDS, prefect, etc) — NOT your own status. 866 - Do NOT call this when someone asks if you're online — that's about you, not infrastructure. 867 - Only use during daily reflection or when someone explicitly asks about services/infrastructure.""" 868 - return await _check_services_impl() 205 + register_all(self.agent, self.graze_client) 869 206 870 207 logger.info("phi agent initialized with pdsx + pub-search mcp tools") 871 208
+19
src/bot/tools/__init__.py
··· 1 + """Tool registration for phi agent.""" 2 + 3 + from bot.core.graze_client import GrazeClient 4 + from bot.tools._helpers import PhiDeps, _check_services_impl 5 + 6 + 7 + def register_all(agent, graze_client: GrazeClient): 8 + """Register all tools on the agent.""" 9 + from bot.tools import blog, bluesky, cosmik, feeds, memory, search 10 + 11 + memory.register(agent) 12 + search.register(agent) 13 + cosmik.register(agent) 14 + feeds.register(agent, graze_client) 15 + bluesky.register(agent) 16 + blog.register(agent) 17 + 18 + 19 + __all__ = ["PhiDeps", "_check_services_impl", "register_all"]
+200
src/bot/tools/_helpers.py
··· 1 + """Shared types and utilities for phi's tools.""" 2 + 3 + import logging 4 + from dataclasses import dataclass 5 + from datetime import date 6 + 7 + import httpx 8 + from pydantic_ai import RunContext 9 + 10 + from bot.config import settings 11 + from bot.core.atproto_client import bot_client 12 + from bot.memory import NamespaceMemory 13 + 14 + logger = logging.getLogger("bot.tools") 15 + 16 + 17 + # --- deps --- 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 + thread_context: str | None = None 28 + last_post_text: str | None = None 29 + recent_activity: str | None = None 30 + service_health: str | None = None 31 + 32 + 33 + def _is_owner(ctx: RunContext[PhiDeps]) -> bool: 34 + """Check if the current message author is the bot's owner.""" 35 + return ctx.deps.author_handle == settings.owner_handle 36 + 37 + 38 + # --- formatting --- 39 + 40 + 41 + def _relative_age(timestamp: str, today: date) -> str: 42 + """Turn an ISO timestamp into a human-readable age like '2y ago' or '3d ago'.""" 43 + try: 44 + post_date = date.fromisoformat(timestamp[:10]) 45 + except (ValueError, TypeError): 46 + return "" 47 + delta = today - post_date 48 + days = delta.days 49 + if days < 0: 50 + return "" 51 + if days == 0: 52 + return "today" 53 + if days == 1: 54 + return "1d ago" 55 + if days < 30: 56 + return f"{days}d ago" 57 + months = days // 30 58 + if months < 12: 59 + return f"{months}mo ago" 60 + years = days // 365 61 + remaining_months = (days % 365) // 30 62 + if remaining_months: 63 + return f"{years}y {remaining_months}mo ago" 64 + return f"{years}y ago" 65 + 66 + 67 + def _format_feed_posts(feed_posts, limit: int = 20) -> str: 68 + """Format feed posts into a readable summary.""" 69 + today = date.today() 70 + lines = [] 71 + for item in feed_posts[:limit]: 72 + post = item.post 73 + text = post.record.text if hasattr(post.record, "text") else "" 74 + handle = post.author.handle 75 + likes = post.like_count or 0 76 + age = ( 77 + _relative_age(post.indexed_at, today) 78 + if hasattr(post, "indexed_at") and post.indexed_at 79 + else "" 80 + ) 81 + age_str = f", {age}" if age else "" 82 + lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 83 + return "\n\n".join(lines) 84 + 85 + 86 + def _format_user_results(results: list[dict], handle: str) -> list[str]: 87 + parts = [] 88 + for r in results: 89 + kind = r.get("kind", "unknown") 90 + content = r.get("content", "") 91 + tags = r.get("tags", []) 92 + tag_str = f"[{', '.join(tags)}]" if tags else "" 93 + parts.append(f"[{kind}]{tag_str} {content}") 94 + return parts 95 + 96 + 97 + def _format_episodic_results(results: list[dict]) -> list[str]: 98 + parts = [] 99 + for r in results: 100 + tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 101 + parts.append(f"{r['content']}{tags}") 102 + return parts 103 + 104 + 105 + def _format_unified_results(results: list[dict], handle: str) -> list[str]: 106 + parts = [] 107 + for r in results: 108 + source = r.get("_source", "") 109 + content = r.get("content", "") 110 + tags = r.get("tags", []) 111 + tag_str = f" [{', '.join(tags)}]" if tags else "" 112 + if source == "user": 113 + kind = r.get("kind", "unknown") 114 + parts.append(f"[@{handle} {kind}]{tag_str} {content}") 115 + else: 116 + parts.append(f"[note]{tag_str} {content}") 117 + return parts 118 + 119 + 120 + # --- record creation --- 121 + 122 + 123 + async def _create_cosmik_record(collection: str, record: dict) -> str: 124 + """Write a cosmik record to phi's PDS. Returns the AT URI.""" 125 + await bot_client.authenticate() 126 + assert bot_client.client.me is not None 127 + result = bot_client.client.com.atproto.repo.create_record( 128 + data={ 129 + "repo": bot_client.client.me.did, 130 + "collection": collection, 131 + "record": record, 132 + } 133 + ) 134 + return result.uri 135 + 136 + 137 + # --- infrastructure --- 138 + 139 + EVERGREEN_PROXY = "https://evergreen-proxy.nate-8fe.workers.dev" 140 + SERVICE_CHECKS = [ 141 + {"url": "https://api.plyr.fm/health", "name": "plyr api"}, 142 + {"url": "https://plyr.fm", "name": "plyr frontend"}, 143 + {"url": "https://pds.zzstoatzz.io/xrpc/_health", "name": "PDS"}, 144 + {"url": "https://prefect-server.waow.tech/api/health", "name": "prefect"}, 145 + {"url": "https://prefect-metrics.waow.tech/api/health", "name": "grafana"}, 146 + {"url": "https://relay.waow.tech/xrpc/_health", "name": "indigo relay"}, 147 + {"url": "https://zlay.waow.tech/_health", "name": "zlay"}, 148 + {"url": "https://coral.fly.dev/health", "name": "trending"}, 149 + { 150 + "url": "https://leaflet-search-backend.fly.dev/health", 151 + "name": "standard.site backend", 152 + }, 153 + {"url": "https://pub-search.waow.tech", "name": "pub-search"}, 154 + {"url": "https://typeahead.waow.tech/stats", "name": "typeahead"}, 155 + {"url": "https://zig-bsky-feed.fly.dev/health", "name": "music-feed"}, 156 + {"url": "https://pollz-backend.fly.dev/health", "name": "pollz"}, 157 + ] 158 + 159 + 160 + async def _check_services_impl() -> str: 161 + """Hit the evergreen proxy with all service checks. Returns formatted status.""" 162 + async with httpx.AsyncClient(timeout=30) as client: 163 + try: 164 + r = await client.post( 165 + EVERGREEN_PROXY, 166 + json={"checks": SERVICE_CHECKS}, 167 + ) 168 + r.raise_for_status() 169 + results = r.json() 170 + except Exception as e: 171 + return f"evergreen proxy unreachable: {e}" 172 + 173 + failures: list[str] = [] 174 + healthy: list[str] = [] 175 + 176 + checks = results if isinstance(results, list) else results.get("results", []) 177 + # build name lookup from our request 178 + name_by_url = {c["url"]: c["name"] for c in SERVICE_CHECKS} 179 + 180 + for check in checks: 181 + url = check.get("url", "") 182 + name = name_by_url.get(url, url) 183 + status = check.get("status") 184 + ms = check.get("ms", "?") 185 + ok = check.get("ok", False) 186 + 187 + if ok: 188 + healthy.append(f"{name}: ok ({ms}ms)") 189 + else: 190 + error = check.get("error", f"status {status}") 191 + failures.append(f"{name}: DOWN ({error})") 192 + 193 + parts: list[str] = [] 194 + if failures: 195 + parts.append("FAILURES:\n" + "\n".join(failures)) 196 + parts.append(f"{len(healthy)}/{len(healthy) + len(failures)} services healthy") 197 + if not failures: 198 + parts.append("\n".join(healthy)) 199 + 200 + return "\n".join(parts)
+127
src/bot/tools/blog.py
··· 1 + """Blog tools — greengale publishing.""" 2 + 3 + from pydantic_ai import RunContext 4 + 5 + from bot.config import settings 6 + from bot.core.atproto_client import bot_client 7 + from bot.tools._helpers import PhiDeps 8 + from bot.types import GreenGaleDocument, generate_tid 9 + 10 + 11 + def register(agent): 12 + @agent.tool 13 + async def list_blog_posts(ctx: RunContext[PhiDeps], limit: int = 10) -> str: 14 + """List your published blog posts on greengale. Call this before publishing to avoid duplicates.""" 15 + try: 16 + await bot_client.authenticate() 17 + assert bot_client.client.me is not None 18 + did = bot_client.client.me.did 19 + handle = settings.bluesky_handle 20 + 21 + response = bot_client.client.com.atproto.repo.list_records( 22 + params={ 23 + "repo": did, 24 + "collection": "app.greengale.document", 25 + "limit": min(limit, 100), 26 + } 27 + ) 28 + 29 + if not response.records: 30 + return "no blog posts yet" 31 + 32 + lines = [] 33 + for rec in response.records: 34 + val = rec.value 35 + title = ( 36 + val.get("title", "untitled") 37 + if isinstance(val, dict) 38 + else "untitled" 39 + ) 40 + rkey = rec.uri.split("/")[-1] 41 + published = val.get("publishedAt", "") if isinstance(val, dict) else "" 42 + tags = val.get("tags", []) if isinstance(val, dict) else [] 43 + url = f"https://greengale.app/{handle}/{rkey}" 44 + tag_str = f" [{', '.join(tags)}]" if tags else "" 45 + date_str = f" ({published[:10]})" if published else "" 46 + lines.append(f"- {title}{tag_str}{date_str}\n {url}") 47 + return "\n".join(lines) 48 + except Exception as e: 49 + return f"failed to list blog posts: {e}" 50 + 51 + @agent.tool 52 + async def publish_blog_post( 53 + ctx: RunContext[PhiDeps], 54 + title: str, 55 + content: str, 56 + tags: list[str] | None = None, 57 + ) -> str: 58 + """Publish a markdown blog post to greengale.app (your ATProto blog). 59 + 60 + IMPORTANT: before calling this, use list_blog_posts to review your existing posts 61 + so you don't repeat yourself. 62 + 63 + title: post title. 64 + content: full markdown body. 65 + tags: optional list of topic tags. 66 + """ 67 + try: 68 + doc = GreenGaleDocument( 69 + title=title, 70 + content=content, 71 + tags=tags or [], 72 + ) 73 + except Exception as e: 74 + return f"validation failed: {e}" 75 + 76 + try: 77 + await bot_client.authenticate() 78 + assert bot_client.client.me is not None 79 + did = bot_client.client.me.did 80 + handle = settings.bluesky_handle 81 + 82 + # check for title duplicates 83 + existing = bot_client.client.com.atproto.repo.list_records( 84 + params={ 85 + "repo": did, 86 + "collection": "app.greengale.document", 87 + "limit": 100, 88 + } 89 + ) 90 + if existing.records: 91 + for rec in existing.records: 92 + val = rec.value 93 + existing_title = ( 94 + val.get("title", "") if isinstance(val, dict) else "" 95 + ) 96 + if existing_title == title: 97 + rkey = rec.uri.split("/")[-1] 98 + return ( 99 + f"refused: a post with this exact title already exists " 100 + f"at https://greengale.app/{handle}/{rkey}" 101 + ) 102 + 103 + rkey = generate_tid() 104 + record = doc.to_record(handle=handle, rkey=rkey) 105 + 106 + bot_client.client.com.atproto.repo.put_record( 107 + data={ 108 + "repo": did, 109 + "collection": "app.greengale.document", 110 + "rkey": rkey, 111 + "record": record, 112 + } 113 + ) 114 + 115 + url = f"https://greengale.app/{handle}/{rkey}" 116 + 117 + # store in episodic memory 118 + if ctx.deps.memory: 119 + await ctx.deps.memory.store_episodic_memory( 120 + f"published blog post: {title} — {url}", 121 + ["blog", "greengale"] + (tags or []), 122 + source="tool", 123 + ) 124 + 125 + return f"published: {url}" 126 + except Exception as e: 127 + return f"failed to publish: {e}"
+120
src/bot/tools/bluesky.py
··· 1 + """Bluesky account tools — posting, own posts, URL checks, labels, infra.""" 2 + 3 + import asyncio 4 + import ipaddress 5 + import socket 6 + from datetime import date 7 + from urllib.parse import urlparse 8 + 9 + import httpx 10 + from pydantic_ai import RunContext 11 + 12 + from bot.config import settings 13 + from bot.core.atproto_client import bot_client 14 + from bot.tools._helpers import PhiDeps, _check_services_impl, _relative_age 15 + 16 + 17 + def register(agent): 18 + @agent.tool 19 + async def post(ctx: RunContext[PhiDeps], text: str) -> str: 20 + """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 21 + try: 22 + # top-level posts: only allow tagging owner + self 23 + allowed = {settings.owner_handle, settings.bluesky_handle} 24 + if ctx.deps.author_handle: 25 + allowed.add(ctx.deps.author_handle) 26 + await bot_client.create_post(text, allowed_handles=allowed) 27 + return f"posted: {text[:100]}" 28 + except Exception as e: 29 + return f"failed to post: {e}" 30 + 31 + @agent.tool 32 + async def get_own_posts(ctx: RunContext[PhiDeps], limit: int = 10) -> str: 33 + """Read your own recent top-level posts (no replies). Use this instead of list_records when you need to review what you've posted.""" 34 + try: 35 + posts = await bot_client.get_own_posts(limit=limit) 36 + if not posts: 37 + return "no posts found" 38 + today = date.today() 39 + lines = [] 40 + for item in posts: 41 + p = item.post 42 + text = p.record.text if hasattr(p.record, "text") else "" 43 + age = ( 44 + _relative_age(p.indexed_at, today) 45 + if hasattr(p, "indexed_at") and p.indexed_at 46 + else "" 47 + ) 48 + age_str = f" ({age})" if age else "" 49 + lines.append(f"[{p.uri}]{age_str}: {text[:200]}") 50 + return "\n\n".join(lines) 51 + except Exception as e: 52 + return f"failed to get own posts: {e}" 53 + 54 + @agent.tool 55 + async def check_urls(ctx: RunContext[PhiDeps], urls: list[str]) -> str: 56 + """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).""" 57 + 58 + async def _check(client: httpx.AsyncClient, url: str) -> str: 59 + if not url.startswith(("http://", "https://")): 60 + url = f"https://{url}" 61 + try: 62 + hostname = urlparse(url).hostname 63 + if not hostname: 64 + return f"{url} → blocked: no hostname" 65 + # resolve and check for private/loopback IPs (SSRF protection) 66 + try: 67 + addrs = await asyncio.get_event_loop().run_in_executor( 68 + None, lambda: socket.getaddrinfo(hostname, None) 69 + ) 70 + except socket.gaierror: 71 + return f"{url} → blocked: DNS resolution failed" 72 + for addr_info in addrs: 73 + ip = ipaddress.ip_address(addr_info[4][0]) 74 + if ip.is_private or ip.is_loopback or ip.is_link_local: 75 + return f"{url} → blocked: private IP" 76 + 77 + r = await client.head(url, follow_redirects=True) 78 + return f"{url} → {r.status_code}" 79 + except httpx.TimeoutException: 80 + return f"{url} → timeout" 81 + except Exception as e: 82 + return f"{url} → error: {type(e).__name__}" 83 + 84 + async with httpx.AsyncClient(timeout=10) as client: 85 + results = await asyncio.gather(*[_check(client, u) for u in urls]) 86 + return "\n".join(results) 87 + 88 + @agent.tool 89 + async def manage_labels( 90 + ctx: RunContext[PhiDeps], action: str, label: str = "" 91 + ) -> str: 92 + """Manage self-labels on your profile. Actions: 'list' to see current labels, 'add' to add a label, 'remove' to remove a label. The 'bot' label marks you as an automated account.""" 93 + from bot.core.profile_manager import ( 94 + add_self_label, 95 + get_self_labels, 96 + remove_self_label, 97 + ) 98 + 99 + if action == "list": 100 + labels = get_self_labels(bot_client.client) 101 + return f"current self-labels: {labels}" if labels else "no self-labels set" 102 + elif action == "add": 103 + if not label: 104 + return "provide a label value to add" 105 + labels = add_self_label(bot_client.client, label) 106 + return f"added '{label}', labels now: {labels}" 107 + elif action == "remove": 108 + if not label: 109 + return "provide a label value to remove" 110 + labels = remove_self_label(bot_client.client, label) 111 + return f"removed '{label}', labels now: {labels}" 112 + else: 113 + return f"unknown action '{action}', use 'list', 'add', or 'remove'" 114 + 115 + @agent.tool 116 + async def check_services(ctx: RunContext[PhiDeps]) -> str: 117 + """Check health of nate's infrastructure (plyr, PDS, prefect, etc) — NOT your own status. 118 + Do NOT call this when someone asks if you're online — that's about you, not infrastructure. 119 + Only use during daily reflection or when someone explicitly asks about services/infrastructure.""" 120 + return await _check_services_impl()
+71
src/bot/tools/cosmik.py
··· 1 + """Cosmik record tools — URL cards and connections.""" 2 + 3 + from pydantic_ai import RunContext 4 + 5 + from bot.tools._helpers import PhiDeps, _create_cosmik_record 6 + from bot.types import CosmikConnection, CosmikUrlCard, UrlContent 7 + 8 + 9 + def register(agent): 10 + @agent.tool 11 + async def save_url( 12 + ctx: RunContext[PhiDeps], 13 + url: str, 14 + title: str, 15 + description: str | None = None, 16 + ) -> str: 17 + """Save a URL as a cosmik card on your PDS. Use when you find something worth bookmarking publicly. 18 + Always provide a concise, descriptive title — this is what appears in the activity feed.""" 19 + try: 20 + card = CosmikUrlCard( 21 + content=UrlContent(url=url, title=title, description=description) 22 + ) 23 + except Exception as e: 24 + return f"validation failed: {e}" 25 + 26 + parts: list[str] = [] 27 + 28 + # public: cosmik URL card on PDS 29 + try: 30 + uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 31 + parts.append(f"card created: {uri}") 32 + except Exception as e: 33 + return f"failed to create card: {e}" 34 + 35 + # private: also store in turbopuffer for recall 36 + if ctx.deps.memory: 37 + desc = f"bookmarked {url}" + (f" — {title}" if title else "") 38 + await ctx.deps.memory.store_episodic_memory( 39 + desc, ["bookmark", "url"], source="tool" 40 + ) 41 + parts.append("noted privately") 42 + 43 + return " + ".join(parts) 44 + 45 + @agent.tool 46 + async def create_connection( 47 + ctx: RunContext[PhiDeps], 48 + source: str, 49 + target: str, 50 + connection_type: str | None = None, 51 + note: str | None = None, 52 + ) -> str: 53 + """Create a network.cosmik.connection record — a semantic link between two entities. 54 + Source and target must be URLs or at:// URIs. Connection types: related, supports, opposes, addresses, helpful, explainer, leads_to, supplements.""" 55 + try: 56 + conn = CosmikConnection( 57 + source=source, 58 + target=target, 59 + connection_type=connection_type, 60 + note=note, 61 + ) 62 + except Exception as e: 63 + return f"validation failed: {e}" 64 + 65 + try: 66 + uri = await _create_cosmik_record( 67 + "network.cosmik.connection", conn.to_record() 68 + ) 69 + return f"connection created: {uri}" 70 + except Exception as e: 71 + return f"failed to create connection: {e}"
+158
src/bot/tools/feeds.py
··· 1 + """Feed tools — graze feed CRUD, timeline, following.""" 2 + 3 + import logging 4 + 5 + from pydantic_ai import RunContext 6 + 7 + from bot.config import settings 8 + from bot.core.atproto_client import bot_client 9 + from bot.core.graze_client import GrazeClient 10 + from bot.tools._helpers import PhiDeps, _format_feed_posts, _is_owner 11 + 12 + logger = logging.getLogger("bot.tools.feeds") 13 + 14 + 15 + def register(agent, graze_client: GrazeClient): 16 + @agent.tool 17 + async def create_feed( 18 + ctx: RunContext[PhiDeps], 19 + name: str, 20 + display_name: str, 21 + description: str, 22 + filter_manifest: dict, 23 + ) -> str: 24 + """Create a new bluesky feed powered by graze. Only the bot's owner can use this tool. 25 + 26 + name: url-safe slug (e.g. "electronic-music"). becomes the feed rkey. 27 + display_name: human-readable feed title. 28 + description: what the feed shows. 29 + filter_manifest: graze filter DSL (grazer engine operators). key operators: 30 + - regex_any: ["field", ["term1", "term2"]] — match any term (case-insensitive by default) 31 + - regex_none: ["field", ["term1", "term2"]] — exclude posts matching any term 32 + - regex_matches: ["field", "pattern"] — single regex match 33 + - and: [...filters], or: [...filters] — combine filters 34 + field is usually "text". example: {"filter": {"and": [{"regex_any": ["text", ["jazz", "bebop"]]}]}} 35 + """ 36 + if not _is_owner(ctx): 37 + return f"only @{settings.owner_handle} can create feeds" 38 + try: 39 + result = await graze_client.create_feed( 40 + rkey=name, 41 + display_name=display_name, 42 + description=description, 43 + filter_manifest=filter_manifest, 44 + ) 45 + return f"feed created: {result['uri']} (algo_id={result['algo_id']})" 46 + except Exception as e: 47 + logger.warning(f"create_feed failed: {e}") 48 + return f"failed to create feed: {e}" 49 + 50 + @agent.tool 51 + async def list_feeds(ctx: RunContext[PhiDeps]) -> str: 52 + """List your existing graze-powered feeds. Returns name (slug for read_feed) and algo_id (for delete_feed).""" 53 + try: 54 + feeds = await graze_client.list_feeds() 55 + if not feeds: 56 + return "no graze feeds found" 57 + lines = [] 58 + for f in feeds: 59 + display = f.get("display_name") or f.get("name") or "unnamed" 60 + algo_id = f.get("id") or f.get("algo_id") or "?" 61 + uri = f.get("feed_uri") or f.get("uri") or "" 62 + # extract rkey slug from feed_uri for use with read_feed 63 + rkey = f.get("record_name") or (uri.rsplit("/", 1)[-1] if uri else "?") 64 + lines.append(f"- {display} | name={rkey} | algo_id={algo_id}") 65 + return "\n".join(lines) 66 + except Exception as e: 67 + logger.warning(f"list_feeds failed: {e}") 68 + return f"failed to list feeds: {e}" 69 + 70 + @agent.tool 71 + async def delete_feed(ctx: RunContext[PhiDeps], algo_id: int) -> str: 72 + """Delete a graze-powered feed by its algo_id. Only the bot's owner can use this tool. 73 + 74 + algo_id: the numeric id from list_feeds (e.g. 33726). 75 + This deletes both the graze registration and the PDS feed generator record. 76 + """ 77 + if not _is_owner(ctx): 78 + return f"only @{settings.owner_handle} can delete feeds" 79 + try: 80 + # find the record_name from graze so we can delete the PDS record too 81 + feeds = await graze_client.list_feeds() 82 + record_name = None 83 + for f in feeds: 84 + if f.get("id") == algo_id: 85 + record_name = f.get("record_name") 86 + break 87 + 88 + await graze_client.delete_feed(algo_id) 89 + 90 + # also delete the PDS record if we found the rkey 91 + if record_name: 92 + assert bot_client.client.me is not None 93 + try: 94 + bot_client.client.com.atproto.repo.delete_record( 95 + data={ 96 + "repo": bot_client.client.me.did, 97 + "collection": "app.bsky.feed.generator", 98 + "rkey": record_name, 99 + } 100 + ) 101 + except Exception as e: 102 + logger.warning(f"PDS record delete failed: {e}") 103 + 104 + return f"deleted feed algo_id={algo_id}" + ( 105 + f" and PDS record '{record_name}'" if record_name else "" 106 + ) 107 + except Exception as e: 108 + logger.warning(f"delete_feed failed: {e}") 109 + return f"failed to delete feed: {e}" 110 + 111 + # --- feed consumption + following --- 112 + 113 + @agent.tool 114 + async def read_timeline(ctx: RunContext[PhiDeps], limit: int = 20) -> str: 115 + """Read your 'following' timeline — posts from accounts you follow. Use this when someone asks what's on your feed or what people you follow are talking about.""" 116 + try: 117 + response = await bot_client.get_timeline(limit=limit) 118 + if not response.feed: 119 + return ( 120 + "your timeline is empty — you're not following anyone yet. " 121 + f"ask @{settings.owner_handle} to have me follow some accounts!" 122 + ) 123 + return _format_feed_posts(response.feed, limit=limit) 124 + except Exception as e: 125 + return f"failed to read timeline: {e}" 126 + 127 + @agent.tool 128 + async def read_feed(ctx: RunContext[PhiDeps], name: str, limit: int = 20) -> str: 129 + """Read posts from one of your graze-powered feeds. 130 + 131 + name: the feed slug (e.g. "mushroom-foraging"). use list_feeds to see available names. 132 + """ 133 + try: 134 + await bot_client.authenticate() 135 + assert bot_client.client.me is not None 136 + feed_uri = f"at://{bot_client.client.me.did}/app.bsky.feed.generator/{name}" 137 + response = await bot_client.get_feed(feed_uri, limit=limit) 138 + if not response.feed: 139 + return "no posts in this feed yet" 140 + return _format_feed_posts(response.feed, limit=limit) 141 + except Exception as e: 142 + return f"failed to read feed: {e}" 143 + 144 + @agent.tool 145 + async def follow_user(ctx: RunContext[PhiDeps], handle: str) -> str: 146 + """Follow a user on bluesky. Only the bot's owner can use this tool.""" 147 + if not _is_owner(ctx): 148 + return f"only @{settings.owner_handle} can ask me to follow people" 149 + try: 150 + # check if already following 151 + following = await bot_client.get_following() 152 + for f in following.follows: 153 + if f.handle == handle: 154 + return f"already following @{handle}" 155 + uri = await bot_client.follow_user(handle) 156 + return f"now following @{handle} ({uri})" 157 + except Exception as e: 158 + return f"failed to follow @{handle}: {e}"
+48
src/bot/tools/memory.py
··· 1 + """Memory tools — private recall and note-taking.""" 2 + 3 + from pydantic_ai import RunContext 4 + 5 + from bot.tools._helpers import ( 6 + PhiDeps, 7 + _format_unified_results, 8 + _format_user_results, 9 + ) 10 + 11 + 12 + def register(agent): 13 + @agent.tool 14 + async def recall(ctx: RunContext[PhiDeps], query: str, about: str = "") -> str: 15 + """Search your private memory. Use to remember past conversations and what you know about specific people. 16 + Pass about="@handle" to search a specific user, or leave empty for general private recall. 17 + For public network knowledge, use search_network instead.""" 18 + if not ctx.deps.memory: 19 + return "memory not available" 20 + 21 + if about.startswith("@"): 22 + handle = about.lstrip("@") 23 + results = await ctx.deps.memory.search(handle, query, top_k=10) 24 + if not results: 25 + return f"no memories found about @{handle}" 26 + return "\n".join(_format_user_results(results, handle)) 27 + 28 + if about == "": 29 + results = await ctx.deps.memory.search_unified( 30 + ctx.deps.author_handle, query, top_k=8 31 + ) 32 + if not results: 33 + return "no relevant memories found" 34 + return "\n".join(_format_unified_results(results, ctx.deps.author_handle)) 35 + 36 + # bare handle without @ 37 + results = await ctx.deps.memory.search(about, query, top_k=10) 38 + if not results: 39 + return f"no memories found about @{about}" 40 + return "\n".join(_format_user_results(results, about)) 41 + 42 + @agent.tool 43 + async def note(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 44 + """Leave a note for your future self. Stored privately for fast vector recall.""" 45 + if ctx.deps.memory: 46 + await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 47 + return f"noted — {content[:100]}" 48 + return "private memory not available"
+127
src/bot/tools/search.py
··· 1 + """Search tools — bluesky posts, cosmik network, trending.""" 2 + 3 + from datetime import date 4 + 5 + import httpx 6 + from pydantic_ai import RunContext 7 + 8 + from bot.core.atproto_client import bot_client 9 + from bot.tools._helpers import PhiDeps, _relative_age 10 + 11 + 12 + def register(agent): 13 + @agent.tool 14 + async def search_posts( 15 + ctx: RunContext[PhiDeps], query: str, limit: int = 10 16 + ) -> str: 17 + """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 18 + try: 19 + response = bot_client.client.app.bsky.feed.search_posts( 20 + params={"q": query, "limit": min(limit, 25), "sort": "top"} 21 + ) 22 + if not response.posts: 23 + return f"no posts found for '{query}'" 24 + 25 + today = date.today() 26 + lines = [] 27 + for post in response.posts: 28 + text = post.record.text if hasattr(post.record, "text") else "" 29 + handle = post.author.handle 30 + likes = post.like_count or 0 31 + age = ( 32 + _relative_age(post.indexed_at, today) 33 + if hasattr(post, "indexed_at") and post.indexed_at 34 + else "" 35 + ) 36 + age_str = f", {age}" if age else "" 37 + lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 38 + return "\n\n".join(lines) 39 + except Exception as e: 40 + return f"search failed: {e}" 41 + 42 + @agent.tool 43 + async def search_network(ctx: RunContext[PhiDeps], query: str) -> str: 44 + """Search the cosmik network for cards and bookmarks collected by people across the atmosphere. 45 + Use this to find what the network knows about a topic — links, notes, and resources that others have saved. 46 + Different from recall (your private memory) and search_posts (live bluesky posts).""" 47 + try: 48 + async with httpx.AsyncClient(timeout=15) as client: 49 + r = await client.get( 50 + "https://api.semble.so/api/search/semantic", 51 + params={"query": query, "limit": 10}, 52 + ) 53 + r.raise_for_status() 54 + data = r.json() 55 + 56 + # response is {urls: [...], pagination: {...}} 57 + items = data.get("urls") if isinstance(data, dict) else data 58 + if not items: 59 + return f"no network results for '{query}'" 60 + 61 + lines = [] 62 + for item in items: 63 + meta = item.get("metadata", {}) 64 + title = meta.get("title") or item.get("title") or "untitled" 65 + url = item.get("url", "") 66 + saves = item.get("urlLibraryCount") or 0 67 + desc = meta.get("description") or "" 68 + line = f"{title}" 69 + if url: 70 + line += f" — {url}" 71 + if saves: 72 + line += f" ({saves} saves)" 73 + if desc: 74 + line += f"\n {desc[:200]}" 75 + lines.append(line) 76 + return "\n\n".join(lines) 77 + except Exception as e: 78 + return f"network search failed: {e}" 79 + 80 + @agent.tool 81 + async def get_trending(ctx: RunContext[PhiDeps]) -> str: 82 + """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.""" 83 + parts: list[str] = [] 84 + 85 + async with httpx.AsyncClient(timeout=15) as client: 86 + # coral entity graph — NER-extracted trending entities from the firehose 87 + try: 88 + r = await client.get("https://coral.fly.dev/entity-graph") 89 + r.raise_for_status() 90 + data = r.json() 91 + entities = data.get("entities", []) 92 + stats = data.get("stats", {}) 93 + 94 + by_trend = sorted( 95 + entities, key=lambda e: e.get("trend", 0), reverse=True 96 + )[:15] 97 + 98 + lines = [ 99 + f"coral ({stats.get('active', 0)} active entities, " 100 + f"{stats.get('clusters', 0)} clusters" 101 + f"{', percolating' if stats.get('percolates') else ''}):" 102 + ] 103 + for e in by_trend: 104 + lines.append( 105 + f" {e['text']} ({e.get('label', '')}) " 106 + f"trend={e.get('trend', 0):.2f}" 107 + ) 108 + parts.append("\n".join(lines)) 109 + except Exception as e: 110 + parts.append(f"coral unavailable: {e}") 111 + 112 + # official bluesky trending topics 113 + try: 114 + r = await client.get( 115 + "https://public.api.bsky.app/xrpc/app.bsky.unspecced.getTrendingTopics" 116 + ) 117 + r.raise_for_status() 118 + topics = r.json().get("topics", []) 119 + if topics: 120 + lines = ["bluesky trending:"] 121 + for t in topics[:15]: 122 + lines.append(f" {t.get('displayName', t.get('topic', ''))}") 123 + parts.append("\n".join(lines)) 124 + except Exception as e: 125 + parts.append(f"bluesky trending unavailable: {e}") 126 + 127 + return "\n\n".join(parts) if parts else "no trending data available"
+43
src/bot/types.py
··· 1 1 """Validated types for atproto records phi creates.""" 2 2 3 + import time 3 4 from datetime import UTC, datetime 4 5 from typing import Annotated, Literal 5 6 6 7 from pydantic import AfterValidator, BaseModel, Field 8 + 9 + _TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" 10 + 11 + 12 + def generate_tid() -> str: 13 + """Generate an AT Protocol TID (timestamp identifier). 14 + 15 + 13-char base32-sortstring encoding microsecond timestamp + clock_id. 16 + """ 17 + us = int(time.time() * 1_000_000) 18 + n = (us << 10) | 0 # clock_id = 0 19 + chars = [] 20 + for _ in range(13): 21 + chars.append(_TID_CHARSET[n & 0x1F]) 22 + n >>= 5 23 + return "".join(reversed(chars)) 24 + 7 25 8 26 # --- validators --- 9 27 ··· 194 212 "addedBy": self.added_by, 195 213 "addedAt": self.added_at, 196 214 } 215 + 216 + 217 + class GreenGaleDocument(BaseModel): 218 + """app.greengale.document record — a long-form markdown blog post. 219 + 220 + Published to phi's PDS, rendered at greengale.app/{handle}/{rkey}, 221 + and indexed by pub-search for discoverability. 222 + """ 223 + 224 + title: str = Field(max_length=1000) 225 + content: str = Field(max_length=100000) 226 + tags: list[str] = Field(default_factory=list) 227 + visibility: Literal["public", "url", "author"] = "public" 228 + 229 + def to_record(self, handle: str, rkey: str) -> dict: 230 + return { 231 + "$type": "app.greengale.document", 232 + "content": self.content, 233 + "title": self.title, 234 + "url": f"https://greengale.app/{handle}", 235 + "path": f"/{rkey}", 236 + "publishedAt": datetime.now(UTC).isoformat(), 237 + "visibility": self.visibility, 238 + "tags": self.tags, 239 + }
+2 -2
tests/test_tool_usage.py
··· 138 138 if not os.environ.get("ANTHROPIC_API_KEY"): 139 139 pytest.skip("No Anthropic API key configured") 140 140 141 - with patch("bot.agent.bot_client"): 141 + with patch("bot.core.atproto_client.bot_client"): 142 142 from bot.agent import PhiAgent 143 143 144 144 agent = PhiAgent() ··· 154 154 if not os.environ.get("ANTHROPIC_API_KEY"): 155 155 pytest.skip("No Anthropic API key configured") 156 156 157 - with patch("bot.agent.bot_client"): 157 + with patch("bot.core.atproto_client.bot_client"): 158 158 from bot.agent import PhiAgent 159 159 160 160 agent = PhiAgent()