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.

pre-reply stranger lookup + skepticism guidance

when phi receives a notification from someone it doesn't already have
stored knowledge about, fetch their profile and recent posts before
the agent run and inject as context. models the natural "let me see
who you are" behavior — not a hard gate, just enrichment so phi has
something to actually look at when deciding how to respond.

also adds a skepticism paragraph to operational instructions covering
the tells of low-substance content engines: drifting numerals, "we
built this" without artifacts, templated post shapes, sales links.

motivated by an exchange with a flattery-bot account where phi made
22 posts in 1h45m citing "19 agents, 70 days" with shifting numbers
across replies. phi had no signal that the counterparty was producing
same-shape slop across many threads, and no skepticism heuristic for
unverifiable empirical claims.

implementation:
- NamespaceMemory.get_knowledge_count + is_stranger
- bot/utils/lookup.py: fetch_author_lookup helper
- PhiDeps.author_lookup + inject_author_lookup dynamic system prompt
- MessageHandler._maybe_lookup_stranger called from all three handlers
(post, engagement, follow)
- skipped for owner and bot itself
- best-effort: any fetch error returns None, never blocks the reply

also hoists two deferred imports per CLAUDE.md style.

zzstoatzz 19300d20 cf893508

+160 -13
+1 -1
loq.toml
··· 17 17 18 18 [[rules]] 19 19 path = "src/bot/memory/namespace_memory.py" 20 - max_lines = 888 20 + max_lines = 894 21 21 22 22 [[rules]] 23 23 path = "src/bot/main.py"
+18 -2
src/bot/agent.py
··· 12 12 from pydantic_ai.mcp import MCPServerStreamableHTTP 13 13 14 14 from bot.config import settings 15 + from bot.core.curiosity_queue import claim, complete, enqueue, fail 15 16 from bot.core.graze_client import GrazeClient 16 17 from bot.exploration import EXPLORATION_SYSTEM_PROMPT, ExplorationResult 17 18 from bot.memory.extraction import EXTRACTION_SYSTEM_PROMPT, ExtractionResult ··· 43 44 - if the user's current message contradicts your notes, trust their current words. 44 45 - never assert personal details (names, roles, relationships) from synthesized impressions as fact. say "my notes suggest..." or verify with the user. 45 46 - if you're uncertain whether something is real or a bad breadcrumb, say so. 47 + 48 + healthy skepticism — the way a scientist or a careful editor would: 49 + when someone makes specific empirical claims — "we measured X across Y agents," "we ran this for N days," "calibrated against Z traces" — those are claims to investigate, not facts to accept. you have the tools to check: read their recent posts, see if their numbers stay consistent across posts, see if there's an artifact behind the claim or just rhetoric. 50 + tells of an account producing low-substance content at volume: 51 + - suspiciously round numerals that drift between posts (19 agents, 70 days, 1,315 traces in one post and 1,352 in another) 52 + - "we built this / going into the spec / this thread just designed the system" framing without ever linking to the artifact 53 + - every post has the same template-shape regardless of topic 54 + - repeated links to a product they're selling 55 + when you see these signals, slow down. one or two replies to engage genuinely is fine; getting drawn into a long back-and-forth with a flattery engine is not. being interested is not the same as being convinced — you can find a question genuinely interesting without granting its source credibility it hasn't earned. when in doubt, ask for the artifact: "what does the data look like? is the methodology written up somewhere?" a real practitioner answers that. a content engine flinches. 46 56 47 57 your tools for finding information: 48 58 - recall: your private memory — what you know about people you've talked to, past conversations. use about="@handle" for a specific person. ··· 204 214 return f"[SERVICE HEALTH]:\n{ctx.deps.service_health}" 205 215 return "" 206 216 217 + @self.agent.system_prompt(dynamic=True) 218 + def inject_author_lookup(ctx: RunContext[PhiDeps]) -> str: 219 + if ctx.deps.author_lookup: 220 + return ctx.deps.author_lookup 221 + return "" 222 + 207 223 # --- register tools from tools/ package --- 208 224 209 225 self.graze_client = GrazeClient( ··· 254 270 thread_context: str, 255 271 thread_uri: str | None = None, 256 272 image_urls: list[str] | None = None, 273 + author_lookup: str | None = None, 257 274 ) -> Response: 258 275 """Process a mention with structured memory context.""" 259 276 logger.info(f"processing mention from @{author_handle}: {mention_text[:80]}") ··· 263 280 memory=self.memory, 264 281 thread_uri=thread_uri, 265 282 thread_context=thread_context, 283 + author_lookup=author_lookup, 266 284 ) 267 285 268 286 # User prompt is just the message — context is injected via dynamic system prompts ··· 462 480 463 481 async def process_exploration(self) -> int: 464 482 """Claim one curiosity item, explore it, store findings. Returns count stored.""" 465 - from bot.core.curiosity_queue import claim, complete, enqueue, fail 466 - 467 483 claimed = await claim() 468 484 if not claimed: 469 485 return 0
+16 -10
src/bot/memory/namespace_memory.py
··· 10 10 from turbopuffer import Turbopuffer 11 11 12 12 from bot.config import settings 13 + from bot.core.curiosity_queue import enqueue as enqueue_curiosity 13 14 from bot.memory.extraction import ( 14 15 EPISODIC_SCHEMA, 15 16 USER_NAMESPACE_SCHEMA, ··· 849 850 ) 850 851 logger.info(f"stored exploration note for @{handle}: {content[:80]}") 851 852 852 - async def _maybe_enqueue_exploration(self, handle: str): 853 - """If we don't know much about this person, queue them for exploration. 853 + async def get_knowledge_count(self, handle: str) -> int: 854 + """Count observations + exploration notes phi has stored about a handle. 854 855 855 - Counts both observations and exploration_notes — if we've already 856 - explored someone, don't re-enqueue just because obs count is low. 856 + Used to gauge how much we know about someone — for exploration triggers 857 + and pre-reply lookup decisions. 857 858 """ 858 859 user_ns = self.get_user_namespace(handle) 859 860 try: 860 - # count observations + exploration notes together 861 861 response = user_ns.query( 862 862 rank_by=("created_at", "desc"), 863 863 top_k=2, ··· 870 870 ], 871 871 include_attributes=["kind"], 872 872 ) 873 - knowledge_count = len(response.rows) if response.rows else 0 873 + return len(response.rows) if response.rows else 0 874 874 except Exception: 875 - knowledge_count = 0 # namespace may not exist yet — worth exploring 875 + return 0 # namespace may not exist yet — treated as stranger 876 876 877 - if knowledge_count < 2: 878 - from bot.core.curiosity_queue import enqueue 877 + async def is_stranger(self, handle: str) -> bool: 878 + """True if phi has fewer than 2 stored knowledge items about this handle.""" 879 + return await self.get_knowledge_count(handle) < 2 879 880 880 - await enqueue(kind="explore_handle", subject=handle, source="interaction") 881 + async def _maybe_enqueue_exploration(self, handle: str): 882 + """If we don't know much about this person, queue them for deeper exploration.""" 883 + if await self.is_stranger(handle): 884 + await enqueue_curiosity( 885 + kind="explore_handle", subject=handle, source="interaction" 886 + ) 881 887 882 888 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 883 889 """Post-interaction hook: store the raw exchange, maybe queue exploration."""
+32
src/bot/services/message_handler.py
··· 12 12 from bot.config import settings 13 13 from bot.core.atproto_client import BotClient 14 14 from bot.status import bot_status 15 + from bot.utils.lookup import fetch_author_lookup 15 16 from bot.utils.thread import ( 16 17 build_thread_context, 17 18 describe_embed, ··· 49 50 self.client = client 50 51 self.agent = PhiAgent() 51 52 53 + async def _maybe_lookup_stranger(self, author_handle: str) -> str | None: 54 + """If author is a stranger, fetch their profile + recent posts as context. 55 + 56 + Pre-reply behavior modeled on what a person would naturally do when 57 + someone they don't know responds to them: glance at the profile. 58 + Skipped for the owner, phi itself, and anyone phi already knows. 59 + """ 60 + if not self.agent.memory: 61 + return None 62 + if author_handle in (settings.owner_handle, settings.bluesky_handle): 63 + return None 64 + try: 65 + if not await self.agent.memory.is_stranger(author_handle): 66 + return None 67 + except Exception as e: 68 + logger.debug(f"is_stranger check failed for @{author_handle}: {e}") 69 + return None 70 + try: 71 + return await fetch_author_lookup(self.client, author_handle) 72 + except Exception as e: 73 + logger.debug(f"author lookup failed for @{author_handle}: {e}") 74 + return None 75 + 52 76 async def handle_notification(self, notification): 53 77 """Process any notification through the agent.""" 54 78 reason = notification.reason ··· 95 119 96 120 mention_text = f"[notification: @{author_handle} {reason}d your post]\nyour post: {post_text}" 97 121 122 + author_lookup = await self._maybe_lookup_stranger(author_handle) 98 123 response = await self.agent.process_mention( 99 124 mention_text=mention_text, 100 125 author_handle=author_handle, 101 126 thread_context="", 127 + author_lookup=author_lookup, 102 128 ) 103 129 104 130 if response.action == "ignore": ··· 133 159 134 160 mention_text = f"[notification: @{author_handle} followed you]" 135 161 162 + author_lookup = await self._maybe_lookup_stranger(author_handle) 136 163 response = await self.agent.process_mention( 137 164 mention_text=mention_text, 138 165 author_handle=author_handle, 139 166 thread_context="", 167 + author_lookup=author_lookup, 140 168 ) 141 169 142 170 if response.action == "ignore": ··· 198 226 except Exception as e: 199 227 logger.warning(f"failed to fetch thread context: {e}") 200 228 229 + # Pre-reply lookup for strangers — natural "let me see who you are" behavior 230 + author_lookup = await self._maybe_lookup_stranger(author_handle) 231 + 201 232 # Process with agent (has episodic memory + MCP tools) 202 233 response = await self.agent.process_mention( 203 234 mention_text=mention_text, ··· 205 236 thread_context=thread_context, 206 237 thread_uri=thread_uri, 207 238 image_urls=image_urls, 239 + author_lookup=author_lookup, 208 240 ) 209 241 210 242 # Handle response actions
+1
src/bot/tools/_helpers.py
··· 28 28 last_post_text: str | None = None 29 29 recent_activity: str | None = None 30 30 service_health: str | None = None 31 + author_lookup: str | None = None 31 32 32 33 33 34 def _is_owner(ctx: RunContext[PhiDeps]) -> bool:
+92
src/bot/utils/lookup.py
··· 1 + """Cold-contact lookup — fetch a stranger's profile + recent posts before replying. 2 + 3 + This is the synchronous "let me see who you are" pre-reply behavior. Distinct from 4 + the background exploration loop (which writes findings to memory for later) — this 5 + just enriches the current reply context with what's publicly visible right now. 6 + """ 7 + 8 + import logging 9 + 10 + from bot.core.atproto_client import BotClient 11 + 12 + logger = logging.getLogger("bot.lookup") 13 + 14 + 15 + async def fetch_author_lookup( 16 + client: BotClient, handle: str, post_limit: int = 10 17 + ) -> str | None: 18 + """Fetch a handle's profile description + recent posts as formatted context. 19 + 20 + Returns formatted text suitable for injection into the agent's system prompt, 21 + or None on failure or if no useful data was retrieved. Best-effort: any error 22 + returns None rather than propagating, since this is enrichment, not gating. 23 + """ 24 + await client.authenticate() 25 + 26 + profile_desc = "" 27 + follower_count: int | None = None 28 + follow_count: int | None = None 29 + post_count: int | None = None 30 + created_at = "" 31 + try: 32 + profile = client.client.app.bsky.actor.get_profile({"actor": handle}) 33 + profile_desc = (getattr(profile, "description", "") or "").strip() 34 + follower_count = getattr(profile, "followers_count", None) 35 + follow_count = getattr(profile, "follows_count", None) 36 + post_count = getattr(profile, "posts_count", None) 37 + created_at = getattr(profile, "created_at", "") or "" 38 + except Exception as e: 39 + logger.debug(f"profile fetch failed for @{handle}: {e}") 40 + 41 + posts: list[str] = [] 42 + try: 43 + feed = client.client.app.bsky.feed.get_author_feed( 44 + params={"actor": handle, "limit": post_limit, "filter": "posts_no_replies"} 45 + ) 46 + for item in feed.feed: 47 + record = getattr(item.post, "record", None) 48 + text = getattr(record, "text", "") if record else "" 49 + indexed = getattr(item.post, "indexed_at", "") or "" 50 + ts = indexed[:16].replace("T", " ") if indexed else "" 51 + if text: 52 + posts.append(f" [{ts}] {text}") 53 + except Exception as e: 54 + logger.debug(f"author feed fetch failed for @{handle}: {e}") 55 + 56 + if not profile_desc and not posts: 57 + return None 58 + 59 + parts = [ 60 + f"[FIRST INTERACTION WITH @{handle} — phi looked at their public profile before responding]" 61 + ] 62 + 63 + profile_lines = [] 64 + if profile_desc: 65 + profile_lines.append(f"bio: {profile_desc}") 66 + counts: list[str] = [] 67 + if post_count is not None: 68 + counts.append(f"{post_count} posts") 69 + if follower_count is not None: 70 + counts.append(f"{follower_count} followers") 71 + if follow_count is not None: 72 + counts.append(f"following {follow_count}") 73 + if created_at: 74 + counts.append(f"joined {created_at[:10]}") 75 + if counts: 76 + profile_lines.append(" · ".join(counts)) 77 + if profile_lines: 78 + parts.append("profile:") 79 + parts.extend(f" {line}" for line in profile_lines) 80 + 81 + if posts: 82 + parts.append(f"recent posts (last {len(posts)}):") 83 + parts.extend(posts) 84 + 85 + parts.append( 86 + "use this to gauge who you're talking to. apply the same skepticism you'd " 87 + "apply to any stranger making claims — templated repetition, suspiciously round " 88 + "numerals across posts, links to a product they're selling, and 'we built X' " 89 + "without artifacts are tells. being curious is fine; granting credibility is earned." 90 + ) 91 + 92 + return "\n".join(parts)