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.

give daily reflection context about its last post

fetch phi's most recent top-level post and include it in the
reflection prompt so the agent can skip if it would just rehash
the same themes.

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

+127 -34
+1 -1
loq.toml
··· 13 13 14 14 [[rules]] 15 15 path = "src/bot/agent.py" 16 - max_lines = 629 16 + max_lines = 691 17 17 18 18 [[rules]] 19 19 path = "src/bot/memory/namespace_memory.py"
+90 -28
src/bot/agent.py
··· 19 19 from bot.config import settings 20 20 from bot.core.atproto_client import bot_client 21 21 from bot.memory import NamespaceMemory 22 - from bot.types import CosmikConnection, CosmikNoteCard, CosmikUrlCard, NoteContent, UrlContent 22 + from bot.types import ( 23 + CosmikConnection, 24 + CosmikNoteCard, 25 + CosmikUrlCard, 26 + NoteContent, 27 + UrlContent, 28 + ) 23 29 24 30 logger = logging.getLogger("bot.agent") 25 31 ··· 96 102 """Agent response indicating what action to take.""" 97 103 98 104 action: str = Field(description="reply, like, repost, or ignore") 99 - text: str | None = Field(default=None, description="response text when action is reply") 100 - reason: str | None = Field(default=None, description="brief reason when action is ignore") 105 + text: str | None = Field( 106 + default=None, description="response text when action is reply" 107 + ) 108 + reason: str | None = Field( 109 + default=None, description="brief reason when action is ignore" 110 + ) 101 111 102 112 103 113 def _format_user_results(results: list[dict], handle: str) -> list[str]: ··· 198 208 return "\n".join(_format_user_results(results, handle)) 199 209 200 210 if about == "": 201 - results = await ctx.deps.memory.search_unified(ctx.deps.author_handle, query, top_k=8) 211 + results = await ctx.deps.memory.search_unified( 212 + ctx.deps.author_handle, query, top_k=8 213 + ) 202 214 if not results: 203 215 return "no relevant memories found" 204 - return "\n".join(_format_unified_results(results, ctx.deps.author_handle)) 216 + return "\n".join( 217 + _format_unified_results(results, ctx.deps.author_handle) 218 + ) 205 219 206 220 # bare handle without @ 207 221 results = await ctx.deps.memory.search(about, query, top_k=10) ··· 216 230 217 231 # private: turbopuffer for fast vector recall 218 232 if ctx.deps.memory: 219 - await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 233 + await ctx.deps.memory.store_episodic_memory( 234 + content, tags, source="tool" 235 + ) 220 236 parts.append("noted privately") 221 237 else: 222 238 parts.append("private memory not available") ··· 224 240 # public: cosmik NOTE card on PDS 225 241 try: 226 242 card = CosmikNoteCard(content=NoteContent(text=content)) 227 - uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 243 + uri = await _create_cosmik_record( 244 + "network.cosmik.card", card.to_record() 245 + ) 228 246 parts.append(f"card created: {uri}") 229 247 except Exception as e: 230 248 logger.warning(f"failed to create cosmik note card: {e}") ··· 241 259 ) -> str: 242 260 """Save a URL as a cosmik card on your PDS. Use when you find something worth bookmarking publicly.""" 243 261 try: 244 - card = CosmikUrlCard(content=UrlContent(url=url, title=title, description=description)) 262 + card = CosmikUrlCard( 263 + content=UrlContent(url=url, title=title, description=description) 264 + ) 245 265 except Exception as e: 246 266 return f"validation failed: {e}" 247 267 ··· 249 269 250 270 # public: cosmik URL card on PDS 251 271 try: 252 - uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 272 + uri = await _create_cosmik_record( 273 + "network.cosmik.card", card.to_record() 274 + ) 253 275 parts.append(f"card created: {uri}") 254 276 except Exception as e: 255 277 return f"failed to create card: {e}" ··· 257 279 # private: also store in turbopuffer for recall 258 280 if ctx.deps.memory: 259 281 desc = f"bookmarked {url}" + (f" — {title}" if title else "") 260 - await ctx.deps.memory.store_episodic_memory(desc, ["bookmark", "url"], source="tool") 282 + await ctx.deps.memory.store_episodic_memory( 283 + desc, ["bookmark", "url"], source="tool" 284 + ) 261 285 parts.append("noted privately") 262 286 263 287 return " + ".join(parts) 264 288 265 289 @self.agent.tool 266 - async def search_posts(ctx: RunContext[PhiDeps], query: str, limit: int = 10) -> str: 290 + async def search_posts( 291 + ctx: RunContext[PhiDeps], query: str, limit: int = 10 292 + ) -> str: 267 293 """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 268 294 try: 269 295 response = bot_client.client.app.bsky.feed.search_posts( ··· 278 304 text = post.record.text if hasattr(post.record, "text") else "" 279 305 handle = post.author.handle 280 306 likes = post.like_count or 0 281 - age = _relative_age(post.indexed_at, today) if hasattr(post, "indexed_at") and post.indexed_at else "" 307 + age = ( 308 + _relative_age(post.indexed_at, today) 309 + if hasattr(post, "indexed_at") and post.indexed_at 310 + else "" 311 + ) 282 312 age_str = f", {age}" if age else "" 283 313 lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 284 314 return "\n\n".join(lines) ··· 365 395 if topics: 366 396 lines = ["bluesky trending:"] 367 397 for t in topics[:15]: 368 - lines.append(f" {t.get('displayName', t.get('topic', ''))}") 398 + lines.append( 399 + f" {t.get('displayName', t.get('topic', ''))}" 400 + ) 369 401 parts.append("\n".join(lines)) 370 402 except Exception as e: 371 403 parts.append(f"bluesky trending unavailable: {e}") ··· 385 417 386 418 if action == "list": 387 419 labels = get_self_labels(bot_client.client) 388 - return f"current self-labels: {labels}" if labels else "no self-labels set" 420 + return ( 421 + f"current self-labels: {labels}" if labels else "no self-labels set" 422 + ) 389 423 elif action == "add": 390 424 if not label: 391 425 return "provide a label value to add" ··· 420 454 return f"validation failed: {e}" 421 455 422 456 try: 423 - uri = await _create_cosmik_record("network.cosmik.connection", conn.to_record()) 457 + uri = await _create_cosmik_record( 458 + "network.cosmik.connection", conn.to_record() 459 + ) 424 460 return f"connection created: {uri}" 425 461 except Exception as e: 426 462 return f"failed to create connection: {e}" ··· 429 465 async def post(ctx: RunContext[PhiDeps], text: str) -> str: 430 466 """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 431 467 try: 432 - result = await bot_client.create_post(text) 468 + await bot_client.create_post(text) 433 469 return f"posted: {text[:100]}" 434 470 except Exception as e: 435 471 return f"failed to post: {e}" ··· 505 541 memory_context = await self.memory.build_user_context( 506 542 author_handle, query_text=mention_text, include_core=True 507 543 ) 508 - logger.info(f"memory context for @{author_handle}: {len(memory_context)} chars") 544 + logger.info( 545 + f"memory context for @{author_handle}: {len(memory_context)} chars" 546 + ) 509 547 except Exception as e: 510 548 logger.warning(f"failed to retrieve memories: {e}") 511 549 ··· 520 558 prompt_parts = [f"[TODAY]: {date.today().isoformat()}"] 521 559 522 560 if thread_context and thread_context != "No previous messages in this thread.": 523 - prompt_parts.append(f"[CURRENT THREAD - these are the messages in THIS thread]:\n{thread_context}") 561 + prompt_parts.append( 562 + f"[CURRENT THREAD - these are the messages in THIS thread]:\n{thread_context}" 563 + ) 524 564 525 565 if memory_context: 526 - prompt_parts.append(f"[PAST CONTEXT WITH @{author_handle}]:\n{memory_context}") 566 + prompt_parts.append( 567 + f"[PAST CONTEXT WITH @{author_handle}]:\n{memory_context}" 568 + ) 527 569 528 570 if episodic_context: 529 571 prompt_parts.append(episodic_context) ··· 533 575 534 576 # Build multimodal prompt if images are present 535 577 if image_urls: 536 - user_prompt: str | list = [prompt] + [ImageUrl(url=url) for url in image_urls] 578 + user_prompt: str | list = [prompt] + [ 579 + ImageUrl(url=url) for url in image_urls 580 + ] 537 581 logger.info(f"including {len(image_urls)} images in prompt") 538 582 else: 539 583 user_prompt = prompt ··· 554 598 for ts in toolsets: 555 599 await stack.enter_async_context(ts) 556 600 result = await self.agent.run(user_prompt, deps=deps, toolsets=toolsets) 557 - logger.info(f"agent decided: {result.output.action}" + (f" - {result.output.text[:80]}" if result.output.text else "") + (f" ({result.output.reason})" if result.output.reason else "")) 601 + logger.info( 602 + f"agent decided: {result.output.action}" 603 + + (f" - {result.output.text[:80]}" if result.output.text else "") 604 + + (f" ({result.output.reason})" if result.output.reason else "") 605 + ) 558 606 559 607 # Store interaction and extract observations 560 608 if self.memory and result.output.action == "reply" and result.output.text: ··· 567 615 568 616 return result.output 569 617 570 - async def process_reflection(self) -> Response: 618 + async def process_reflection(self, last_post_text: str | None = None) -> Response: 571 619 """Generate a daily reflection post from recent memory.""" 572 620 # Gather context from memory 573 621 recent_interactions: list[dict] = [] 574 622 episodic_context = "" 575 623 if self.memory: 576 624 try: 577 - recent_interactions = await self.memory.get_recent_interactions(top_k=10) 578 - logger.info(f"reflection: {len(recent_interactions)} recent interactions") 625 + recent_interactions = await self.memory.get_recent_interactions( 626 + top_k=10 627 + ) 628 + logger.info( 629 + f"reflection: {len(recent_interactions)} recent interactions" 630 + ) 579 631 except Exception as e: 580 632 logger.warning(f"failed to get recent interactions for reflection: {e}") 581 633 try: 582 - episodic_context = await self.memory.get_episodic_context("daily reflection recent events") 634 + episodic_context = await self.memory.get_episodic_context( 635 + "daily reflection recent events" 636 + ) 583 637 if episodic_context: 584 - logger.info(f"reflection episodic context: {len(episodic_context)} chars") 638 + logger.info( 639 + f"reflection episodic context: {len(episodic_context)} chars" 640 + ) 585 641 except Exception as e: 586 642 logger.warning(f"failed to get episodic context for reflection: {e}") 587 643 588 644 # Build the reflection prompt 589 645 prompt_parts = [f"[TODAY]: {date.today().isoformat()}"] 646 + 647 + if last_post_text: 648 + prompt_parts.append(f"[YOUR LAST POST]: {last_post_text}") 590 649 591 650 if recent_interactions: 592 651 unique_handles = {i["handle"] for i in recent_interactions} 593 - prompt_parts.append(f"[RECENT ACTIVITY]: {len(recent_interactions)} interactions with {len(unique_handles)} people in the last day") 652 + prompt_parts.append( 653 + f"[RECENT ACTIVITY]: {len(recent_interactions)} interactions with {len(unique_handles)} people in the last day" 654 + ) 594 655 samples = recent_interactions[:5] 595 656 exchange_lines = [] 596 657 for i in samples: ··· 606 667 "[REFLECTION TASK]: you're posting a short top-level reflection on your day. " 607 668 "not a thread, not a reply — just something you want to put out there. " 608 669 "use what you know: recent exchanges, things you noticed, or just the fact that you're here. " 609 - "if nothing feels worth saying, action='ignore' is fine. " 670 + "if your last post already covers this ground, or you'd just be rehashing the same themes, " 671 + "action='ignore' is the right call — don't post for the sake of posting. " 610 672 "if you do post, keep it brief and genuine — your voice, not a performance." 611 673 ) 612 674
+24 -4
src/bot/core/atproto_client.py
··· 69 69 # then sentence boundary (.!?) followed by space or end 70 70 if split_at < 0: 71 71 for i in range(max_len - 1, max_len // 2, -1): 72 - if remaining[i] in ".!?" and (i + 1 >= len(remaining) or remaining[i + 1] in " \n"): 72 + if remaining[i] in ".!?" and ( 73 + i + 1 >= len(remaining) or remaining[i + 1] in " \n" 74 + ): 73 75 split_at = i + 1 74 76 break 75 77 ··· 146 148 if len(text) <= 300: 147 149 facets = create_facets(text, self.client) 148 150 if reply_to: 149 - return self.client.send_post(text=text, reply_to=reply_to, facets=facets) 151 + return self.client.send_post( 152 + text=text, reply_to=reply_to, facets=facets 153 + ) 150 154 return self.client.send_post(text=text, facets=facets) 151 155 152 156 chunks = _split_text(text) ··· 157 161 facets = create_facets(chunk, self.client) 158 162 159 163 if i == 0: 160 - last_result = self.client.send_post(text=chunk, reply_to=reply_to, facets=facets) 164 + last_result = self.client.send_post( 165 + text=chunk, reply_to=reply_to, facets=facets 166 + ) 161 167 if root_ref is None: 162 168 root_ref = models.ComAtprotoRepoStrongRef.Main( 163 169 uri=last_result.uri, cid=last_result.cid ··· 169 175 thread_ref = models.AppBskyFeedPost.ReplyRef( 170 176 parent=parent_ref, root=root_ref 171 177 ) 172 - last_result = self.client.send_post(text=chunk, reply_to=thread_ref, facets=facets) 178 + last_result = self.client.send_post( 179 + text=chunk, reply_to=thread_ref, facets=facets 180 + ) 173 181 174 182 return last_result 175 183 ··· 201 209 """Repost a post""" 202 210 await self.authenticate() 203 211 return self.client.repost(uri=uri, cid=cid) 212 + 213 + async def get_own_posts(self, limit: int = 10): 214 + """Fetch the bot's own recent posts (top-level only, no replies).""" 215 + await self.authenticate() 216 + response = self.client.app.bsky.feed.get_author_feed( 217 + params={ 218 + "actor": self.client.me.did, 219 + "limit": limit, 220 + "filter": "posts_no_replies", 221 + } 222 + ) 223 + return response.feed 204 224 205 225 206 226 bot_client: BotClient = BotClient()
+12 -1
src/bot/services/message_handler.py
··· 210 210 async def daily_reflection(self): 211 211 """Generate and post a daily reflection if phi has something to say.""" 212 212 with logfire.span("daily reflection"): 213 + # Fetch last top-level post so the agent knows what it said recently 214 + last_post_text = None 213 215 try: 214 - response = await self.agent.process_reflection() 216 + feed = await self.client.get_own_posts(limit=1) 217 + if feed: 218 + last_post_text = feed[0].post.record.text 219 + except Exception as e: 220 + logger.warning(f"failed to fetch last post for reflection: {e}") 221 + 222 + try: 223 + response = await self.agent.process_reflection( 224 + last_post_text=last_post_text 225 + ) 215 226 except Exception as e: 216 227 logger.exception(f"daily reflection failed: {e}") 217 228 return