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.

separate personality from system instructions

phi.md is now pure identity/voice — removed capabilities list (redundant
with tool docstrings), response format (moved to Response model fields),
tool discipline (folded into engagement section), and platform constraints
(handled by code). operational guardrails live in agent.py as a separate
constant appended to the system prompt.

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

+81 -76
+4 -39
personalities/phi.md
··· 37 37 38 38 ## nate 39 39 40 - nate (@zzstoatzz.io) built phi and points it at things worth paying attention to. he adjusts phi's tools, memory, and personality openly — this document is the record. 41 - 42 - nate decides what phi pays attention to. phi decides what to say about it. 40 + nate (@zzstoatzz.io) built phi and adjusts its tools, memory, and personality openly — this document is the record. 43 41 44 42 ## honesty 45 43 ··· 49 47 50 48 phi responds when someone is genuinely talking to it. it ignores spam, bots, provocations, and bad faith. if people are having their own conversation, phi stays out of it. 51 49 52 - phi shows up. it doesn't say "let me look that up" or promise a future action — it has one shot per mention and it takes it. the response might not be perfect every time, but phi engages honestly with what's in front of it. 50 + phi does its homework before responding. if a question needs research, phi researches first and replies with what it found — it never promises a follow-up or says "let me look that up." each mention is one shot, and phi takes it. 53 51 54 - --- 52 + if phi shares a link, it verifies the link works first. 55 53 56 54 ## style 57 55 58 56 - lowercase unless idiomatic 59 - - bluesky has a 300-char limit — use far less when possible 60 - - no emojis, no filler, no pleasantries 61 - 62 - ## capabilities 63 - 64 - - remember facts about people via episodic memory (automatically extracted after conversations) 65 - - remember things about the world via `remember` tool (facts, patterns, events worth recalling) 66 - - search own memory via `search_my_memory` for things previously learned 67 - - see thread context when replying 68 - - use pdsx tools for atproto record operations (create, list, get, update, delete any record type) 69 - - search memory for more context about a user when needed 70 - - search ATProto publications (leaflet, whitewind, offprint, etc.) via pub-search tools (prefixed with `pub_`) 71 - - search bluesky posts by keyword via `search_posts` 72 - - check what's trending on bluesky via `get_trending` (entity-level trends from coral + official trending topics) 73 - - manage self-labels on your profile via `manage_labels` (list, add, remove — e.g. the "bot" label) 74 - - post to bluesky via `post` (top-level posts, not replies) 75 - 76 - ## how responses work 77 - 78 - use the `final_result` tool to indicate your decision: 79 - 80 - - **reply** — respond with text (provide in "text" field) 81 - - **like** — acknowledge without words 82 - - **repost** — share with followers 83 - - **ignore** — decline to respond (provide brief reason in "reason" field) 84 - 85 - do NOT directly post, like, or repost using atproto tools — indicate the action and the message handler executes it. 86 - 87 - ## tool discipline 88 - 89 - - always complete your research (search, check_urls, etc.) BEFORE submitting a reply. 90 - - never reply with "let me look that up" or promise a future action — you only get one reply per mention. 91 - - if you want to share links, use `check_urls` first to verify they work. 92 - - always include `https://` when sharing URLs so they render as clickable links. 57 + - no emojis, no filler
+77 -37
src/bot/agent.py
··· 8 8 from pathlib import Path 9 9 10 10 import httpx 11 - from pydantic import BaseModel 11 + from pydantic import BaseModel, Field 12 12 from pydantic_ai import Agent, ImageUrl, RunContext 13 13 from pydantic_ai.mcp import MCPServerStreamableHTTP 14 14 ··· 16 16 from bot.memory import NamespaceMemory 17 17 18 18 logger = logging.getLogger("bot.agent") 19 + 20 + # Operational instructions kept separate from personality — these are 21 + # system-level guardrails that change when tools/architecture change. 22 + OPERATIONAL_INSTRUCTIONS = """ 23 + indicate your response action via the structured output — do not use atproto tools to post, like, or repost directly. 24 + when sharing URLs, verify them with check_urls first and always include https://. 25 + """.strip() 19 26 20 27 21 28 def _relative_age(timestamp: str, today: date) -> str: ··· 56 63 class Response(BaseModel): 57 64 """Agent response indicating what action to take.""" 58 65 59 - action: str # "reply", "like", "ignore", "repost" 60 - text: str | None = None 61 - reason: str | None = None 66 + action: str = Field(description="reply, like, repost, or ignore") 67 + text: str | None = Field(default=None, description="response text when action is reply") 68 + reason: str | None = Field(default=None, description="brief reason when action is ignore") 69 + 70 + 71 + def _format_user_results(results: list[dict], handle: str) -> list[str]: 72 + parts = [] 73 + for r in results: 74 + kind = r.get("kind", "unknown") 75 + content = r.get("content", "") 76 + tags = r.get("tags", []) 77 + tag_str = f"[{', '.join(tags)}]" if tags else "" 78 + parts.append(f"[{kind}]{tag_str} {content}") 79 + return parts 80 + 81 + 82 + def _format_episodic_results(results: list[dict]) -> list[str]: 83 + parts = [] 84 + for r in results: 85 + tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 86 + parts.append(f"{r['content']}{tags}") 87 + return parts 88 + 89 + 90 + def _format_unified_results(results: list[dict], handle: str) -> list[str]: 91 + parts = [] 92 + for r in results: 93 + source = r.get("_source", "") 94 + content = r.get("content", "") 95 + tags = r.get("tags", []) 96 + tag_str = f" [{', '.join(tags)}]" if tags else "" 97 + if source == "user": 98 + kind = r.get("kind", "unknown") 99 + parts.append(f"[@{handle} {kind}]{tag_str} {content}") 100 + else: 101 + parts.append(f"[note]{tag_str} {content}") 102 + return parts 62 103 63 104 64 105 class PhiAgent: ··· 104 145 self.agent = Agent[PhiDeps, Response]( 105 146 name="phi", 106 147 model="anthropic:claude-sonnet-4-6", 107 - system_prompt=self.base_personality, 148 + system_prompt=f"{self.base_personality}\n\n{OPERATIONAL_INSTRUCTIONS}", 108 149 output_type=Response, 109 150 deps_type=PhiDeps, 110 151 toolsets=[pdsx_mcp, pub_search_mcp], 111 152 ) 112 153 113 - # Register search_memory tool on the agent 154 + # --- memory tools --- 155 + 114 156 @self.agent.tool 115 - async def search_memory(ctx: RunContext[PhiDeps], query: str) -> str: 116 - """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.""" 157 + async def recall(ctx: RunContext[PhiDeps], query: str, about: str = "") -> str: 158 + """Search memory. By default searches both your notes and what you know about the current user. 159 + Pass about="@handle" to search a specific user, or about="self" for only your own notes.""" 117 160 if not ctx.deps.memory: 118 161 return "memory not available" 119 162 120 - results = await ctx.deps.memory.search(ctx.deps.author_handle, query, top_k=10) 163 + if about == "self": 164 + results = await ctx.deps.memory.search_episodic(query, top_k=10) 165 + if not results: 166 + return "no relevant memories found" 167 + return "\n".join(_format_episodic_results(results)) 168 + 169 + if about.startswith("@"): 170 + handle = about.lstrip("@") 171 + results = await ctx.deps.memory.search(handle, query, top_k=10) 172 + if not results: 173 + return f"no memories found about @{handle}" 174 + return "\n".join(_format_user_results(results, handle)) 175 + 176 + if about == "": 177 + results = await ctx.deps.memory.search_unified(ctx.deps.author_handle, query, top_k=8) 178 + if not results: 179 + return "no relevant memories found" 180 + return "\n".join(_format_unified_results(results, ctx.deps.author_handle)) 181 + 182 + # bare handle without @ 183 + results = await ctx.deps.memory.search(about, query, top_k=10) 121 184 if not results: 122 - return "no relevant memories found" 123 - 124 - parts = [] 125 - for r in results: 126 - kind = r.get("kind", "unknown") 127 - content = r.get("content", "") 128 - tags = r.get("tags", []) 129 - tag_str = f" [{', '.join(tags)}]" if tags else "" 130 - parts.append(f"[{kind}]{tag_str} {content}") 131 - return "\n".join(parts) 185 + return f"no memories found about @{about}" 186 + return "\n".join(_format_user_results(results, about)) 132 187 133 188 @self.agent.tool 134 - async def remember(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 135 - """Store something you learned or found interesting in your memory. 136 - Use sparingly — only for facts worth recalling in future conversations.""" 189 + async def note(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 190 + """Leave a note for your future self. Use for facts, patterns, or corrections worth recalling later.""" 137 191 if not ctx.deps.memory: 138 192 return "memory not available" 139 193 await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 140 - return f"remembered: {content[:100]}" 141 - 142 - @self.agent.tool 143 - async def search_my_memory(ctx: RunContext[PhiDeps], query: str) -> str: 144 - """Search your own memory for things you've previously learned about the world.""" 145 - if not ctx.deps.memory: 146 - return "memory not available" 147 - results = await ctx.deps.memory.search_episodic(query, top_k=10) 148 - if not results: 149 - return "no relevant memories found" 150 - parts = [] 151 - for r in results: 152 - tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 153 - parts.append(f"{r['content']}{tags}") 154 - return "\n".join(parts) 194 + return f"noted: {content[:100]}" 155 195 156 196 @self.agent.tool 157 197 async def search_posts(ctx: RunContext[PhiDeps], query: str, limit: int = 10) -> str: