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 [SELF STATE] block — fresh-observer haiku summary + canonical PDS pointers

phi now sees its own posting pattern from outside on every tick. the
characterization is generated by a haiku-pass over the last 10 top-level
posts framed as if from a stranger reading the timeline cold — same voice
that lands on people who don't already know phi.

sources are canonical:
- posts: app.bsky.feed.post on phi's PDS (haiku derives summary)
- last follow: app.bsky.graph.follow on phi's PDS
- queue depth: io.zzstoatzz.phi.curiosityQueue on phi's PDS

the haiku summary is *derived* (not duplicated state) and cached in
memory: 1h TTL, invalidated when the latest post URI changes. the whole
block is also block-cached at 5min so notification polls (10s) don't
hammer PDS.

groundwork for collapsing trigger paths into a single tick loop where
phi can see its balance — concentration, gaps, recent action distribution
— and decide what's worth doing without each behavior being baked in as
a separate scheduled prompt.

also: hoist fetch_relay_names + get_state_block imports to module scope
(no circular deps; deferred imports were just habit).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+194 -2
+7 -2
src/bot/agent.py
··· 14 14 from bot.core.atproto_client import bot_client, get_identity_block 15 15 from bot.core.curiosity_queue import claim, complete, enqueue, fail 16 16 from bot.core.graze_client import GrazeClient 17 + from bot.core.self_state import get_state_block 17 18 from bot.exploration import EXPLORATION_SYSTEM_PROMPT, ExplorationResult 18 19 from bot.memory.extraction import EXTRACTION_SYSTEM_PROMPT, ExtractionResult 19 20 from bot.memory.review import REVIEW_SYSTEM_PROMPT, ReviewResult 20 21 from bot.tools import PhiDeps, _check_services_impl, register_all 22 + from bot.tools.bluesky import fetch_relay_names 21 23 22 24 logger = logging.getLogger("bot.agent") 23 25 ··· 172 174 @self.agent.system_prompt(dynamic=True) 173 175 async def inject_known_relays() -> str: 174 176 """List the valid relay hostnames for check_relays(name=...).""" 175 - from bot.tools.bluesky import fetch_relay_names 176 - 177 177 names = await fetch_relay_names() 178 178 if not names: 179 179 return "" 180 180 return "[KNOWN RELAYS]: " + ", ".join(names) 181 + 182 + @self.agent.system_prompt(dynamic=True) 183 + async def inject_self_state() -> str: 184 + """How phi looks from outside + canonical pointers (last follow, queue).""" 185 + return await get_state_block(bot_client) 181 186 182 187 @self.agent.system_prompt(dynamic=True) 183 188 def inject_notifications(ctx: RunContext[PhiDeps]) -> str:
+187
src/bot/core/self_state.py
··· 1 + """[SELF STATE] block — phi's view of its own recent posting pattern, queue, and engagement. 2 + 3 + Sources are canonical: posts/follows live on PDS, the haiku summary is *derived* 4 + from those posts and cached in-memory (regeneratable, not duplicated state). 5 + """ 6 + 7 + import logging 8 + import time 9 + from datetime import UTC, datetime 10 + 11 + from pydantic_ai import Agent 12 + 13 + from bot.config import settings 14 + from bot.core.atproto_client import BotClient 15 + 16 + logger = logging.getLogger("bot.self_state") 17 + 18 + # Haiku summary cache — invalidated by new post (latest URI changes) or TTL. 19 + _SUMMARY_TTL_SECONDS = 3600 # 1h 20 + _summary_cache: dict = {"text": "", "fetched_at": 0.0, "based_on_uri": ""} 21 + 22 + # Whole-block cache — re-renders at most every few minutes so dynamic system 23 + # prompts firing on every notification poll (10s) don't hammer PDS. 24 + _BLOCK_TTL_SECONDS = 300 # 5min 25 + _block_cache: dict = {"text": "", "fetched_at": 0.0} 26 + 27 + # Lazy haiku agent — characterizes recent posts as if from a fresh observer. 28 + # Framing is intentional: phi sees how its voice lands to someone with no prior 29 + # context, which is also how strangers actually encounter the timeline. 30 + _summary_agent: Agent | None = None 31 + 32 + 33 + def _get_summary_agent() -> Agent: 34 + global _summary_agent 35 + if _summary_agent is None: 36 + _summary_agent = Agent[None, str]( 37 + name="phi-self-summary", 38 + model=settings.extraction_model, 39 + system_prompt=( 40 + "You read recent top-level posts from a single Bluesky account " 41 + "and characterize what they've been writing about lately, as if " 42 + "you're a fresh observer who's never seen this account before. " 43 + "Lowercase. No preamble, no meta-commentary, no quoting back. " 44 + "Two or three short observations max. Note: themes, recurring " 45 + "beats, who they reference, anything notable (concentration on " 46 + "one topic or person, absences from usual variety, " 47 + "grounded-vs-pattern-matched balance)." 48 + ), 49 + output_type=str, 50 + ) 51 + return _summary_agent 52 + 53 + 54 + async def _summarize_posts(posts: list[str]) -> str: 55 + if not posts: 56 + return "" 57 + payload = "\n\n---\n\n".join(posts) 58 + try: 59 + result = await _get_summary_agent().run(payload) 60 + return (result.output or "").strip() 61 + except Exception as e: 62 + logger.warning(f"haiku summary failed: {e}") 63 + return "" 64 + 65 + 66 + def _relative_when(iso_ts: str) -> str: 67 + """Human-readable age from an ISO timestamp.""" 68 + try: 69 + ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")) 70 + except (ValueError, TypeError): 71 + return "" 72 + delta = datetime.now(UTC) - ts 73 + days = delta.days 74 + if days < 0: 75 + return "" 76 + if days == 0: 77 + hours = delta.seconds // 3600 78 + return f"{hours}h ago" if hours else "just now" 79 + if days == 1: 80 + return "1d ago" 81 + if days < 30: 82 + return f"{days}d ago" 83 + months = days // 30 84 + return f"{months}mo ago" if months < 12 else f"{days // 365}y ago" 85 + 86 + 87 + async def _last_follow_when(client: BotClient) -> str: 88 + """Look up the most recent app.bsky.graph.follow record on phi's PDS.""" 89 + try: 90 + await client.authenticate() 91 + if not client.client.me: 92 + return "" 93 + response = client.client.com.atproto.repo.list_records( 94 + { 95 + "repo": client.client.me.did, 96 + "collection": "app.bsky.graph.follow", 97 + "limit": 1, 98 + } 99 + ) 100 + if not response.records: 101 + return "" 102 + record = response.records[0] 103 + created_at = dict(record.value).get("createdAt", "") if record.value else "" 104 + return _relative_when(created_at) 105 + except Exception as e: 106 + logger.debug(f"last_follow lookup failed: {e}") 107 + return "" 108 + 109 + 110 + async def _queue_depth(client: BotClient) -> int: 111 + """Count pending items in phi's curiosity queue.""" 112 + try: 113 + await client.authenticate() 114 + if not client.client.me: 115 + return 0 116 + response = client.client.com.atproto.repo.list_records( 117 + { 118 + "repo": client.client.me.did, 119 + "collection": "io.zzstoatzz.phi.curiosityQueue", 120 + "limit": 100, 121 + } 122 + ) 123 + return sum( 124 + 1 for r in response.records if dict(r.value).get("status") == "pending" 125 + ) 126 + except Exception as e: 127 + logger.debug(f"queue depth lookup failed: {e}") 128 + return 0 129 + 130 + 131 + async def get_state_block(client: BotClient) -> str: 132 + """Compose the [SELF STATE] block. 133 + 134 + Returns the cached block text when fresh; recomputes from canonical PDS 135 + state otherwise. The haiku summary inside is cached separately (longer TTL, 136 + URI-invalidated) so we don't regenerate it on every block refresh. 137 + """ 138 + now = time.time() 139 + if _block_cache["text"] and now - _block_cache["fetched_at"] < _BLOCK_TTL_SECONDS: 140 + return _block_cache["text"] 141 + 142 + parts: list[str] = [] 143 + 144 + # Posting pattern — fresh-observer characterization of last 10 posts. 145 + try: 146 + feed = await client.get_own_posts(limit=10) 147 + posts: list[str] = [] 148 + latest_uri = "" 149 + for item in feed: 150 + if hasattr(item.post.record, "text"): 151 + posts.append(item.post.record.text) 152 + if not latest_uri: 153 + latest_uri = item.post.uri 154 + 155 + summary_stale = now - _summary_cache["fetched_at"] > _SUMMARY_TTL_SECONDS 156 + summary_invalid = latest_uri != _summary_cache["based_on_uri"] 157 + if not _summary_cache["text"] or summary_stale or summary_invalid: 158 + new_summary = await _summarize_posts(posts) 159 + if new_summary: 160 + _summary_cache["text"] = new_summary 161 + _summary_cache["fetched_at"] = now 162 + _summary_cache["based_on_uri"] = latest_uri 163 + 164 + if _summary_cache["text"]: 165 + parts.append( 166 + "[POSTING PATTERN — fresh observer, last 10 posts]\n" 167 + f"{_summary_cache['text']}" 168 + ) 169 + except Exception as e: 170 + logger.debug(f"posting-pattern compose failed: {e}") 171 + 172 + # Last follow + queue depth (canonical PDS state) 173 + follow_age = await _last_follow_when(client) 174 + queue_n = await _queue_depth(client) 175 + 176 + misc: list[str] = [] 177 + if follow_age: 178 + misc.append(f"last follow: {follow_age}") 179 + if queue_n > 0: 180 + misc.append(f"exploration queue: {queue_n} pending") 181 + if misc: 182 + parts.append(" | ".join(misc)) 183 + 184 + block = "[SELF STATE]\n" + "\n\n".join(parts) if parts else "" 185 + _block_cache["text"] = block 186 + _block_cache["fetched_at"] = now 187 + return block