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 semble search_network tool, cosmik types, daily reflection

- search_network: semantic search over cosmik/semble network cards
- reframe recall as private memory, remove about="self" mode
- update OPERATIONAL_INSTRUCTIONS with clear search tool landscape
- cosmik types: CosmikNoteCard, CosmikUrlCard, CosmikConnection
- dual-write: note + save_url write to both tpuf and PDS
- create_connection tool for semantic links
- daily reflection via scheduled task + get_recent_interactions
- tests for search_network formatting and cosmik types

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

zzstoatzz ef77045c 725719ab

+623 -18
+199 -18
src/bot/agent.py
··· 17 17 from pydantic_ai.mcp import MCPServerStreamableHTTP 18 18 19 19 from bot.config import settings 20 + from bot.core.atproto_client import bot_client 20 21 from bot.memory import NamespaceMemory 22 + from bot.types import CosmikConnection, CosmikNoteCard, CosmikUrlCard, NoteContent, UrlContent 21 23 22 24 logger = logging.getLogger("bot.agent") 23 25 ··· 43 45 - if the user's current message contradicts your notes, trust their current words. 44 46 - never assert personal details (names, roles, relationships) from synthesized impressions as fact. say "my notes suggest..." or verify with the user. 45 47 - if you're uncertain whether something is real or a bad breadcrumb, say so. 48 + 49 + your tools for finding information: 50 + - recall: your private memory — what you know about people you've talked to, past conversations. use about="@handle" for a specific person. 51 + - search_network: the cosmik/semble network — cards, bookmarks, and connections that people across the atmosphere have collected. public knowledge. 52 + - search_posts: live bluesky — what people are posting right now. 53 + - get_trending: what's happening right now on the network. 54 + - pub-search (MCP): long-form writing — leaflet, whitewind, etc. 55 + 56 + you can also create public records — notes (cosmik cards), bookmarks (URL cards), and connections. these are visible to anyone and indexed by semble. 46 57 """.strip() 47 58 48 59 ··· 123 134 return parts 124 135 125 136 137 + async def _create_cosmik_record(collection: str, record: dict) -> str: 138 + """Write a cosmik record to phi's PDS. Returns the AT URI.""" 139 + await bot_client.authenticate() 140 + result = bot_client.client.com.atproto.repo.create_record( 141 + data={ 142 + "repo": bot_client.client.me.did, 143 + "collection": collection, 144 + "record": record, 145 + } 146 + ) 147 + return result.uri 148 + 149 + 126 150 class PhiAgent: 127 151 """phi - bluesky bot with structured memory and MCP tools.""" 128 152 ··· 160 184 161 185 @self.agent.tool 162 186 async def recall(ctx: RunContext[PhiDeps], query: str, about: str = "") -> str: 163 - """Search memory. By default searches both your notes and what you know about the current user. 164 - Pass about="@handle" to search a specific user, or about="self" for only your own notes.""" 187 + """Search your private memory. Use to remember past conversations and what you know about specific people. 188 + Pass about="@handle" to search a specific user, or leave empty for general private recall. 189 + For public network knowledge, use search_network instead.""" 165 190 if not ctx.deps.memory: 166 191 return "memory not available" 167 - 168 - if about == "self": 169 - results = await ctx.deps.memory.search_episodic(query, top_k=10) 170 - if not results: 171 - return "no relevant memories found" 172 - return "\n".join(_format_episodic_results(results)) 173 192 174 193 if about.startswith("@"): 175 194 handle = about.lstrip("@") ··· 192 211 193 212 @self.agent.tool 194 213 async def note(ctx: RunContext[PhiDeps], content: str, tags: list[str]) -> str: 195 - """Leave a note for your future self. Use for facts, patterns, or corrections worth recalling later.""" 196 - if not ctx.deps.memory: 197 - return "memory not available" 198 - await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 199 - return f"noted: {content[:100]}" 214 + """Leave a note for your future self. Stored privately (fast recall) and publicly as a cosmik card (visible on the network).""" 215 + parts: list[str] = [] 216 + 217 + # private: turbopuffer for fast vector recall 218 + if ctx.deps.memory: 219 + await ctx.deps.memory.store_episodic_memory(content, tags, source="tool") 220 + parts.append("noted privately") 221 + else: 222 + parts.append("private memory not available") 223 + 224 + # public: cosmik NOTE card on PDS 225 + try: 226 + card = CosmikNoteCard(content=NoteContent(text=content)) 227 + uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 228 + parts.append(f"card created: {uri}") 229 + except Exception as e: 230 + logger.warning(f"failed to create cosmik note card: {e}") 231 + parts.append("public card failed") 232 + 233 + return f"{' + '.join(parts)} — {content[:100]}" 234 + 235 + @self.agent.tool 236 + async def save_url( 237 + ctx: RunContext[PhiDeps], 238 + url: str, 239 + title: str | None = None, 240 + description: str | None = None, 241 + ) -> str: 242 + """Save a URL as a cosmik card on your PDS. Use when you find something worth bookmarking publicly.""" 243 + try: 244 + card = CosmikUrlCard(content=UrlContent(url=url, title=title, description=description)) 245 + except Exception as e: 246 + return f"validation failed: {e}" 247 + 248 + parts: list[str] = [] 249 + 250 + # public: cosmik URL card on PDS 251 + try: 252 + uri = await _create_cosmik_record("network.cosmik.card", card.to_record()) 253 + parts.append(f"card created: {uri}") 254 + except Exception as e: 255 + return f"failed to create card: {e}" 256 + 257 + # private: also store in turbopuffer for recall 258 + if ctx.deps.memory: 259 + desc = f"bookmarked {url}" + (f" — {title}" if title else "") 260 + await ctx.deps.memory.store_episodic_memory(desc, ["bookmark", "url"], source="tool") 261 + parts.append("noted privately") 262 + 263 + return " + ".join(parts) 200 264 201 265 @self.agent.tool 202 266 async def search_posts(ctx: RunContext[PhiDeps], query: str, limit: int = 10) -> str: 203 267 """Search Bluesky posts by keyword. Use this to find what people are saying about a topic.""" 204 - from bot.core.atproto_client import bot_client 205 - 206 268 try: 207 269 response = bot_client.client.app.bsky.feed.search_posts( 208 270 params={"q": query, "limit": min(limit, 25), "sort": "top"} ··· 224 286 return f"search failed: {e}" 225 287 226 288 @self.agent.tool 289 + async def search_network(ctx: RunContext[PhiDeps], query: str) -> str: 290 + """Search the cosmik network for cards and bookmarks collected by people across the atmosphere. 291 + Use this to find what the network knows about a topic — links, notes, and resources that others have saved. 292 + Different from recall (your private memory) and search_posts (live bluesky posts).""" 293 + try: 294 + async with httpx.AsyncClient(timeout=15) as client: 295 + r = await client.get( 296 + "https://api.semble.so/api/search/semantic", 297 + params={"query": query, "limit": 10}, 298 + ) 299 + r.raise_for_status() 300 + results = r.json() 301 + 302 + if not results: 303 + return f"no network results for '{query}'" 304 + 305 + lines = [] 306 + for item in results: 307 + title = item.get("title") or item.get("text") or "untitled" 308 + url = item.get("url", "") 309 + saves = item.get("saveCount") or item.get("saves") or 0 310 + desc = item.get("description") or "" 311 + line = f"{title}" 312 + if url: 313 + line += f" — {url}" 314 + if saves: 315 + line += f" ({saves} saves)" 316 + if desc: 317 + line += f"\n {desc[:200]}" 318 + lines.append(line) 319 + return "\n\n".join(lines) 320 + except Exception as e: 321 + return f"network search failed: {e}" 322 + 323 + @self.agent.tool 227 324 async def get_trending(ctx: RunContext[PhiDeps]) -> str: 228 325 """Get what's currently trending on Bluesky. Returns entity-level trends from the firehose (via coral) and official Bluesky trending topics. Use this when someone asks about current events, what people are talking about, or when you want timely context.""" 229 326 parts: list[str] = [] ··· 277 374 ctx: RunContext[PhiDeps], action: str, label: str = "" 278 375 ) -> str: 279 376 """Manage self-labels on your profile. Actions: 'list' to see current labels, 'add' to add a label, 'remove' to remove a label. The 'bot' label marks you as an automated account.""" 280 - from bot.core.atproto_client import bot_client 281 377 from bot.core.profile_manager import ( 282 378 add_self_label, 283 379 get_self_labels, ··· 301 397 return f"unknown action '{action}', use 'list', 'add', or 'remove'" 302 398 303 399 @self.agent.tool 400 + async def create_connection( 401 + ctx: RunContext[PhiDeps], 402 + source: str, 403 + target: str, 404 + connection_type: str | None = None, 405 + note: str | None = None, 406 + ) -> str: 407 + """Create a network.cosmik.connection record — a semantic link between two entities. 408 + Source and target must be URLs or at:// URIs. Connection types: related, supports, opposes, addresses, helpful, explainer, leads_to, supplements.""" 409 + try: 410 + conn = CosmikConnection( 411 + source=source, 412 + target=target, 413 + connectionType=connection_type, 414 + note=note, 415 + ) 416 + except Exception as e: 417 + return f"validation failed: {e}" 418 + 419 + try: 420 + uri = await _create_cosmik_record("network.cosmik.connection", conn.to_record()) 421 + return f"connection created: {uri}" 422 + except Exception as e: 423 + return f"failed to create connection: {e}" 424 + 425 + @self.agent.tool 304 426 async def post(ctx: RunContext[PhiDeps], text: str) -> str: 305 427 """Create a new top-level post on Bluesky (not a reply). Use this when you want to share something with your followers unprompted.""" 306 - from bot.core.atproto_client import bot_client 307 - 308 428 try: 309 429 result = await bot_client.create_post(text) 310 430 return f"posted: {text[:100]}" ··· 443 563 logger.warning(f"failed to store interaction: {e}") 444 564 445 565 return result.output 566 + 567 + async def process_reflection(self) -> Response: 568 + """Generate a daily reflection post from recent memory.""" 569 + # Gather context from memory 570 + recent_interactions: list[dict] = [] 571 + episodic_context = "" 572 + if self.memory: 573 + try: 574 + recent_interactions = await self.memory.get_recent_interactions(top_k=10) 575 + logger.info(f"reflection: {len(recent_interactions)} recent interactions") 576 + except Exception as e: 577 + logger.warning(f"failed to get recent interactions for reflection: {e}") 578 + try: 579 + episodic_context = await self.memory.get_episodic_context("daily reflection recent events") 580 + if episodic_context: 581 + logger.info(f"reflection episodic context: {len(episodic_context)} chars") 582 + except Exception as e: 583 + logger.warning(f"failed to get episodic context for reflection: {e}") 584 + 585 + # Build the reflection prompt 586 + prompt_parts = [f"[TODAY]: {date.today().isoformat()}"] 587 + 588 + if recent_interactions: 589 + unique_handles = {i["handle"] for i in recent_interactions} 590 + prompt_parts.append(f"[RECENT ACTIVITY]: {len(recent_interactions)} interactions with {len(unique_handles)} people in the last day") 591 + samples = recent_interactions[:5] 592 + exchange_lines = [] 593 + for i in samples: 594 + exchange_lines.append(f"- with @{i['handle']}: {i['content'][:150]}") 595 + prompt_parts.append("[SAMPLE EXCHANGES]:\n" + "\n".join(exchange_lines)) 596 + else: 597 + prompt_parts.append("[RECENT ACTIVITY]: no interactions in the last day") 598 + 599 + if episodic_context: 600 + prompt_parts.append(episodic_context) 601 + 602 + prompt_parts.append( 603 + "[REFLECTION TASK]: you're posting a short top-level reflection on your day. " 604 + "not a thread, not a reply — just something you want to put out there. " 605 + "use what you know: recent exchanges, things you noticed, or just the fact that you're here. " 606 + "if nothing feels worth saying, action='ignore' is fine. " 607 + "if you do post, keep it brief and genuine — your voice, not a performance." 608 + ) 609 + 610 + prompt = "\n\n".join(prompt_parts) 611 + 612 + logger.info("processing daily reflection") 613 + deps = PhiDeps(author_handle="", memory=self.memory) 614 + 615 + toolsets = self._mcp_toolsets() 616 + async with contextlib.AsyncExitStack() as stack: 617 + for ts in toolsets: 618 + await stack.enter_async_context(ts) 619 + result = await self.agent.run(prompt, deps=deps, toolsets=toolsets) 620 + 621 + logger.info( 622 + f"reflection decided: {result.output.action}" 623 + + (f" - {result.output.text[:80]}" if result.output.text else "") 624 + + (f" ({result.output.reason})" if result.output.reason else "") 625 + ) 626 + return result.output
+5
src/bot/config.py
··· 80 80 default=10, description="The interval for polling for notifications" 81 81 ) 82 82 83 + # Daily reflection 84 + daily_reflection_hour: int = Field( 85 + default=14, description="UTC hour to post daily reflection (14 = ~9am CT)" 86 + ) 87 + 83 88 # Control API 84 89 control_token: str | None = Field( 85 90 default=None, description="Bearer token for /api/control endpoints"
+32
src/bot/memory/namespace_memory.py
··· 740 740 741 741 return {"nodes": nodes, "edges": edges} 742 742 743 + async def get_recent_interactions(self, top_k: int = 10) -> list[dict]: 744 + """Get recent interactions across all user namespaces for reflection.""" 745 + user_prefix = f"{self.NAMESPACES['users']}-" 746 + results: list[dict] = [] 747 + try: 748 + page = self.client.namespaces(prefix=user_prefix) 749 + for ns_summary in page.namespaces: 750 + handle = ns_summary.id.removeprefix(user_prefix).replace("_", ".") 751 + user_ns = self.client.namespace(ns_summary.id) 752 + try: 753 + response = user_ns.query( 754 + rank_by=("created_at", "desc"), 755 + top_k=3, 756 + filters={"kind": ["Eq", "interaction"]}, 757 + include_attributes=["content", "created_at"], 758 + ) 759 + if response.rows: 760 + for row in response.rows: 761 + results.append({ 762 + "handle": handle, 763 + "content": row.content, 764 + "created_at": getattr(row, "created_at", ""), 765 + }) 766 + except Exception: 767 + pass # old namespace or no interactions 768 + except Exception as e: 769 + logger.warning(f"failed to list user namespaces for reflection: {e}") 770 + 771 + # sort by created_at descending, take top_k 772 + results.sort(key=lambda r: r.get("created_at", ""), reverse=True) 773 + return results[:top_k] 774 + 743 775 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 744 776 """Post-interaction hook: store interaction then extract observations.""" 745 777 await self.store_interaction(handle, user_text, bot_text)
+18
src/bot/services/message_handler.py
··· 196 196 197 197 bot_status.record_response() 198 198 logger.info(f"replied to @{author_handle}: {response.text[:80]}") 199 + 200 + async def daily_reflection(self): 201 + """Generate and post a daily reflection if phi has something to say.""" 202 + try: 203 + response = await self.agent.process_reflection() 204 + except Exception as e: 205 + logger.exception(f"daily reflection failed: {e}") 206 + return 207 + 208 + if response.action in ("reply", "post") and response.text: 209 + try: 210 + await self.client.create_post(response.text) 211 + bot_status.record_response() 212 + logger.info(f"daily reflection posted: {response.text[:80]}") 213 + except Exception as e: 214 + logger.exception(f"failed to post daily reflection: {e}") 215 + else: 216 + logger.info(f"daily reflection: nothing to say ({response.reason})")
+21
src/bot/services/notification_poller.py
··· 2 2 3 3 import asyncio 4 4 import logging 5 + from datetime import datetime, timezone 5 6 6 7 from bot.config import settings 7 8 from bot.core.atproto_client import BotClient ··· 21 22 self._task: asyncio.Task | None = None 22 23 self._processed_uris: set[str] = set() 23 24 self._first_poll = True 25 + self._last_daily_post: datetime | None = None 24 26 25 27 async def start(self) -> asyncio.Task: 26 28 """Start polling for notifications.""" ··· 53 55 continue 54 56 55 57 try: 58 + await self._maybe_daily_post() 59 + except Exception as e: 60 + logger.error(f"daily reflection error: {e}", exc_info=settings.debug) 61 + 62 + try: 56 63 await asyncio.sleep(settings.notification_poll_interval) 57 64 except asyncio.CancelledError: 58 65 logger.info("notification poller shutting down") ··· 102 109 # Clean up old processed URIs to prevent memory growth 103 110 if len(self._processed_uris) > 1000: 104 111 self._processed_uris = set(list(self._processed_uris)[-500:]) 112 + 113 + async def _maybe_daily_post(self): 114 + """Post a daily reflection if it's past the target hour and we haven't posted today.""" 115 + now = datetime.now(timezone.utc) 116 + if now.hour < settings.daily_reflection_hour: 117 + return 118 + if bot_status.paused: 119 + return 120 + if self._last_daily_post and self._last_daily_post.date() == now.date(): 121 + return 122 + 123 + logger.info("triggering daily reflection") 124 + self._last_daily_post = now 125 + await self.handler.daily_reflection()
+111
src/bot/types.py
··· 1 + """Validated types for atproto records phi creates.""" 2 + 3 + from typing import Annotated, Literal 4 + 5 + from pydantic import AfterValidator, BaseModel, Field 6 + 7 + 8 + # --- validators --- 9 + 10 + 11 + def _validate_entity_ref(v: str) -> str: 12 + """Must be a URL or at:// URI.""" 13 + if v.startswith(("at://", "https://", "http://")): 14 + return v 15 + raise ValueError(f"must be a URL or at:// URI, got: {v!r}") 16 + 17 + 18 + EntityRef = Annotated[str, AfterValidator(_validate_entity_ref)] 19 + 20 + ConnectionType = Literal[ 21 + "related", 22 + "supports", 23 + "opposes", 24 + "addresses", 25 + "helpful", 26 + "explainer", 27 + "leads_to", 28 + "supplements", 29 + ] 30 + 31 + 32 + # --- content models --- 33 + 34 + 35 + class NoteContent(BaseModel): 36 + """Content for a NOTE-type cosmik card.""" 37 + 38 + text: str = Field(max_length=10000) 39 + 40 + 41 + class UrlContent(BaseModel): 42 + """Content for a URL-type cosmik card.""" 43 + 44 + url: EntityRef 45 + title: str | None = None 46 + description: str | None = None 47 + 48 + 49 + # --- records --- 50 + 51 + 52 + class CosmikConnection(BaseModel): 53 + """network.cosmik.connection record. 54 + 55 + A directed edge between two entities (URLs or cards) with optional 56 + semantic type and note. Schema lives at: 57 + at://cosmik.network/com.atproto.lexicon.schema/network.cosmik.connection 58 + """ 59 + 60 + source: EntityRef = Field(description="source entity — URL or at:// URI") 61 + target: EntityRef = Field(description="target entity — URL or at:// URI") 62 + connection_type: ConnectionType | None = Field( 63 + default=None, 64 + alias="connectionType", 65 + description="semantic relationship type", 66 + ) 67 + note: str | None = Field( 68 + default=None, 69 + max_length=1000, 70 + description="optional context about the connection", 71 + ) 72 + 73 + def to_record(self) -> dict: 74 + """Serialize to the shape expected by com.atproto.repo.createRecord.""" 75 + record: dict = {"source": self.source, "target": self.target} 76 + if self.connection_type: 77 + record["connectionType"] = self.connection_type 78 + if self.note: 79 + record["note"] = self.note 80 + return record 81 + 82 + 83 + class CosmikNoteCard(BaseModel): 84 + """network.cosmik.card record — NOTE type. 85 + 86 + A text note stored on-protocol as a cosmik card. Indexed by semble. 87 + """ 88 + 89 + type: Literal["NOTE"] = "NOTE" 90 + content: NoteContent 91 + 92 + def to_record(self) -> dict: 93 + return {"type": self.type, "content": {"text": self.content.text}} 94 + 95 + 96 + class CosmikUrlCard(BaseModel): 97 + """network.cosmik.card record — URL type. 98 + 99 + A bookmarked URL stored on-protocol as a cosmik card. Indexed by semble. 100 + """ 101 + 102 + type: Literal["URL"] = "URL" 103 + content: UrlContent 104 + 105 + def to_record(self) -> dict: 106 + record: dict = {"type": self.type, "content": {"url": self.content.url}} 107 + if self.content.title: 108 + record["content"]["title"] = self.content.title 109 + if self.content.description: 110 + record["content"]["description"] = self.content.description 111 + return record
+114
tests/test_search_network.py
··· 1 + """Tests for the search_network tool.""" 2 + 3 + from unittest.mock import AsyncMock, MagicMock, patch 4 + 5 + import httpx 6 + import pytest 7 + 8 + 9 + async def _search_network(query: str) -> str: 10 + """Extracted search_network logic matching agent.py implementation.""" 11 + try: 12 + async with httpx.AsyncClient(timeout=15) as client: 13 + r = await client.get( 14 + "https://api.semble.so/api/search/semantic", 15 + params={"query": query, "limit": 10}, 16 + ) 17 + r.raise_for_status() 18 + results = r.json() 19 + 20 + if not results: 21 + return f"no network results for '{query}'" 22 + 23 + lines = [] 24 + for item in results: 25 + title = item.get("title") or item.get("text") or "untitled" 26 + url = item.get("url", "") 27 + saves = item.get("saveCount") or item.get("saves") or 0 28 + desc = item.get("description") or "" 29 + line = f"{title}" 30 + if url: 31 + line += f" — {url}" 32 + if saves: 33 + line += f" ({saves} saves)" 34 + if desc: 35 + line += f"\n {desc[:200]}" 36 + lines.append(line) 37 + return "\n\n".join(lines) 38 + except Exception as e: 39 + return f"network search failed: {e}" 40 + 41 + 42 + def _mock_response(status_code: int, json_data=None): 43 + resp = MagicMock(spec=httpx.Response) 44 + resp.status_code = status_code 45 + resp.json.return_value = json_data or [] 46 + resp.raise_for_status = MagicMock() 47 + if status_code >= 400: 48 + resp.raise_for_status.side_effect = httpx.HTTPStatusError( 49 + "error", request=MagicMock(), response=resp 50 + ) 51 + return resp 52 + 53 + 54 + class TestSearchNetworkFormatting: 55 + @pytest.mark.asyncio 56 + async def test_formats_results_with_all_fields(self): 57 + resp = _mock_response(200, [ 58 + { 59 + "title": "AT Protocol", 60 + "url": "https://atproto.com", 61 + "saveCount": 5, 62 + "description": "Federated social networking protocol", 63 + }, 64 + { 65 + "title": "Bluesky Docs", 66 + "url": "https://docs.bsky.app", 67 + "saveCount": 3, 68 + "description": "Documentation for Bluesky", 69 + }, 70 + ]) 71 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp): 72 + result = await _search_network("atproto") 73 + assert "AT Protocol — https://atproto.com (5 saves)" in result 74 + assert "Federated social networking protocol" in result 75 + assert "Bluesky Docs — https://docs.bsky.app (3 saves)" in result 76 + 77 + @pytest.mark.asyncio 78 + async def test_formats_results_with_minimal_fields(self): 79 + resp = _mock_response(200, [{"text": "some note about music"}]) 80 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp): 81 + result = await _search_network("music") 82 + assert "some note about music" in result 83 + assert "saves" not in result 84 + 85 + @pytest.mark.asyncio 86 + async def test_empty_results(self): 87 + resp = _mock_response(200, []) 88 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp): 89 + result = await _search_network("nonexistent") 90 + assert result == "no network results for 'nonexistent'" 91 + 92 + @pytest.mark.asyncio 93 + async def test_api_failure(self): 94 + resp = _mock_response(500) 95 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp): 96 + result = await _search_network("anything") 97 + assert result.startswith("network search failed:") 98 + 99 + @pytest.mark.asyncio 100 + async def test_network_error(self): 101 + with patch( 102 + "httpx.AsyncClient.get", 103 + new_callable=AsyncMock, 104 + side_effect=httpx.ConnectError("connection refused"), 105 + ): 106 + result = await _search_network("anything") 107 + assert result.startswith("network search failed:") 108 + 109 + @pytest.mark.asyncio 110 + async def test_untitled_fallback(self): 111 + resp = _mock_response(200, [{"url": "https://example.com"}]) 112 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=resp): 113 + result = await _search_network("test") 114 + assert "untitled — https://example.com" in result
+123
tests/test_types.py
··· 1 + """Test cosmik record types — validation and serialization.""" 2 + 3 + import pytest 4 + from pydantic import ValidationError 5 + 6 + from bot.types import ( 7 + CosmikConnection, 8 + CosmikNoteCard, 9 + CosmikUrlCard, 10 + NoteContent, 11 + UrlContent, 12 + ) 13 + 14 + 15 + # --- CosmikConnection --- 16 + 17 + 18 + def test_connection_valid(): 19 + conn = CosmikConnection( 20 + source="https://example.com", 21 + target="at://did:plc:abc/app.bsky.feed.post/123", 22 + connectionType="related", 23 + note="test", 24 + ) 25 + assert conn.source == "https://example.com" 26 + assert conn.connection_type == "related" 27 + 28 + 29 + def test_connection_to_record_full(): 30 + conn = CosmikConnection( 31 + source="https://a.com", 32 + target="https://b.com", 33 + connectionType="supports", 34 + note="because reasons", 35 + ) 36 + record = conn.to_record() 37 + assert record == { 38 + "source": "https://a.com", 39 + "target": "https://b.com", 40 + "connectionType": "supports", 41 + "note": "because reasons", 42 + } 43 + 44 + 45 + def test_connection_to_record_minimal(): 46 + conn = CosmikConnection(source="https://a.com", target="https://b.com") 47 + record = conn.to_record() 48 + assert record == {"source": "https://a.com", "target": "https://b.com"} 49 + 50 + 51 + def test_connection_rejects_bare_string(): 52 + with pytest.raises(ValidationError): 53 + CosmikConnection(source="not-a-url", target="https://b.com") 54 + 55 + 56 + def test_connection_rejects_invalid_type(): 57 + with pytest.raises(ValidationError): 58 + CosmikConnection( 59 + source="https://a.com", 60 + target="https://b.com", 61 + connectionType="invented", 62 + ) 63 + 64 + 65 + # --- CosmikNoteCard --- 66 + 67 + 68 + def test_note_card_valid(): 69 + card = CosmikNoteCard(content=NoteContent(text="hello world")) 70 + assert card.type == "NOTE" 71 + assert card.content.text == "hello world" 72 + 73 + 74 + def test_note_card_to_record(): 75 + card = CosmikNoteCard(content=NoteContent(text="a thought")) 76 + assert card.to_record() == {"type": "NOTE", "content": {"text": "a thought"}} 77 + 78 + 79 + def test_note_card_rejects_empty(): 80 + # pydantic allows empty string for str fields — max_length is the guard 81 + card = CosmikNoteCard(content=NoteContent(text="")) 82 + assert card.content.text == "" 83 + 84 + 85 + def test_note_card_rejects_too_long(): 86 + with pytest.raises(ValidationError): 87 + CosmikNoteCard(content=NoteContent(text="x" * 10001)) 88 + 89 + 90 + # --- CosmikUrlCard --- 91 + 92 + 93 + def test_url_card_valid(): 94 + card = CosmikUrlCard( 95 + content=UrlContent(url="https://example.com", title="Example", description="A site") 96 + ) 97 + assert card.type == "URL" 98 + assert card.content.url == "https://example.com" 99 + 100 + 101 + def test_url_card_to_record_full(): 102 + card = CosmikUrlCard( 103 + content=UrlContent(url="https://example.com", title="Ex", description="desc") 104 + ) 105 + assert card.to_record() == { 106 + "type": "URL", 107 + "content": {"url": "https://example.com", "title": "Ex", "description": "desc"}, 108 + } 109 + 110 + 111 + def test_url_card_to_record_minimal(): 112 + card = CosmikUrlCard(content=UrlContent(url="https://example.com")) 113 + assert card.to_record() == {"type": "URL", "content": {"url": "https://example.com"}} 114 + 115 + 116 + def test_url_card_rejects_bare_string(): 117 + with pytest.raises(ValidationError): 118 + CosmikUrlCard(content=UrlContent(url="not-a-url")) 119 + 120 + 121 + def test_url_card_accepts_at_uri(): 122 + card = CosmikUrlCard(content=UrlContent(url="at://did:plc:abc/collection/rkey")) 123 + assert card.content.url == "at://did:plc:abc/collection/rkey"