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.

clean up malformed cosmik records, add per-page favicons, fix tests

- delete 9 orphaned NOTE cards + recreate URL card with correct semble format
- add thematically distinct SVG favicons (pulse for home, clock for status, graph for memory)
- update test_types to match new to_record() output ($type, createdAt, metadata nesting)
- add tests for CosmikCollection, CosmikCollectionLink, StrongRef, parentCard
- fix pre-existing ruff lint issues

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

+562 -88
+1 -1
loq.toml
··· 21 21 22 22 [[rules]] 23 23 path = "src/bot/main.py" 24 - max_lines = 679 24 + max_lines = 726
+87
scripts/fix_cosmik_records.py
··· 1 + """One-off: delete malformed cosmik cards and recreate the URL card correctly. 2 + 3 + All 10 existing cards were created before the semble lexicon fix: 4 + - 9 NOTE cards: missing $type, createdAt, parentCard (semble drops standalone NOTEs) 5 + - 1 URL card: missing $type discriminators, metadata at wrong nesting level 6 + 7 + Run: cd bot && uv run python scripts/fix_cosmik_records.py 8 + """ 9 + 10 + from datetime import UTC, datetime 11 + 12 + from atproto import Client 13 + 14 + from bot.config import settings 15 + 16 + NOTE_RKEYS = [ 17 + "3miimkmqzfe2h", 18 + "3miiee7sd722m", 19 + "3mig7idbqdm2q", 20 + "3mi3jtxvf362o", 21 + "3mi3jsqmenp2n", 22 + "3mi3jrmptnx22", 23 + "3mi3jqr2ejd2e", 24 + "3mi3iwqz6yh2y", 25 + "3mi3hxb66i72o", 26 + ] 27 + 28 + URL_RKEY = "3mhwa4hm47e2n" 29 + 30 + URL_CARD_RECORD = { 31 + "type": "URL", 32 + "content": { 33 + "$type": "network.cosmik.card#urlContent", 34 + "url": "https://atproto.brussels/atproto-architecture", 35 + "metadata": { 36 + "$type": "network.cosmik.card#urlMetadata", 37 + "title": "ATProto Architecture — visual summary", 38 + "description": ( 39 + "a single-page visual map of the AT Protocol stack: PDS, AppView," 40 + " identity, records, lexicons. useful when you need to orient quickly" 41 + " or explain the system to someone new." 42 + ), 43 + }, 44 + }, 45 + "createdAt": datetime(2026, 3, 25, 12, 0, 0, tzinfo=UTC).isoformat(), 46 + } 47 + 48 + 49 + def main(): 50 + client = Client(base_url=settings.bluesky_service) 51 + client.login(settings.bluesky_handle, settings.bluesky_password) 52 + did = client.me.did 53 + print(f"authenticated as {did}") 54 + 55 + # delete orphaned NOTE cards 56 + for rkey in NOTE_RKEYS: 57 + try: 58 + client.com.atproto.repo.delete_record( 59 + {"repo": did, "collection": "network.cosmik.card", "rkey": rkey} 60 + ) 61 + print(f" deleted NOTE {rkey}") 62 + except Exception as e: 63 + print(f" failed to delete {rkey}: {e}") 64 + 65 + # delete old malformed URL card 66 + try: 67 + client.com.atproto.repo.delete_record( 68 + {"repo": did, "collection": "network.cosmik.card", "rkey": URL_RKEY} 69 + ) 70 + print(f" deleted URL {URL_RKEY}") 71 + except Exception as e: 72 + print(f" failed to delete URL card: {e}") 73 + 74 + # recreate URL card with correct format 75 + resp = client.com.atproto.repo.create_record( 76 + { 77 + "repo": did, 78 + "collection": "network.cosmik.card", 79 + "record": URL_CARD_RECORD, 80 + } 81 + ) 82 + print(f" created URL card: {resp.uri} (cid: {resp.cid})") 83 + print("done") 84 + 85 + 86 + if __name__ == "__main__": 87 + main()
+378 -70
src/bot/main.py
··· 6 6 from contextlib import asynccontextmanager 7 7 from datetime import datetime 8 8 9 + import httpx 9 10 import logfire 10 11 from fastapi import FastAPI, Request 11 12 from fastapi.responses import HTMLResponse, JSONResponse ··· 98 99 logger.warning(f"logfire fastapi instrumentation failed: {_e}") 99 100 100 101 101 - NAV_HTML = '<nav><a href="/">phi</a><a href="/status">status</a><a href="/memory">memory</a></nav>' 102 + PHI_DID = "did:plc:65sucjiel52gefhcdcypynsr" 103 + 104 + VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1">' 105 + 106 + # per-page favicons — thematically distinct inline SVGs 107 + _FAVICON_HOME = ( 108 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 109 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 110 + "%3Cpath d=%22M2 16 L8 16 L11 6 L16 26 L21 10 L24 16 L30 16%22" 111 + " fill=%22none%22 stroke=%2258a6ff%22 stroke-width=%222.5%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/%3E" 112 + '%3C/svg%3E">' 113 + ) 114 + _FAVICON_STATUS = ( 115 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 116 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 117 + "%3Ccircle cx=%2216%22 cy=%2216%22 r=%2212%22 fill=%22none%22 stroke=%222ea043%22 stroke-width=%222%22/%3E" 118 + "%3Cline x1=%2216%22 y1=%2216%22 x2=%2216%22 y2=%228%22 stroke=%222ea043%22 stroke-width=%222.5%22 stroke-linecap=%22round%22/%3E" 119 + "%3Cline x1=%2216%22 y1=%2216%22 x2=%2222%22 y2=%2218%22 stroke=%222ea043%22 stroke-width=%222%22 stroke-linecap=%22round%22/%3E" 120 + "%3Ccircle cx=%2216%22 cy=%2216%22 r=%222%22 fill=%222ea043%22/%3E" 121 + '%3C/svg%3E">' 122 + ) 123 + _FAVICON_MEMORY = ( 124 + '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 125 + "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 126 + "%3Cline x1=%228%22 y1=%2210%22 x2=%2220%22 y2=%227%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 127 + "%3Cline x1=%228%22 y1=%2210%22 x2=%2214%22 y2=%2224%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 128 + "%3Cline x1=%2220%22 y1=%227%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 129 + "%3Cline x1=%2214%22 y1=%2224%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 130 + "%3Ccircle cx=%228%22 cy=%2210%22 r=%223.5%22 fill=%22a371f7%22/%3E" 131 + "%3Ccircle cx=%2220%22 cy=%227%22 r=%223%22 fill=%2258a6ff%22/%3E" 132 + "%3Ccircle cx=%2226%22 cy=%2220%22 r=%222.5%22 fill=%222ea043%22/%3E" 133 + "%3Ccircle cx=%2214%22 cy=%2224%22 r=%223%22 fill=%228b949e%22/%3E" 134 + '%3C/svg%3E">' 135 + ) 136 + 137 + NAV_HTML = """<nav> 138 + <a href="/" class="nav-brand">phi</a> 139 + <div class="nav-links"> 140 + <a href="/status">status</a> 141 + <a href="/memory">memory</a> 142 + <a href="/docs">api</a> 143 + </div> 144 + </nav>""" 102 145 103 146 BASE_STYLE = """ 104 147 * { margin: 0; padding: 0; box-sizing: border-box; } 105 148 body { 106 149 font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 107 - background: #0d1117; color: #c9d1d9; 150 + background: #0d1117; color: #c9d1d9; font-size: 14px; 151 + -webkit-font-smoothing: antialiased; 108 152 } 109 153 nav { 110 - padding: 12px 20px; 154 + padding: 14px 20px; 111 155 border-bottom: 1px solid #30363d; 112 156 background: #0d1117; 157 + display: flex; align-items: center; justify-content: space-between; 113 158 } 114 - nav a { 115 - color: #8b949e; text-decoration: none; margin-right: 20px; 116 - font-size: 13px; letter-spacing: 0.5px; 159 + .nav-brand { 160 + color: #c9d1d9; text-decoration: none; 161 + font-size: 15px; font-weight: 500; letter-spacing: 0.5px; 117 162 } 118 - nav a:hover { color: #c9d1d9; } 119 - .container { max-width: 720px; margin: 0 auto; padding: 40px 20px; } 163 + .nav-links { display: flex; gap: 6px; } 164 + .nav-links a { 165 + color: #8b949e; text-decoration: none; 166 + font-size: 13px; letter-spacing: 0.3px; 167 + padding: 6px 12px; border-radius: 16px; 168 + transition: background 0.15s, color 0.15s; 169 + } 170 + .nav-links a:hover { color: #c9d1d9; background: #161b22; } 171 + .container { max-width: 640px; margin: 0 auto; padding: 32px 20px; } 120 172 a { color: #58a6ff; text-decoration: none; } 121 173 a:hover { text-decoration: underline; } 122 174 """ ··· 124 176 125 177 @app.get("/", response_class=HTMLResponse) 126 178 async def root(): 127 - """Landing page.""" 179 + """Landing page with activity feed.""" 128 180 status = "online" if bot_status.polling_active else "offline" 129 181 status_color = "#2ea043" if bot_status.polling_active else "#da3633" 130 182 return f"""<!DOCTYPE html> 131 - <html><head><title>phi</title><style>{BASE_STYLE} 132 - h1 {{ font-size: 24px; font-weight: 300; margin-bottom: 8px; }} 133 - .subtitle {{ color: #8b949e; font-size: 13px; margin-bottom: 32px; }} 183 + <html><head><title>phi</title>{VIEWPORT_META}{_FAVICON_HOME}<style>{BASE_STYLE} 184 + .header {{ margin-bottom: 28px; }} 185 + h1 {{ font-size: 28px; font-weight: 400; margin-bottom: 6px; }} 186 + .subtitle {{ color: #8b949e; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }} 134 187 .status-dot {{ 135 188 display: inline-block; width: 8px; height: 8px; 136 - border-radius: 50%; margin-right: 6px; 189 + border-radius: 50%; flex-shrink: 0; 137 190 }} 138 - .links {{ margin-top: 24px; }} 139 - .links a {{ 140 - display: inline-block; color: #8b949e; margin-right: 20px; 141 - font-size: 13px; padding: 6px 0; 191 + .desc {{ color: #8b949e; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }} 192 + .stats {{ 193 + display: flex; gap: 24px; margin-bottom: 32px; 194 + font-size: 13px; color: #8b949e; flex-wrap: wrap; 142 195 }} 143 - .links a:hover {{ color: #c9d1d9; text-decoration: none; }} 196 + .stat-val {{ color: #c9d1d9; font-size: 18px; display: block; margin-bottom: 2px; }} 197 + .feed-title {{ font-size: 15px; color: #8b949e; margin-bottom: 16px; font-weight: 400; }} 198 + .feed {{ display: flex; flex-direction: column; gap: 10px; }} 199 + .card {{ 200 + background: #161b22; border-radius: 8px; padding: 14px 16px; 201 + border-left: 3px solid #30363d; 202 + }} 203 + .card-post {{ border-left-color: #58a6ff; }} 204 + .card-note {{ border-left-color: #a371f7; }} 205 + .card-url {{ border-left-color: #2ea043; }} 206 + .card-type {{ 207 + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; 208 + margin-bottom: 6px; font-weight: 500; 209 + }} 210 + .type-post {{ color: #58a6ff; }} 211 + .type-note {{ color: #a371f7; }} 212 + .type-url {{ color: #2ea043; }} 213 + .card-text {{ font-size: 14px; line-height: 1.5; margin-bottom: 8px; word-break: break-word; }} 214 + .card-meta {{ font-size: 12px; color: #484f58; }} 215 + .card-meta a {{ color: #484f58; }} 216 + .card-meta a:hover {{ color: #8b949e; }} 217 + #feed-loading {{ color: #484f58; font-size: 13px; }} 144 218 </style></head> 145 219 <body> 146 220 {NAV_HTML} 147 221 <div class="container"> 148 - <h1>phi</h1> 149 - <div class="subtitle"> 150 - <span class="status-dot" style="background:{status_color}"></span>{status} 151 - &middot; <a href="https://bsky.app/profile/{settings.bluesky_handle}">@{settings.bluesky_handle}</a> 222 + <div class="header"> 223 + <h1>phi</h1> 224 + <div class="subtitle"> 225 + <span class="status-dot" style="background:{status_color}"></span> 226 + <span>{status}</span> 227 + <span>&middot;</span> 228 + <a href="https://bsky.app/profile/{settings.bluesky_handle}">@{settings.bluesky_handle}</a> 229 + </div> 152 230 </div> 153 - <p style="color:#8b949e;font-size:13px;line-height:1.6;max-width:480px"> 154 - bluesky bot with mcp tools and episodic memory. 231 + <p class="desc"> 232 + bluesky bot with episodic memory and mcp tools. 155 233 learns from conversations, remembers across sessions. 156 234 </p> 157 - <div class="links"> 158 - <a href="/status">status</a> 159 - <a href="/memory">memory graph</a> 160 - <a href="/health">health</a> 161 - <a href="/docs">api docs</a> 235 + <div class="stats"> 236 + <div><span class="stat-val">{bot_status.uptime_str}</span>uptime</div> 237 + <div><span class="stat-val">{bot_status.mentions_received}</span>mentions</div> 238 + <div><span class="stat-val">{bot_status.responses_sent}</span>responses</div> 239 + </div> 240 + <h2 class="feed-title">recent activity</h2> 241 + <div class="feed" id="feed"> 242 + <div id="feed-loading">loading...</div> 162 243 </div> 163 244 </div> 245 + <script> 246 + function timeAgo(iso) {{ 247 + const s = (Date.now() - new Date(iso).getTime()) / 1000; 248 + if (s < 60) return Math.floor(s) + 's ago'; 249 + if (s < 3600) return Math.floor(s / 60) + 'm ago'; 250 + if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 251 + return Math.floor(s / 86400) + 'd ago'; 252 + }} 253 + function truncate(s, n) {{ return s.length > n ? s.slice(0, n) + '...' : s; }} 254 + fetch('/api/activity') 255 + .then(r => r.json()) 256 + .then(items => {{ 257 + const el = document.getElementById('feed'); 258 + document.getElementById('feed-loading').remove(); 259 + if (!items.length) {{ el.textContent = 'no recent activity'; return; }} 260 + el.innerHTML = items.map(i => ` 261 + <div class="card card-${{i.type}}"> 262 + <div class="card-type type-${{i.type}}">${{i.type}}</div> 263 + <div class="card-text">${{truncate(i.text || '', 200)}}</div> 264 + <div class="card-meta"> 265 + ${{timeAgo(i.time)}} 266 + ${{i.url ? ` &middot; <a href="${{i.url}}" target="_blank" rel="noopener">view</a>` : ''}} 267 + </div> 268 + </div> 269 + `).join(''); 270 + }}) 271 + .catch(() => {{ 272 + document.getElementById('feed-loading').textContent = 'failed to load activity'; 273 + }}); 274 + </script> 164 275 </body></html>""" 165 276 166 277 ··· 224 335 return f"{int(delta / 3600)}h ago" 225 336 226 337 active = bot_status.polling_active 227 - indicator = f'<span style="color:{"#2ea043" if active else "#da3633"}">{"active" if active else "inactive"}</span>' 338 + status_text = "online" if active else "offline" 339 + status_color = "#2ea043" if active else "#da3633" 340 + error_color = "#da3633" if bot_status.errors > 0 else "#c9d1d9" 228 341 229 - rows = [ 230 - ("status", indicator), 231 - ("handle", f"@{settings.bluesky_handle}"), 232 - ("uptime", bot_status.uptime_str), 233 - ("mentions", str(bot_status.mentions_received)), 234 - ("responses", str(bot_status.responses_sent)), 235 - ("last mention", format_time_ago(bot_status.last_mention_time)), 236 - ("last response", format_time_ago(bot_status.last_response_time)), 237 - ("errors", str(bot_status.errors)), 342 + metrics = [ 343 + ("uptime", bot_status.uptime_str, "#58a6ff"), 344 + ("status", status_text, status_color), 345 + ("mentions", str(bot_status.mentions_received), "#c9d1d9"), 346 + ("responses", str(bot_status.responses_sent), "#c9d1d9"), 347 + ("last mention", format_time_ago(bot_status.last_mention_time), "#8b949e"), 348 + ("last response", format_time_ago(bot_status.last_response_time), "#8b949e"), 349 + ("errors", str(bot_status.errors), error_color), 350 + ("handle", f"@{settings.bluesky_handle}", "#58a6ff"), 238 351 ] 239 - metrics_html = "\n".join( 240 - f'<tr><td style="color:#8b949e;padding:6px 16px 6px 0">{k}</td><td>{v}</td></tr>' 241 - for k, v in rows 352 + cards_html = "\n".join( 353 + f'<div class="metric-card"><div class="metric-label">{label}</div>' 354 + f'<div class="metric-value" style="color:{color}">{value}</div></div>' 355 + for label, value, color in metrics 242 356 ) 243 357 244 358 return f"""<!DOCTYPE html> 245 - <html><head><title>phi &middot; status</title><style>{BASE_STYLE} 246 - h1 {{ font-size: 18px; font-weight: 400; margin-bottom: 24px; }} 247 - table {{ font-size: 13px; border-collapse: collapse; }} 359 + <html><head><title>phi &middot; status</title>{VIEWPORT_META}{_FAVICON_STATUS}<style>{BASE_STYLE} 360 + h1 {{ font-size: 22px; font-weight: 400; margin-bottom: 24px; }} 361 + .grid {{ 362 + display: grid; grid-template-columns: 1fr 1fr; gap: 10px; 363 + }} 364 + .metric-card {{ 365 + background: #161b22; border-radius: 8px; padding: 16px; 366 + border: 1px solid #21262d; 367 + }} 368 + .metric-label {{ font-size: 12px; color: #484f58; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }} 369 + .metric-value {{ font-size: 18px; font-weight: 400; }} 248 370 </style></head> 249 371 <body> 250 372 {NAV_HTML} 251 373 <div class="container"> 252 374 <h1>status</h1> 253 - <table>{metrics_html}</table> 375 + <div class="grid">{cards_html}</div> 254 376 </div> 255 377 </body></html>""" 256 378 257 379 380 + _TID_CHARSET = "234567abcdefghijklmnopqrstuvwxyz" 381 + 382 + 383 + def _tid_to_iso(tid: str) -> str: 384 + """Decode an AT Protocol TID (base32-sortstring) to ISO8601.""" 385 + try: 386 + n = 0 387 + for ch in tid: 388 + n = n * 32 + _TID_CHARSET.index(ch) 389 + # 64-bit TID: bit 63=0, bits 62..10=timestamp(us), bits 9..0=clockid 390 + us = (n >> 10) & ((1 << 53) - 1) 391 + dt = datetime.fromtimestamp(us / 1_000_000) 392 + return dt.isoformat() 393 + except (ValueError, OSError): 394 + return "" 395 + 396 + 397 + _activity_cache: dict[str, object] = {"data": None, "expires": 0.0} 398 + _ACTIVITY_CACHE_TTL = 60 # seconds 399 + 258 400 _graph_cache: dict[str, object] = {"data": None, "expires": 0.0} 259 401 _GRAPH_CACHE_TTL = 60 # seconds 260 402 261 403 404 + @app.get("/api/activity") 405 + async def activity_feed(): 406 + """Recent posts and cosmik cards, merged by time.""" 407 + now = time.monotonic() 408 + if _activity_cache["data"] is not None and now < _activity_cache["expires"]: 409 + return JSONResponse(_activity_cache["data"]) 410 + 411 + items: list[dict] = [] 412 + async with httpx.AsyncClient(timeout=10) as client: 413 + posts_coro = client.get( 414 + "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed", 415 + params={"actor": PHI_DID, "filter": "posts_no_replies", "limit": 10}, 416 + ) 417 + cards_coro = client.get( 418 + "https://bsky.social/xrpc/com.atproto.repo.listRecords", 419 + params={"repo": PHI_DID, "collection": "network.cosmik.card", "limit": 10}, 420 + ) 421 + posts_resp, cards_resp = await asyncio.gather( 422 + posts_coro, cards_coro, return_exceptions=True 423 + ) 424 + 425 + if isinstance(posts_resp, httpx.Response) and posts_resp.status_code == 200: 426 + for entry in posts_resp.json().get("feed", []): 427 + post = entry.get("post", {}) 428 + record = post.get("record", {}) 429 + uri = post.get("uri", "") 430 + # at://did/app.bsky.feed.post/rkey -> bsky.app link 431 + parts = uri.split("/") 432 + bsky_url = ( 433 + f"https://bsky.app/profile/{PHI_DID}/post/{parts[-1]}" 434 + if len(parts) >= 5 435 + else None 436 + ) 437 + items.append( 438 + { 439 + "type": "post", 440 + "text": record.get("text", ""), 441 + "time": record.get("createdAt", ""), 442 + "uri": uri, 443 + "url": bsky_url, 444 + } 445 + ) 446 + 447 + if isinstance(cards_resp, httpx.Response) and cards_resp.status_code == 200: 448 + for rec in cards_resp.json().get("records", []): 449 + value = rec.get("value", {}) 450 + card_type = value.get("type", "NOTE") 451 + item_type = "url" if card_type == "URL" else "note" 452 + content = value.get("content", {}) 453 + if item_type == "url": 454 + text = ( 455 + content.get("title", "") 456 + or content.get("description", "") 457 + or content.get("url", "") 458 + ) 459 + else: 460 + text = ( 461 + content.get("text", "") 462 + if isinstance(content, dict) 463 + else str(content) 464 + ) 465 + # derive time from TID rkey (base32-sortstring microseconds) 466 + rkey = rec.get("uri", "").rsplit("/", 1)[-1] 467 + card_time = _tid_to_iso(rkey) 468 + items.append( 469 + { 470 + "type": item_type, 471 + "text": text, 472 + "time": card_time, 473 + "uri": rec.get("uri", ""), 474 + "url": content.get("url") if item_type == "url" else None, 475 + } 476 + ) 477 + 478 + items.sort(key=lambda x: x.get("time", ""), reverse=True) 479 + _activity_cache["data"] = items 480 + _activity_cache["expires"] = now + _ACTIVITY_CACHE_TTL 481 + return JSONResponse(items) 482 + 483 + 262 484 @app.get("/api/memory/graph") 263 485 @limiter.limit("10/minute") 264 486 async def memory_graph_data(request: Request): ··· 285 507 async def memory_page(): 286 508 """Interactive memory graph visualization.""" 287 509 return f"""<!DOCTYPE html> 288 - <html><head><title>phi &middot; memory</title> 510 + <html><head><title>phi &middot; memory</title>{VIEWPORT_META}{_FAVICON_MEMORY} 289 511 <script src="https://d3js.org/d3.v7.min.js"></script> 290 512 <style>{BASE_STYLE} 291 513 body {{ overflow: hidden; }} 292 514 nav {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; }} 293 515 #graph {{ width: 100vw; height: 100vh; }} 294 516 .tooltip {{ 295 - position: absolute; padding: 6px 10px; 517 + position: absolute; padding: 8px 12px; 296 518 background: #161b22; border: 1px solid #30363d; 297 - border-radius: 4px; font-size: 12px; 519 + border-radius: 6px; font-size: 13px; 298 520 pointer-events: none; opacity: 0; 299 521 color: #c9d1d9; max-width: 280px; 300 522 }} 301 523 .legend {{ 302 524 position: fixed; bottom: 16px; left: 16px; 303 525 background: #161b22; border: 1px solid #30363d; 304 - border-radius: 4px; padding: 10px 14px; font-size: 11px; 526 + border-radius: 8px; padding: 14px 16px; font-size: 12px; 527 + max-width: 220px; 305 528 }} 306 - .legend-item {{ display: flex; align-items: center; margin: 3px 0; }} 529 + .legend-title {{ color: #8b949e; font-size: 11px; margin-bottom: 8px; line-height: 1.4; }} 530 + .legend-item {{ display: flex; align-items: center; margin: 5px 0; }} 307 531 .legend-dot {{ 308 532 width: 8px; height: 8px; border-radius: 50%; 309 - margin-right: 8px; flex-shrink: 0; 533 + margin-right: 10px; flex-shrink: 0; 310 534 }} 535 + .legend-label {{ color: #c9d1d9; }} 311 536 #loading {{ 312 537 position: fixed; top: 50%; left: 50%; 313 538 transform: translate(-50%, -50%); 314 - color: #8b949e; font-size: 13px; 539 + color: #8b949e; font-size: 14px; 315 540 }} 316 541 </style></head> 317 542 <body> ··· 320 545 <div id="graph"></div> 321 546 <div class="tooltip" id="tooltip"></div> 322 547 <div class="legend"> 323 - <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div>phi</div> 324 - <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div>user</div> 325 - <div class="legend-item"><div class="legend-dot" style="background:#8b949e"></div>tag</div> 326 - <div class="legend-item"><div class="legend-dot" style="background:#a371f7"></div>episodic</div> 548 + <div class="legend-title">nodes positioned by semantic similarity</div> 549 + <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div><span class="legend-label">phi (self)</span></div> 550 + <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div><span class="legend-label">identities phi knows</span></div> 551 + <div class="legend-item"><div class="legend-dot" style="background:#8b949e"></div><span class="legend-label">topics from conversations</span></div> 552 + <div class="legend-item"><div class="legend-dot" style="background:#a371f7"></div><span class="legend-label">memories &amp; experiences</span></div> 327 553 </div> 328 554 <script> 329 555 const colors = {{ phi: '#58a6ff', user: '#2ea043', tag: '#8b949e', episodic: '#a371f7' }}; 330 556 const radii = {{ phi: 14, user: 9, tag: 5, episodic: 7 }}; 331 557 558 + async function fetchAvatars(nodes) {{ 559 + // collect identity handles for phi + user nodes 560 + const identities = nodes 561 + .filter(d => d.type === 'phi' || d.type === 'user') 562 + .map(d => {{ 563 + const h = d.label.replace(/^@/, ''); 564 + return d.type === 'phi' ? '{settings.bluesky_handle}' : h; 565 + }}) 566 + .filter(h => h && !h.includes('example')); 567 + if (!identities.length) return {{}}; 568 + const params = identities.map(h => 'actors=' + encodeURIComponent(h)).join('&'); 569 + try {{ 570 + const res = await fetch('https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?' + params); 571 + if (!res.ok) return {{}}; 572 + const data = await res.json(); 573 + const map = {{}}; 574 + for (const p of data.profiles || []) {{ 575 + if (p.avatar) map[p.handle] = p.avatar; 576 + }} 577 + return map; 578 + }} catch {{ return {{}}; }} 579 + }} 580 + 332 581 fetch('/api/memory/graph') 333 582 .then(r => r.json()) 334 - .then(data => {{ 583 + .then(async data => {{ 335 584 document.getElementById('loading').remove(); 336 585 if (!data.nodes.length) return; 337 586 587 + const avatarMap = await fetchAvatars(data.nodes); 588 + // attach avatar URLs to nodes 589 + data.nodes.forEach(d => {{ 590 + if (d.type === 'phi') d.avatar = avatarMap['{settings.bluesky_handle}']; 591 + else if (d.type === 'user') d.avatar = avatarMap[d.label.replace(/^@/, '')]; 592 + }}); 593 + 338 594 const width = window.innerWidth; 339 595 const height = window.innerHeight; 596 + const pad = 60; 340 597 const tooltip = d3.select('#tooltip'); 341 598 599 + const sx = d => d.x != null ? pad + (d.x + 1) / 2 * (width - 2 * pad) : width / 2; 600 + const sy = d => d.y != null ? pad + (d.y + 1) / 2 * (height - 2 * pad) : height / 2; 601 + 602 + data.nodes.forEach(d => {{ 603 + d.sx = sx(d); 604 + d.sy = sy(d); 605 + d.x = d.sx; 606 + d.y = d.sy; 607 + }}); 608 + 342 609 const svg = d3.select('#graph') 343 610 .append('svg') 344 611 .attr('width', width) 345 612 .attr('height', height); 346 613 614 + const defs = svg.append('defs'); 347 615 const g = svg.append('g'); 616 + let currentZoom = d3.zoomIdentity; 617 + 618 + // create avatar patterns for nodes that have them 619 + data.nodes.filter(d => d.avatar).forEach((d, i) => {{ 620 + const r = radii[d.type]; 621 + const pid = 'avatar-' + i; 622 + d._patternId = pid; 623 + defs.append('pattern') 624 + .attr('id', pid) 625 + .attr('width', 1).attr('height', 1) 626 + .attr('patternContentUnits', 'objectBoundingBox') 627 + .append('image') 628 + .attr('href', d.avatar) 629 + .attr('width', 1).attr('height', 1) 630 + .attr('preserveAspectRatio', 'xMidYMid slice'); 631 + }}); 348 632 349 633 svg.call(d3.zoom() 350 634 .scaleExtent([0.2, 5]) 351 - .on('zoom', e => g.attr('transform', e.transform))); 635 + .on('zoom', e => {{ 636 + g.attr('transform', e.transform); 637 + currentZoom = e.transform; 638 + label.attr('font-size', d => {{ 639 + const base = d.type === 'phi' ? 13 : d.type === 'user' ? 10 : 9; 640 + return base / Math.max(currentZoom.k, 0.5); 641 + }}); 642 + label.style('display', d => {{ 643 + if (d.type === 'phi' || d.type === 'user') return 'block'; 644 + return currentZoom.k >= 1.2 ? 'block' : 'none'; 645 + }}); 646 + }})); 647 + 648 + const edgeOpacity = (source, target) => {{ 649 + const s = typeof source === 'object' ? source.type : ''; 650 + const t = typeof target === 'object' ? target.type : ''; 651 + if (s === 'phi' && t === 'user') return 0.7; 652 + if (s === 'user' && t === 'tag') return 0.2; 653 + if (s === 'tag' || t === 'tag') return 0.25; 654 + return 0.4; 655 + }}; 352 656 353 657 const simulation = d3.forceSimulation(data.nodes) 354 - .force('link', d3.forceLink(data.edges).id(d => d.id).distance(80)) 355 - .force('charge', d3.forceManyBody().strength(-200)) 356 - .force('center', d3.forceCenter(width / 2, height / 2)) 658 + .force('link', d3.forceLink(data.edges).id(d => d.id).distance(40)) 659 + .force('charge', d3.forceManyBody().strength(-80)) 660 + .force('x', d3.forceX(d => d.sx).strength(0.3)) 661 + .force('y', d3.forceY(d => d.sy).strength(0.3)) 357 662 .force('collision', d3.forceCollide().radius(d => radii[d.type] + 4)); 358 663 359 664 const link = g.append('g') ··· 369 674 .data(data.nodes) 370 675 .join('circle') 371 676 .attr('r', d => radii[d.type]) 372 - .attr('fill', d => colors[d.type]) 373 - .attr('stroke', '#0d1117') 374 - .attr('stroke-width', 1.5) 677 + .attr('fill', d => d._patternId ? `url(#${{d._patternId}})` : colors[d.type]) 678 + .attr('stroke', d => d._patternId ? colors[d.type] : '#0d1117') 679 + .attr('stroke-width', d => d._patternId ? 2 : 1.5) 375 680 .style('cursor', 'grab') 376 681 .call(d3.drag() 377 682 .on('start', (e, d) => {{ ··· 395 700 396 701 const label = g.append('g') 397 702 .selectAll('text') 398 - .data(data.nodes.filter(d => d.type === 'phi' || d.type === 'user')) 703 + .data(data.nodes.filter(d => d.type === 'phi' || d.type === 'user' || d.type === 'episodic')) 399 704 .join('text') 400 705 .text(d => d.label) 401 - .attr('font-size', d => d.type === 'phi' ? 13 : 10) 706 + .attr('font-size', d => d.type === 'phi' ? 13 : d.type === 'user' ? 10 : 9) 402 707 .attr('font-family', "'SF Mono', 'Cascadia Code', 'Fira Code', monospace") 403 - .attr('fill', '#8b949e') 708 + .attr('fill', d => d.type === 'episodic' ? '#a371f7' : '#8b949e') 709 + .attr('fill-opacity', d => d.type === 'episodic' ? 0.6 : 1) 404 710 .attr('text-anchor', 'middle') 405 - .attr('dy', d => radii[d.type] + 14); 711 + .attr('dy', d => radii[d.type] + 14) 712 + .style('display', d => d.type === 'episodic' ? 'none' : 'block'); 406 713 407 714 simulation.on('tick', () => {{ 408 715 link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) 409 - .attr('x2', d => d.target.x).attr('y2', d => d.target.y); 716 + .attr('x2', d => d.target.x).attr('y2', d => d.target.y) 717 + .attr('stroke-opacity', d => edgeOpacity(d.source, d.target)); 410 718 node.attr('cx', d => d.x).attr('cy', d => d.y); 411 719 label.attr('x', d => d.x).attr('y', d => d.y); 412 720 }});
+2 -2
src/bot/services/notification_poller.py
··· 2 2 3 3 import asyncio 4 4 import logging 5 - from datetime import datetime, timezone 5 + from datetime import UTC, datetime 6 6 7 7 from bot.config import settings 8 8 from bot.core.atproto_client import BotClient ··· 112 112 113 113 async def _maybe_daily_post(self): 114 114 """Post a daily reflection if it's past the target hour and we haven't posted today.""" 115 - now = datetime.now(timezone.utc) 115 + now = datetime.now(UTC) 116 116 if now.hour < settings.daily_reflection_hour: 117 117 return 118 118 if bot_status.paused:
+13 -7
tests/test_rate_limiting.py
··· 1 1 """Tests for rate limiting and SSRF protection.""" 2 2 3 3 import ipaddress 4 - import socket 5 - from unittest.mock import AsyncMock, Mock, patch 4 + from unittest.mock import AsyncMock, Mock 6 5 7 - import pytest 8 6 from limits import parse as parse_limit 9 7 from limits.storage import MemoryStorage 10 8 from limits.strategies import MovingWindowRateLimiter ··· 64 62 65 63 try: 66 64 # first call succeeds (hits the limiter, then dispatches) 67 - await message_handler.MessageHandler.handle_notification(handler, notification) 65 + await message_handler.MessageHandler.handle_notification( 66 + handler, notification 67 + ) 68 68 handler._handle_post.assert_called_once() 69 69 70 70 handler._handle_post.reset_mock() 71 71 72 72 # second call is rate limited — _handle_post not called 73 - await message_handler.MessageHandler.handle_notification(handler, notification) 73 + await message_handler.MessageHandler.handle_notification( 74 + handler, notification 75 + ) 74 76 handler._handle_post.assert_not_called() 75 77 finally: 76 78 message_handler._limiter = original_limiter ··· 84 86 private_ips = ["127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "::1"] 85 87 for ip_str in private_ips: 86 88 ip = ipaddress.ip_address(ip_str) 87 - assert ip.is_private or ip.is_loopback or ip.is_link_local, f"{ip_str} should be blocked" 89 + assert ip.is_private or ip.is_loopback or ip.is_link_local, ( 90 + f"{ip_str} should be blocked" 91 + ) 88 92 89 93 def test_public_ips_allowed(self): 90 94 public_ips = ["8.8.8.8", "1.1.1.1", "140.82.121.4"] 91 95 for ip_str in public_ips: 92 96 ip = ipaddress.ip_address(ip_str) 93 - assert not (ip.is_private or ip.is_loopback or ip.is_link_local), f"{ip_str} should be allowed" 97 + assert not (ip.is_private or ip.is_loopback or ip.is_link_local), ( 98 + f"{ip_str} should be allowed" 99 + )
+81 -8
tests/test_types.py
··· 4 4 from pydantic import ValidationError 5 5 6 6 from bot.types import ( 7 + CosmikCollection, 8 + CosmikCollectionLink, 7 9 CosmikConnection, 8 10 CosmikNoteCard, 9 11 CosmikUrlCard, 10 12 NoteContent, 13 + StrongRef, 11 14 UrlContent, 12 15 ) 13 - 14 16 15 17 # --- CosmikConnection --- 16 18 ··· 73 75 74 76 def test_note_card_to_record(): 75 77 card = CosmikNoteCard(content=NoteContent(text="a thought")) 76 - assert card.to_record() == {"type": "NOTE", "content": {"text": "a thought"}} 78 + record = card.to_record() 79 + assert record["type"] == "NOTE" 80 + assert record["content"]["$type"] == "network.cosmik.card#noteContent" 81 + assert record["content"]["text"] == "a thought" 82 + assert "createdAt" in record 83 + assert "parentCard" not in record 77 84 78 85 79 86 def test_note_card_rejects_empty(): ··· 92 99 93 100 def test_url_card_valid(): 94 101 card = CosmikUrlCard( 95 - content=UrlContent(url="https://example.com", title="Example", description="A site") 102 + content=UrlContent( 103 + url="https://example.com", title="Example", description="A site" 104 + ) 96 105 ) 97 106 assert card.type == "URL" 98 107 assert card.content.url == "https://example.com" ··· 102 111 card = CosmikUrlCard( 103 112 content=UrlContent(url="https://example.com", title="Ex", description="desc") 104 113 ) 105 - assert card.to_record() == { 106 - "type": "URL", 107 - "content": {"url": "https://example.com", "title": "Ex", "description": "desc"}, 108 - } 114 + record = card.to_record() 115 + assert record["type"] == "URL" 116 + assert record["content"]["$type"] == "network.cosmik.card#urlContent" 117 + assert record["content"]["url"] == "https://example.com" 118 + assert record["content"]["metadata"]["$type"] == "network.cosmik.card#urlMetadata" 119 + assert record["content"]["metadata"]["title"] == "Ex" 120 + assert record["content"]["metadata"]["description"] == "desc" 121 + assert "createdAt" in record 109 122 110 123 111 124 def test_url_card_to_record_minimal(): 112 125 card = CosmikUrlCard(content=UrlContent(url="https://example.com")) 113 - assert card.to_record() == {"type": "URL", "content": {"url": "https://example.com"}} 126 + record = card.to_record() 127 + assert record["type"] == "URL" 128 + assert record["content"]["$type"] == "network.cosmik.card#urlContent" 129 + assert record["content"]["url"] == "https://example.com" 130 + assert "metadata" not in record["content"] 131 + assert "createdAt" in record 114 132 115 133 116 134 def test_url_card_rejects_bare_string(): ··· 121 139 def test_url_card_accepts_at_uri(): 122 140 card = CosmikUrlCard(content=UrlContent(url="at://did:plc:abc/collection/rkey")) 123 141 assert card.content.url == "at://did:plc:abc/collection/rkey" 142 + 143 + 144 + # --- CosmikNoteCard with parentCard --- 145 + 146 + 147 + def test_note_card_with_parent(): 148 + parent = StrongRef(uri="at://did:plc:abc/network.cosmik.card/xyz", cid="bafyabc") 149 + card = CosmikNoteCard(content=NoteContent(text="child note"), parent_card=parent) 150 + record = card.to_record() 151 + assert record["parentCard"] == {"uri": parent.uri, "cid": parent.cid} 152 + 153 + 154 + # --- CosmikCollection --- 155 + 156 + 157 + def test_collection_to_record(): 158 + coll = CosmikCollection(name="epistemology", description="memory and knowledge") 159 + record = coll.to_record() 160 + assert record == { 161 + "name": "epistemology", 162 + "accessType": "OPEN", 163 + "description": "memory and knowledge", 164 + } 165 + 166 + 167 + def test_collection_minimal(): 168 + coll = CosmikCollection(name="misc") 169 + record = coll.to_record() 170 + assert record == {"name": "misc", "accessType": "OPEN"} 171 + 172 + 173 + def test_collection_rejects_long_name(): 174 + with pytest.raises(ValidationError): 175 + CosmikCollection(name="x" * 101) 176 + 177 + 178 + # --- CosmikCollectionLink --- 179 + 180 + 181 + def test_collection_link_to_record(): 182 + link = CosmikCollectionLink( 183 + collection=StrongRef( 184 + uri="at://did:plc:abc/network.cosmik.collection/c1", cid="bafycol" 185 + ), 186 + card=StrongRef(uri="at://did:plc:abc/network.cosmik.card/k1", cid="bafycard"), 187 + added_by="did:plc:abc", 188 + added_at="2026-04-01T00:00:00Z", 189 + ) 190 + record = link.to_record() 191 + assert ( 192 + record["collection"]["uri"] == "at://did:plc:abc/network.cosmik.collection/c1" 193 + ) 194 + assert record["card"]["cid"] == "bafycard" 195 + assert record["addedBy"] == "did:plc:abc" 196 + assert record["addedAt"] == "2026-04-01T00:00:00Z"