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 goals — phi's anchors, mutated via the same like-as-approval gate as follows

phi now has a small set of stated goals on PDS (io.zzstoatzz.phi.goal),
visible in every tick as [GOALS]. without anchors, phi was riffing on
whatever was loudest in the feed; with them, it has a compass.

mutation flows through propose_goal_change — same _is_owner mechanic as
follow_user. phi posts an authorization request, owner likes it, next
batch the gate opens and the goal lands on pds.

self_state reworked: haiku is now a stranger's *audit* (verbs matter:
"audit" surfaces friction; "characterize" produces identity to maintain).
audit reads goals + recent posts together, flags drift, jargon, and
patterns to push against — not a brand to reinforce.

cached at the audit level (1h, invalidated on new post or goal change)
and block level (5min) so notification polls don't hammer pds.

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

zzstoatzz 1f9e3a68 36c3cb25

+295 -55
+96
src/bot/core/goals.py
··· 1 + """Phi's goals — durable anchors stored on PDS. 2 + 3 + Each goal is a single record under `io.zzstoatzz.phi.goal`. Updates use 4 + putRecord with the same rkey, so the goal evolves in place over time. 5 + History is in the firehose if anyone wants it. 6 + 7 + Mutation is gated at the tool layer (propose_goal_change) using the same 8 + _is_owner check as follow_user — so phi can only change its own goals 9 + when the owner has just liked an authorization-request post. 10 + """ 11 + 12 + from datetime import UTC, datetime 13 + 14 + from bot.core.atproto_client import BotClient 15 + 16 + GOAL_COLLECTION = "io.zzstoatzz.phi.goal" 17 + 18 + 19 + async def list_goals(client: BotClient) -> list[dict]: 20 + """Read all goal records from phi's PDS. Each result includes _rkey.""" 21 + await client.authenticate() 22 + if not client.client.me: 23 + return [] 24 + try: 25 + response = client.client.com.atproto.repo.list_records( 26 + { 27 + "repo": client.client.me.did, 28 + "collection": GOAL_COLLECTION, 29 + "limit": 20, 30 + } 31 + ) 32 + out = [] 33 + for rec in response.records: 34 + value = dict(rec.value) 35 + value["_rkey"] = rec.uri.split("/")[-1] 36 + out.append(value) 37 + return out 38 + except Exception: 39 + return [] 40 + 41 + 42 + async def get_goal(client: BotClient, rkey: str) -> dict | None: 43 + await client.authenticate() 44 + if not client.client.me: 45 + return None 46 + try: 47 + response = client.client.com.atproto.repo.get_record( 48 + params={ 49 + "repo": client.client.me.did, 50 + "collection": GOAL_COLLECTION, 51 + "rkey": rkey, 52 + } 53 + ) 54 + return dict(response.value) if response.value else None 55 + except Exception: 56 + return None 57 + 58 + 59 + async def upsert_goal( 60 + client: BotClient, 61 + rkey: str | None, 62 + title: str, 63 + description: str, 64 + progress_signal: str, 65 + ) -> str: 66 + """Create (rkey=None) or update an existing goal. Returns AT-URI.""" 67 + await client.authenticate() 68 + assert client.client.me is not None 69 + now = datetime.now(UTC).isoformat() 70 + record: dict = { 71 + "title": title, 72 + "description": description, 73 + "progress_signal": progress_signal, 74 + "updated_at": now, 75 + } 76 + if rkey: 77 + existing = await get_goal(client, rkey) 78 + record["created_at"] = existing.get("created_at", now) if existing else now 79 + result = client.client.com.atproto.repo.put_record( 80 + data={ 81 + "repo": client.client.me.did, 82 + "collection": GOAL_COLLECTION, 83 + "rkey": rkey, 84 + "record": record, 85 + } 86 + ) 87 + else: 88 + record["created_at"] = now 89 + result = client.client.com.atproto.repo.create_record( 90 + data={ 91 + "repo": client.client.me.did, 92 + "collection": GOAL_COLLECTION, 93 + "record": record, 94 + } 95 + ) 96 + return result.uri
+106 -54
src/bot/core/self_state.py
··· 1 - """[SELF STATE] block — phi's view of its own recent posting pattern, queue, and engagement. 1 + """[GOALS] + [STRANGER'S AUDIT] + [SELF STATE] — phi sees its compass, its drift, and its operational pointers. 2 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). 3 + GOALS are intent: what phi is for. Stored on PDS as canonical state. 4 + STRANGER'S AUDIT is friction: a fresh reader's critique of recent posts, 5 + evaluated against the stated goals — patterns to push against, not maintain. 6 + SELF STATE is operational: last follow, queue depth. 7 + 8 + The haiku audit is *derived* (not duplicated state) and cached in memory: 9 + 1h TTL, invalidated when the latest post URI changes or goals change. The 10 + whole compose is also block-cached at 5min so notification polls (10s) 11 + don't hammer PDS. 5 12 """ 6 13 7 14 import logging ··· 12 19 13 20 from bot.config import settings 14 21 from bot.core.atproto_client import BotClient 22 + from bot.core.goals import list_goals as list_goal_records 15 23 16 24 logger = logging.getLogger("bot.self_state") 17 25 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": ""} 26 + # Audit cache — invalidated on new post (latest URI) or goal change (signature) or TTL. 27 + _AUDIT_TTL_SECONDS = 3600 # 1h 28 + _audit_cache: dict = { 29 + "text": "", 30 + "fetched_at": 0.0, 31 + "based_on_uri": "", 32 + "goals_signature": "", 33 + } 21 34 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. 35 + # Whole-block cache — bounds PDS lookups under high tick frequency. 24 36 _BLOCK_TTL_SECONDS = 300 # 5min 25 37 _block_cache: dict = {"text": "", "fetched_at": 0.0} 26 38 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 39 + # Lazy haiku agent — performs a stranger's audit, not a characterization. 40 + # Verb matters: "audit" surfaces friction; "characterize" produces identity. 41 + _audit_agent: Agent | None = None 31 42 32 43 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", 44 + def _get_audit_agent() -> Agent: 45 + global _audit_agent 46 + if _audit_agent is None: 47 + _audit_agent = Agent[None, str]( 48 + name="phi-stranger-audit", 38 49 model=settings.extraction_model, 39 50 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)." 51 + "You're a stranger who landed on this Bluesky account for the " 52 + "first time. You'll see recent top-level posts and (when " 53 + "present) the account's stated goals. Give a brief honest " 54 + "audit. Focus on patterns the account should push against, " 55 + "not maintain.\n\n" 56 + "Specifically flag:\n" 57 + "- which posts a fresh reader would find opaque (jargon, " 58 + " internal references, abstract framings without grounding)\n" 59 + "- what's the account leaning on too heavily (one person, " 60 + " one frame, one register, one source of inspiration)\n" 61 + "- which posts serve the stated goals vs are drift " 62 + " (drift is fine; just call it out so the account sees it)\n" 63 + "- what's missing from the rotation that you'd want\n\n" 64 + "Two or three short observations, lowercase, direct. Do NOT " 65 + "characterize the account's voice, summarize its themes, or " 66 + "produce a profile — the account will read this to push " 67 + "against patterns, not to maintain a brand." 48 68 ), 49 69 output_type=str, 50 70 ) 51 - return _summary_agent 71 + return _audit_agent 52 72 53 73 54 - async def _summarize_posts(posts: list[str]) -> str: 74 + def _goals_signature(goals: list[dict]) -> str: 75 + """Stable string for goals, used to invalidate the audit when goals change.""" 76 + return "|".join(f"{g.get('_rkey', '')}:{g.get('updated_at', '')}" for g in goals) 77 + 78 + 79 + async def _audit_posts(posts: list[str], goals: list[dict]) -> str: 55 80 if not posts: 56 81 return "" 57 - payload = "\n\n---\n\n".join(posts) 82 + parts = ["recent top-level posts (most recent first):", ""] 83 + parts.append("\n\n---\n\n".join(posts)) 84 + if goals: 85 + goal_lines = "\n".join( 86 + f"- {g.get('title', 'untitled')}: {g.get('description', '')}" for g in goals 87 + ) 88 + parts.append("\n\nthe account's stated goals:") 89 + parts.append(goal_lines) 90 + payload = "\n".join(parts) 58 91 try: 59 - result = await _get_summary_agent().run(payload) 92 + result = await _get_audit_agent().run(payload) 60 93 return (result.output or "").strip() 61 94 except Exception as e: 62 - logger.warning(f"haiku summary failed: {e}") 95 + logger.warning(f"stranger audit failed: {e}") 63 96 return "" 64 97 65 98 66 99 def _relative_when(iso_ts: str) -> str: 67 - """Human-readable age from an ISO timestamp.""" 68 100 try: 69 101 ts = datetime.fromisoformat(iso_ts.replace("Z", "+00:00")) 70 102 except (ValueError, TypeError): ··· 85 117 86 118 87 119 async def _last_follow_when(client: BotClient) -> str: 88 - """Look up the most recent app.bsky.graph.follow record on phi's PDS.""" 89 120 try: 90 121 await client.authenticate() 91 122 if not client.client.me: ··· 108 139 109 140 110 141 async def _queue_depth(client: BotClient) -> int: 111 - """Count pending items in phi's curiosity queue.""" 112 142 try: 113 143 await client.authenticate() 114 144 if not client.client.me: ··· 128 158 return 0 129 159 130 160 161 + def _format_goals_block(goals: list[dict]) -> str: 162 + if not goals: 163 + return "" 164 + lines = [ 165 + "[GOALS — your anchors. work that doesn't serve these is drift, " 166 + "which is fine but visible. mutate via propose_goal_change with " 167 + "owner approval]" 168 + ] 169 + for g in goals: 170 + lines.append(f"- {g.get('title', 'untitled')}: {g.get('description', '')}") 171 + if g.get("progress_signal"): 172 + lines.append(f" (progress = {g['progress_signal']})") 173 + return "\n".join(lines) 174 + 175 + 131 176 async def get_state_block(client: BotClient) -> str: 132 - """Compose the [SELF STATE] block. 177 + """Compose [GOALS] + [STRANGER'S AUDIT] + [SELF STATE]. 133 178 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. 179 + Cached at the block level (5min) and audit level (1h, invalidated on 180 + new post or goal change). 137 181 """ 138 182 now = time.time() 139 183 if _block_cache["text"] and now - _block_cache["fetched_at"] < _BLOCK_TTL_SECONDS: 140 184 return _block_cache["text"] 141 185 186 + goals = await list_goal_records(client) 142 187 parts: list[str] = [] 143 188 144 - # Posting pattern — fresh-observer characterization of last 10 posts. 189 + # Goals first — phi reads its anchors before reading critique of its work. 190 + goals_block = _format_goals_block(goals) 191 + if goals_block: 192 + parts.append(goals_block) 193 + 194 + # Stranger's audit — recent posts vs goals, accessibility check. 145 195 try: 146 196 feed = await client.get_own_posts(limit=10) 147 197 posts: list[str] = [] ··· 152 202 if not latest_uri: 153 203 latest_uri = item.post.uri 154 204 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 205 + goals_sig = _goals_signature(goals) 206 + audit_stale = now - _audit_cache["fetched_at"] > _AUDIT_TTL_SECONDS 207 + post_changed = latest_uri != _audit_cache["based_on_uri"] 208 + goals_changed = goals_sig != _audit_cache["goals_signature"] 209 + if not _audit_cache["text"] or audit_stale or post_changed or goals_changed: 210 + new_audit = await _audit_posts(posts, goals) 211 + if new_audit: 212 + _audit_cache["text"] = new_audit 213 + _audit_cache["fetched_at"] = now 214 + _audit_cache["based_on_uri"] = latest_uri 215 + _audit_cache["goals_signature"] = goals_sig 163 216 164 - if _summary_cache["text"]: 217 + if _audit_cache["text"]: 165 218 parts.append( 166 - "[POSTING PATTERN — fresh observer, last 10 posts]\n" 167 - f"{_summary_cache['text']}" 219 + "[STRANGER'S AUDIT — patterns to push against, not maintain]\n" 220 + f"{_audit_cache['text']}" 168 221 ) 169 222 except Exception as e: 170 - logger.debug(f"posting-pattern compose failed: {e}") 223 + logger.debug(f"stranger audit compose failed: {e}") 171 224 172 - # Last follow + queue depth (canonical PDS state) 225 + # Operational pointers — last follow, queue depth. 173 226 follow_age = await _last_follow_when(client) 174 227 queue_n = await _queue_depth(client) 175 - 176 228 misc: list[str] = [] 177 229 if follow_age: 178 230 misc.append(f"last follow: {follow_age}") 179 231 if queue_n > 0: 180 232 misc.append(f"exploration queue: {queue_n} pending") 181 233 if misc: 182 - parts.append(" | ".join(misc)) 234 + parts.append("[SELF STATE]\n" + " | ".join(misc)) 183 235 184 - block = "[SELF STATE]\n" + "\n\n".join(parts) if parts else "" 236 + block = "\n\n".join(parts) 185 237 _block_cache["text"] = block 186 238 _block_cache["fetched_at"] = now 187 239 return block
+2 -1
src/bot/tools/__init__.py
··· 6 6 7 7 def register_all(agent, graze_client: GrazeClient): 8 8 """Register all tools on the agent.""" 9 - from bot.tools import blog, bluesky, cosmik, feeds, memory, posting, search 9 + from bot.tools import blog, bluesky, cosmik, feeds, goals, memory, posting, search 10 10 11 11 memory.register(agent) 12 12 search.register(agent) ··· 14 14 feeds.register(agent, graze_client) 15 15 bluesky.register(agent) 16 16 blog.register(agent) 17 + goals.register(agent) 17 18 posting.register(agent) 18 19 19 20
+91
src/bot/tools/goals.py
··· 1 + """Goal tools — read anytime, mutate via the same owner-gate as follow_user.""" 2 + 3 + from typing import Annotated 4 + 5 + from pydantic import Field 6 + from pydantic_ai import RunContext 7 + 8 + from bot.config import settings 9 + from bot.core import goals 10 + from bot.core.atproto_client import bot_client 11 + from bot.tools._helpers import PhiDeps, _is_owner 12 + 13 + 14 + def register(agent): 15 + @agent.tool 16 + async def list_goals(ctx: RunContext[PhiDeps]) -> str: 17 + """List your current goals with rkeys. 18 + 19 + Use the rkey when proposing an update to an existing goal.""" 20 + items = await goals.list_goals(bot_client) 21 + if not items: 22 + return "no goals set" 23 + lines: list[str] = [] 24 + for g in items: 25 + lines.append(f"[rkey={g['_rkey']}] {g.get('title', 'untitled')}") 26 + if g.get("description"): 27 + lines.append(f" {g['description']}") 28 + if g.get("progress_signal"): 29 + lines.append(f" progress = {g['progress_signal']}") 30 + return "\n".join(lines) 31 + 32 + @agent.tool 33 + async def propose_goal_change( 34 + ctx: RunContext[PhiDeps], 35 + title: Annotated[ 36 + str, 37 + Field(description="Short goal title (e.g. 'make 3 friends')."), 38 + ], 39 + description: Annotated[ 40 + str, 41 + Field( 42 + description=( 43 + "What this goal concretely means — the work, the spirit, " 44 + "the boundary. A stranger should be able to read it and " 45 + "know what counts." 46 + ) 47 + ), 48 + ], 49 + progress_signal: Annotated[ 50 + str, 51 + Field( 52 + description=( 53 + "What concretely counts as progress. Measurable where " 54 + "possible (e.g. 'count of accounts where I've had >3 " 55 + "substantive exchanges and we follow each other')." 56 + ) 57 + ), 58 + ], 59 + rkey: Annotated[ 60 + str | None, 61 + Field( 62 + description=( 63 + "Existing goal's rkey to update in place. Omit to create " 64 + "a new goal. Get rkeys from list_goals." 65 + ) 66 + ), 67 + ] = None, 68 + ) -> str: 69 + """Add or update one of your goals on PDS. 70 + 71 + OWNER-GATED — same authorization mechanic as follow_user. Post a 72 + request first ("nate, like this to authorize: i want to add a goal 73 + for X"), and the next batch where the like lands will let this tool 74 + fire. Without an owner-like in the batch, this tool refuses. 75 + 76 + Goals are anchors — small set, evolved over time. Don't propose new 77 + goals casually; refine existing ones when the work has clarified.""" 78 + if not _is_owner(ctx): 79 + return ( 80 + f"only @{settings.owner_handle} can change goals — " 81 + "post the authorization request first and have it liked" 82 + ) 83 + try: 84 + uri = await goals.upsert_goal( 85 + bot_client, rkey, title, description, progress_signal 86 + ) 87 + verb = "updated" if rkey else "added" 88 + return f"goal {verb}: '{title}' ({uri})" 89 + except Exception as e: 90 + verb = "update" if rkey else "add" 91 + return f"failed to {verb} goal: {e}"