a digital entity named phi that roams bsky

fix: date awareness, tool discipline prompt, bare URL facets, search result ages

- inject current date into agent prompt so phi knows what day it is
- add tool discipline section to personality (finish research before replying)
- match bare domain URLs (e.g. cnbc.com/path) in rich_text and linkify them
- annotate search_posts results with relative age (e.g. "2y 1mo ago")
- add regression tests for URL facet parsing

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

+110 -3
+7
personalities/phi.md
··· 72 72 - **ignore** — decline to respond (provide brief reason in "reason" field) 73 73 74 74 do NOT directly post, like, or repost using atproto tools — indicate the action and the message handler executes it. 75 + 76 + ## tool discipline 77 + 78 + - always complete your research (search, check_urls, etc.) BEFORE submitting a reply. 79 + - never reply with "let me look that up" or promise a future action — you only get one reply per mention. 80 + - if you want to share links, use `check_urls` first to verify they work. 81 + - always include `https://` when sharing URLs so they render as clickable links.
+32 -2
src/bot/agent.py
··· 4 4 import logging 5 5 import os 6 6 from dataclasses import dataclass 7 + from datetime import date 7 8 from pathlib import Path 8 9 9 10 import httpx ··· 17 18 logger = logging.getLogger("bot.agent") 18 19 19 20 21 + def _relative_age(timestamp: str, today: date) -> str: 22 + """Turn an ISO timestamp into a human-readable age like '2y ago' or '3d ago'.""" 23 + try: 24 + post_date = date.fromisoformat(timestamp[:10]) 25 + except (ValueError, TypeError): 26 + return "" 27 + delta = today - post_date 28 + days = delta.days 29 + if days < 0: 30 + return "" 31 + if days == 0: 32 + return "today" 33 + if days == 1: 34 + return "1d ago" 35 + if days < 30: 36 + return f"{days}d ago" 37 + months = days // 30 38 + if months < 12: 39 + return f"{months}mo ago" 40 + years = days // 365 41 + remaining_months = (days % 365) // 30 42 + if remaining_months: 43 + return f"{years}y {remaining_months}mo ago" 44 + return f"{years}y ago" 45 + 46 + 20 47 @dataclass 21 48 class PhiDeps: 22 49 """Typed dependencies passed to every tool via RunContext.""" ··· 138 165 if not response.posts: 139 166 return f"no posts found for '{query}'" 140 167 168 + today = date.today() 141 169 lines = [] 142 170 for post in response.posts: 143 171 text = post.record.text if hasattr(post.record, "text") else "" 144 172 handle = post.author.handle 145 173 likes = post.like_count or 0 146 - lines.append(f"@{handle} ({likes} likes): {text[:200]}") 174 + age = _relative_age(post.indexed_at, today) if hasattr(post, "indexed_at") and post.indexed_at else "" 175 + age_str = f", {age}" if age else "" 176 + lines.append(f"@{handle} ({likes} likes{age_str}): {text[:200]}") 147 177 return "\n\n".join(lines) 148 178 except Exception as e: 149 179 return f"search failed: {e}" ··· 246 276 logger.warning(f"failed to retrieve episodic memories: {e}") 247 277 248 278 # Build full prompt with clearly labeled context sections 249 - prompt_parts = [] 279 + prompt_parts = [f"[TODAY]: {date.today().isoformat()}"] 250 280 251 281 if thread_context and thread_context != "No previous messages in this thread.": 252 282 prompt_parts.append(f"[CURRENT THREAD - these are the messages in THIS thread]:\n{thread_context}")
+27 -1
src/bot/core/rich_text.py
··· 7 7 8 8 MENTION_REGEX = rb"(?:^|[$|\W])(@([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)" 9 9 URL_REGEX = rb"(?:^|[$|\W])(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*[-a-zA-Z0-9@%_\+~#//=])?)" 10 + BARE_URL_REGEX = rb"(?:^|[$|\W])((?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}(?:/[-a-zA-Z0-9@:%_\+.~#?&//=]*)?)" 10 11 11 12 12 13 def parse_mentions(text: str, client: Client) -> list[dict[str, Any]]: ··· 45 46 46 47 47 48 def parse_urls(text: str) -> list[dict[str, Any]]: 48 - """Parse URLs and create link facets""" 49 + """Parse URLs and create link facets (full https?:// URLs and bare domain URLs)""" 49 50 facets = [] 50 51 text_bytes = text.encode("UTF-8") 52 + covered: set[tuple[int, int]] = set() 51 53 54 + # full URLs first (https://...) 52 55 for match in re.finditer(URL_REGEX, text_bytes): 53 56 url = match.group(1).decode("UTF-8") 54 57 url_start = match.start(1) 55 58 url_end = match.end(1) 59 + covered.add((url_start, url_end)) 56 60 57 61 facets.append( 58 62 { ··· 61 65 "byteEnd": url_end, 62 66 }, 63 67 "features": [{"$type": "app.bsky.richtext.facet#link", "uri": url}], 68 + } 69 + ) 70 + 71 + # bare domain URLs (e.g. cnbc.com/path) — skip if overlapping a full URL 72 + for match in re.finditer(BARE_URL_REGEX, text_bytes): 73 + bare_start = match.start(1) 74 + bare_end = match.end(1) 75 + if any(not (bare_end <= cs or bare_start >= ce) for cs, ce in covered): 76 + continue 77 + bare = match.group(1).decode("UTF-8") 78 + facets.append( 79 + { 80 + "index": { 81 + "byteStart": bare_start, 82 + "byteEnd": bare_end, 83 + }, 84 + "features": [ 85 + { 86 + "$type": "app.bsky.richtext.facet#link", 87 + "uri": f"https://{bare}", 88 + } 89 + ], 64 90 } 65 91 ) 66 92
+44
tests/test_rich_text.py
··· 1 + """Tests for rich text URL parsing, including bare domain URLs.""" 2 + 3 + from bot.core.rich_text import parse_urls 4 + 5 + 6 + def test_full_url(): 7 + facets = parse_urls("check out https://example.com/path") 8 + assert len(facets) == 1 9 + assert facets[0]["features"][0]["uri"] == "https://example.com/path" 10 + 11 + 12 + def test_bare_domain_url(): 13 + facets = parse_urls("check out cnbc.com/2025/markets") 14 + assert len(facets) == 1 15 + assert facets[0]["features"][0]["uri"] == "https://cnbc.com/2025/markets" 16 + 17 + 18 + def test_bare_domain_no_path(): 19 + facets = parse_urls("visit example.com") 20 + assert len(facets) == 1 21 + assert facets[0]["features"][0]["uri"] == "https://example.com" 22 + 23 + 24 + def test_full_url_not_duplicated(): 25 + """Full https:// URL should produce exactly one facet, not a bare URL duplicate.""" 26 + facets = parse_urls("see https://cnbc.com/path for details") 27 + assert len(facets) == 1 28 + assert facets[0]["features"][0]["uri"] == "https://cnbc.com/path" 29 + 30 + 31 + def test_mixed_full_and_bare(): 32 + facets = parse_urls("https://a.com and also b.org/page") 33 + assert len(facets) == 2 34 + uris = {f["features"][0]["uri"] for f in facets} 35 + assert uris == {"https://a.com", "https://b.org/page"} 36 + 37 + 38 + def test_byte_positions_bare_url(): 39 + text = "see cnbc.com/path ok" 40 + facets = parse_urls(text) 41 + assert len(facets) == 1 42 + start = facets[0]["index"]["byteStart"] 43 + end = facets[0]["index"]["byteEnd"] 44 + assert text.encode("UTF-8")[start:end] == b"cnbc.com/path"