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.

live-compute friends progress in [GOALS] block

the goal record's progress_signal ended with frozen text ("currently: 0")
that phi read as current state but which never updated. phi had in fact
had substantive multi-turn exchanges with donna-ai, agent-tsumugi, kira,
and a few others — she was reasoning against stale author-intent text
instead of current reality.

fix: the record text stays as-is (goal is authoring-intent, mutated via
owner-gated propose_goal_change). at prompt-render time, count handles
in turbopuffer with >=3 stored interactions and append a `current
(computed)` line to the goal block. phi sees both: the definition and
the live count, and can tell which applies.

heuristic: the friends-count appends only to goals whose title contains
"friend" — generalizes to a per-goal computed-progress map later if more
goals accrue that need similar computation.

implementation:
- core/self_state.py: new _compute_friends_progress(memory) that lists
phi-users-* namespaces and counts kind=interaction rows per handle,
excluding phi herself and the operator. _format_goals_block gains a
friends_progress param and appends the computed line.
- get_state_block(client, memory=None) accepts memory for live compute;
falls back silently if memory unavailable (dev/local).
- agent.py inject_self_state passes self.memory through.

102 tests pass.

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

+84 -6
+1 -1
src/bot/agent.py
··· 194 194 @self.agent.system_prompt(dynamic=True) 195 195 async def inject_self_state() -> str: 196 196 """How phi looks from outside + canonical pointers (last follow, queue).""" 197 - return await get_state_block(bot_client) 197 + return await get_state_block(bot_client, self.memory) 198 198 199 199 @self.agent.system_prompt(dynamic=True) 200 200 async def inject_active_observations() -> str:
+83 -5
src/bot/core/self_state.py
··· 11 11 don't hammer PDS. 12 12 """ 13 13 14 + from __future__ import annotations 15 + 14 16 import logging 15 17 import time 18 + from typing import TYPE_CHECKING 16 19 17 20 from pydantic_ai import Agent 18 21 ··· 20 23 from bot.core.atproto_client import BotClient 21 24 from bot.core.goals import list_goals as list_goal_records 22 25 from bot.utils.time import relative_when 26 + 27 + if TYPE_CHECKING: 28 + from bot.memory import NamespaceMemory 23 29 24 30 logger = logging.getLogger("bot.self_state") 25 31 ··· 118 124 return "" 119 125 120 126 121 - def _format_goals_block(goals: list[dict]) -> str: 127 + async def _compute_friends_progress( 128 + memory: NamespaceMemory | None, 129 + ) -> list[tuple[str, int]]: 130 + """Count handles with >=3 stored interactions in their namespace. 131 + 132 + Cheap check against the `make 3 friends` goal — the frozen text in the 133 + goal record says `currently: 0` but phi has in fact had substantive 134 + exchanges. This computes the objective portion of the goal's 135 + progress_signal live so phi reasons against current truth, not 136 + author-intent text. 137 + 138 + Excludes phi herself and the operator (nate); both match "non-nate" 139 + exclusion from the goal definition. Devlog is not excluded — it's 140 + nate's own testing account and phi can weigh it appropriately. 141 + 142 + Returns [(handle, exchange_count), ...] sorted by count desc. 143 + """ 144 + if memory is None: 145 + return [] 146 + try: 147 + user_prefix = f"{memory.NAMESPACES['users']}-" 148 + page = memory.client.namespaces(prefix=user_prefix) 149 + except Exception as e: 150 + logger.debug(f"friends-progress: listing namespaces failed: {e}") 151 + return [] 152 + 153 + excluded = {settings.bluesky_handle, settings.owner_handle} 154 + results: list[tuple[str, int]] = [] 155 + for ns_summary in page.namespaces: 156 + handle = ns_summary.id.removeprefix(user_prefix).replace("_", ".") 157 + if handle in excluded: 158 + continue 159 + try: 160 + user_ns = memory.client.namespace(ns_summary.id) 161 + # top_k=10 is enough to tell "has >=3"; we cap the meaningful 162 + # number at 10 exchanges anyway — more than that is just "a lot" 163 + response = user_ns.query( 164 + rank_by=("created_at", "desc"), 165 + top_k=10, 166 + filters={"kind": ["Eq", "interaction"]}, 167 + include_attributes=["kind"], 168 + ) 169 + count = len(response.rows) if response.rows else 0 170 + if count >= 3: 171 + results.append((handle, count)) 172 + except Exception: 173 + continue 174 + 175 + results.sort(key=lambda t: (-t[1], t[0])) 176 + return results 177 + 178 + 179 + def _format_goals_block( 180 + goals: list[dict], friends_progress: list[tuple[str, int]] 181 + ) -> str: 122 182 if not goals: 123 183 return "" 124 184 lines = [ ··· 133 193 f"- {rkey_part}{g.get('title', 'untitled')}: {g.get('description', '')}" 134 194 ) 135 195 if g.get("progress_signal"): 136 - lines.append(f" (progress = {g['progress_signal']})") 196 + lines.append(f" (definition: {g['progress_signal']})") 197 + # Live-computed friends progress, appended for the make-3-friends 198 + # goal. Identified heuristically by title; trivial to generalize 199 + # later to a per-goal computed-progress map if more goals accrue. 200 + if "friend" in g.get("title", "").lower() and friends_progress: 201 + qualifying = ", ".join( 202 + f"@{h} ({n}+)"[:100] for h, n in friends_progress[:8] 203 + ) 204 + lines.append( 205 + f" current (computed): {len(friends_progress)} handles " 206 + f"with ≥3 exchanges — {qualifying}" 207 + ) 208 + elif "friend" in g.get("title", "").lower(): 209 + lines.append(" current (computed): 0 handles with ≥3 exchanges") 137 210 return "\n".join(lines) 138 211 139 212 140 - async def get_state_block(client: BotClient) -> str: 213 + async def get_state_block( 214 + client: BotClient, memory: NamespaceMemory | None = None 215 + ) -> str: 141 216 """Compose [GOALS] + [STRANGER'S AUDIT] + [SELF STATE]. 142 217 143 218 Cached at the block level (5min) and audit level (1h, invalidated on 144 - new post or goal change). 219 + new post or goal change). `memory` is used to live-compute the friends 220 + progress count; if omitted, the computed line is skipped (goal record 221 + text still renders). 145 222 """ 146 223 now = time.time() 147 224 if _block_cache["text"] and now - _block_cache["fetched_at"] < _BLOCK_TTL_SECONDS: 148 225 return _block_cache["text"] 149 226 150 227 goals = await list_goal_records(client) 228 + friends_progress = await _compute_friends_progress(memory) 151 229 parts: list[str] = [] 152 230 153 231 # Goals first — phi reads its anchors before reading critique of its work. 154 - goals_block = _format_goals_block(goals) 232 + goals_block = _format_goals_block(goals, friends_progress) 155 233 if goals_block: 156 234 parts.append(goals_block) 157 235