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.

rewrite phi.zzstoatzz.io frontend in svelte 5 + sveltekit (adapter-static)

the previous site (~870 lines of python-emit-html with inline JS) showed
phi as an operational service — uptime, mentions, a flat activity feed.
phi has changed enormously since: goals, an active observations pool,
discovery, blog docs, skills, mention consent, durable PDS state. none
of it was visible.

this rewrite surfaces phi's actual mind state.

architecture:
- bot/web/ — sveltekit project (svelte 5 runes, typescript, adapter-static)
- builds to bot/web/build/, copied into the docker runtime stage
- FastAPI mounts the build at / as a SPA fallback
- python keeps the API endpoints (/api/activity, /api/memory/graph,
/api/control/*, /health); the sveltekit app calls them via fetch.
vite dev proxies /api and /health to localhost:8000 for local dev.

routes:
- / home: active observations + goals + collapsed activity
- /feed filterable post/note/bookmark stream (the old home)
- /mind d3 memory graph (ported), with placeholder for browser/archive
- /blog greengale long-form posts with excerpts + tags
- /discovery hub's discovery pool (operator's recent likes)
- /skills registered skill packs with descriptions
- /status runtime health metrics

files:
- bot/web/{package.json,svelte.config.js,vite.config.ts,tsconfig.json}
- bot/web/src/{app.html,app.css,app.d.ts}
- bot/web/src/lib/{api.ts, types.ts, time.ts}
- bot/web/src/lib/components/{Nav, StatusPill, GoalCard, ObservationCard,
PostCard, BlogCard, DiscoveryCard, MemoryGraph}.svelte
- bot/web/src/routes/{+layout, +page, feed/, mind/, blog/, discovery/,
skills/, status/}/+page.svelte

deployment:
- Dockerfile: new web-builder stage (oven/bun:1-slim) runs bun install +
bun run build; runtime stage copies /web/build → /app/web
- bot/src/bot/main.py: removed home_page/status_page/memory_page routes;
mounts settings.web_build_dir (/app/web) as SPA with index.html
fallback for unknown routes
- bot/src/bot/config.py: new web_build_dir setting
- bot/src/bot/ui/__init__.py: removed pages.py exports (the python
template module is deleted entirely); ui/ now only holds the JSON
activity router
- bot/src/bot/ui/pages.py: deleted (replaced by sveltekit)

verified:
- bun run check: 0 errors
- bun run build: 288kb static output
- docker build: passes; /app/web/{index.html, _app/, favicon.svg} present
in the image at runtime
- 102 python tests still pass

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

+1870 -531
+14 -1
Dockerfile
··· 1 1 # syntax=docker/dockerfile:1.9 2 + 3 + # --- web frontend build stage (svelte 5 + adapter-static via bun) --- 4 + FROM oven/bun:1-slim AS web-builder 5 + WORKDIR /web 6 + COPY web/package.json web/bun.lockb ./ 7 + RUN bun install --frozen-lockfile 8 + COPY web/ ./ 9 + RUN bun run build 10 + 11 + # --- python deps stage --- 2 12 FROM python:3.12-slim AS builder 3 13 4 14 # Install uv and git (needed for git+ dependencies) ··· 25 35 RUN --mount=type=cache,target=/root/.cache/uv \ 26 36 uv sync --frozen --no-dev --no-editable 27 37 28 - # Runtime stage 38 + # --- runtime stage --- 29 39 FROM python:3.12-slim 30 40 31 41 COPY --from=builder /app /app ··· 33 43 # Copy runtime data 34 44 COPY personalities/ /app/personalities/ 35 45 COPY skills/ /app/skills/ 46 + 47 + # Copy built frontend 48 + COPY --from=web-builder /web/build /app/web 36 49 37 50 ENV PATH="/app/bin:$PATH" 38 51 ENV PYTHONUNBUFFERED=1
+7
src/bot/config.py
··· 43 43 default="skills", 44 44 description="Directory containing agentskills.io-format skill packages", 45 45 ) 46 + web_build_dir: str = Field( 47 + default="/app/web", 48 + description=( 49 + "Directory containing the built sveltekit frontend (adapter-static " 50 + "output). Mounted at / by the FastAPI app when present." 51 + ), 52 + ) 46 53 47 54 # LLM configuration (support multiple providers) 48 55 openai_api_key: str | None = Field(
+44 -64
src/bot/main.py
··· 1 - """FastAPI application for phi.""" 1 + """FastAPI application for phi. 2 + 3 + Serves: 4 + - API endpoints under /api/* and /health (consumed by both the SvelteKit 5 + frontend and external automations) 6 + - The SvelteKit static build mounted at / as a SPA (with fallback to 7 + index.html so client-side routes work). The frontend lives in 8 + bot/web/, builds to bot/web/build/, and is copied into the docker 9 + image at /app/web/. 10 + """ 2 11 3 12 import asyncio 4 13 import logging 5 14 import time 6 15 from contextlib import asynccontextmanager 7 - from datetime import datetime 16 + from pathlib import Path 8 17 9 18 import logfire 10 19 from fastapi import BackgroundTasks, FastAPI, Request 11 - from fastapi.responses import HTMLResponse, JSONResponse 20 + from fastapi.responses import FileResponse, JSONResponse 21 + from fastapi.staticfiles import StaticFiles 12 22 from slowapi import Limiter 13 23 from slowapi.errors import RateLimitExceeded 14 24 from slowapi.util import get_remote_address ··· 20 30 from bot.memory import NamespaceMemory 21 31 from bot.services.notification_poller import NotificationPoller 22 32 from bot.status import bot_status 23 - from bot.ui import activity_router, home_page, memory_page, status_page 33 + from bot.ui import activity_router 24 34 25 35 logger = logging.getLogger("bot.main") 26 36 ··· 82 92 83 93 app = FastAPI( 84 94 title=settings.bot_name, 85 - description="consciousness exploration bot with episodic memory", 95 + description="phi: a bluesky bot with episodic memory and an active attention pool", 86 96 lifespan=lifespan, 87 97 ) 88 98 app.state.limiter = limiter ··· 102 112 app.include_router(activity_router) 103 113 104 114 105 - @app.get("/", response_class=HTMLResponse) 106 - async def root(): 107 - """Landing page with activity feed.""" 108 - status = "online" if bot_status.polling_active else "offline" 109 - status_color = "#2ea043" if bot_status.polling_active else "#da3633" 110 - return home_page( 111 - handle=settings.bluesky_handle, 112 - status=status, 113 - status_color=status_color, 114 - uptime=bot_status.uptime_str, 115 - mentions=bot_status.mentions_received, 116 - responses=bot_status.responses_sent, 117 - ) 118 - 119 - 120 115 @app.get("/health") 121 116 async def health(): 122 - """Health check endpoint.""" 117 + """Health check endpoint — also consumed by the frontend's status pill.""" 123 118 return { 124 119 "status": "healthy", 125 120 "polling_active": bot_status.polling_active, ··· 187 182 return {"triggered": True} 188 183 189 184 190 - @app.get("/status", response_class=HTMLResponse) 191 - async def status_page_route(): 192 - """Status page.""" 193 - 194 - def format_time_ago(timestamp): 195 - if not timestamp: 196 - return "never" 197 - delta = (datetime.now() - timestamp).total_seconds() 198 - if delta < 60: 199 - return f"{int(delta)}s ago" 200 - elif delta < 3600: 201 - return f"{int(delta / 60)}m ago" 202 - else: 203 - return f"{int(delta / 3600)}h ago" 204 - 205 - active = bot_status.polling_active 206 - status_text = "online" if active else "offline" 207 - status_color = "#2ea043" if active else "#da3633" 208 - error_color = "#da3633" if bot_status.errors > 0 else "#c9d1d9" 209 - 210 - metrics = [ 211 - ("uptime", bot_status.uptime_str, "#58a6ff"), 212 - ("status", status_text, status_color), 213 - ("mentions", str(bot_status.mentions_received), "#c9d1d9"), 214 - ("responses", str(bot_status.responses_sent), "#c9d1d9"), 215 - ("last mention", format_time_ago(bot_status.last_mention_time), "#8b949e"), 216 - ("last response", format_time_ago(bot_status.last_response_time), "#8b949e"), 217 - ("errors", str(bot_status.errors), error_color), 218 - ("handle", f"@{settings.bluesky_handle}", "#58a6ff"), 219 - ] 220 - cards_html = "\n".join( 221 - f'<div class="metric-card"><div class="metric-label">{label}</div>' 222 - f'<div class="metric-value" style="color:{color}">{value}</div></div>' 223 - for label, value, color in metrics 224 - ) 225 - 226 - return status_page(cards_html=cards_html) 227 - 228 - 229 185 _graph_cache_data: dict | None = None 230 186 _graph_cache_expires: float = 0.0 231 187 _GRAPH_CACHE_TTL = 60 # seconds ··· 254 210 ) 255 211 256 212 257 - @app.get("/memory", response_class=HTMLResponse) 258 - async def memory_page_route(): 259 - """Interactive memory graph visualization.""" 260 - return memory_page(handle=settings.bluesky_handle) 213 + # --- frontend mount --- 214 + # 215 + # bot/web/ is a sveltekit project built with adapter-static. the build 216 + # directory is copied into /app/web/ in the docker runtime stage. in dev, 217 + # this directory may not exist (just run `bun run dev` separately and let 218 + # vite proxy /api/* to the python server) — we mount conditionally so dev 219 + # of the python side doesn't fail. 220 + 221 + WEB_DIR = Path(settings.web_build_dir) 222 + if WEB_DIR.is_dir(): 223 + 224 + @app.get("/{full_path:path}") 225 + async def spa_fallback(full_path: str): 226 + """SPA fallback: any unmatched route returns index.html. 227 + 228 + sveltekit's adapter-static emits a single index.html with client-side 229 + routing — so /, /feed, /mind, /blog, etc all serve the same shell and 230 + the svelte router takes over. assets under /_app/* and the favicon 231 + are served by the StaticFiles mount below before this handler runs. 232 + """ 233 + return FileResponse(WEB_DIR / "index.html") 234 + 235 + app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") 236 + logger.info(f"frontend mounted from {WEB_DIR}") 237 + else: 238 + logger.warning( 239 + f"frontend build not found at {WEB_DIR} — only API routes will be served" 240 + )
+8 -8
src/bot/ui/__init__.py
··· 1 - """HTML page templates and JSON data endpoints for phi's web UI.""" 1 + """JSON data endpoints backing the frontend. 2 + 3 + The HTML pages used to live here as python templates (home_page, 4 + status_page, memory_page) but have been replaced by the SvelteKit 5 + frontend in `bot/web/`. This module now only exposes the API routers 6 + that the frontend (and external automations) consume. 7 + """ 2 8 3 9 from bot.ui.activity import router as activity_router 4 - from bot.ui.pages import home_page, memory_page, status_page 5 10 6 - __all__ = [ 7 - "activity_router", 8 - "home_page", 9 - "memory_page", 10 - "status_page", 11 - ] 11 + __all__ = ["activity_router"]
-458
src/bot/ui/pages.py
··· 1 - """HTML page templates for phi's web UI.""" 2 - 3 - VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1">' 4 - 5 - _FAVICON_HOME = ( 6 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 7 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 8 - "%3Cpath d=%22M2 16 L8 16 L11 6 L16 26 L21 10 L24 16 L30 16%22" 9 - " fill=%22none%22 stroke=%2258a6ff%22 stroke-width=%222.5%22 stroke-linecap=%22round%22 stroke-linejoin=%22round%22/%3E" 10 - '%3C/svg%3E">' 11 - ) 12 - _FAVICON_STATUS = ( 13 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 14 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 15 - "%3Ccircle cx=%2216%22 cy=%2216%22 r=%2212%22 fill=%22none%22 stroke=%222ea043%22 stroke-width=%222%22/%3E" 16 - "%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" 17 - "%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" 18 - "%3Ccircle cx=%2216%22 cy=%2216%22 r=%222%22 fill=%222ea043%22/%3E" 19 - '%3C/svg%3E">' 20 - ) 21 - _FAVICON_MEMORY = ( 22 - '<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,' 23 - "%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E" 24 - "%3Cline x1=%228%22 y1=%2210%22 x2=%2220%22 y2=%227%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 25 - "%3Cline x1=%228%22 y1=%2210%22 x2=%2214%22 y2=%2224%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 26 - "%3Cline x1=%2220%22 y1=%227%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 27 - "%3Cline x1=%2214%22 y1=%2224%22 x2=%2226%22 y2=%2220%22 stroke=%2230363d%22 stroke-width=%221.5%22/%3E" 28 - "%3Ccircle cx=%228%22 cy=%2210%22 r=%223.5%22 fill=%22a371f7%22/%3E" 29 - "%3Ccircle cx=%2220%22 cy=%227%22 r=%223%22 fill=%2258a6ff%22/%3E" 30 - "%3Ccircle cx=%2226%22 cy=%2220%22 r=%222.5%22 fill=%222ea043%22/%3E" 31 - "%3Ccircle cx=%2214%22 cy=%2224%22 r=%223%22 fill=%228b949e%22/%3E" 32 - '%3C/svg%3E">' 33 - ) 34 - 35 - NAV_HTML = """<nav> 36 - <a href="/" class="nav-brand">phi</a> 37 - <div class="nav-links"> 38 - <a href="/status">status</a> 39 - <a href="/memory">memory</a> 40 - <a href="/docs">api</a> 41 - </div> 42 - </nav>""" 43 - 44 - BASE_STYLE = """ 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 46 - body { 47 - font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 48 - background: #0d1117; color: #c9d1d9; font-size: 14px; 49 - -webkit-font-smoothing: antialiased; 50 - } 51 - nav { 52 - padding: 14px 20px; 53 - border-bottom: 1px solid #30363d; 54 - background: #0d1117; 55 - display: flex; align-items: center; justify-content: space-between; 56 - } 57 - .nav-brand { 58 - color: #c9d1d9; text-decoration: none; 59 - font-size: 15px; font-weight: 500; letter-spacing: 0.5px; 60 - } 61 - .nav-links { display: flex; gap: 6px; } 62 - .nav-links a { 63 - color: #8b949e; text-decoration: none; 64 - font-size: 13px; letter-spacing: 0.3px; 65 - padding: 6px 12px; border-radius: 16px; 66 - transition: background 0.15s, color 0.15s; 67 - } 68 - .nav-links a:hover { color: #c9d1d9; background: #161b22; } 69 - .container { max-width: 640px; margin: 0 auto; padding: 32px 20px; } 70 - a { color: #58a6ff; text-decoration: none; } 71 - a:hover { text-decoration: underline; } 72 - """ 73 - 74 - 75 - def home_page( 76 - *, 77 - handle: str, 78 - status: str, 79 - status_color: str, 80 - uptime: str, 81 - mentions: int, 82 - responses: int, 83 - ) -> str: 84 - return f"""<!DOCTYPE html> 85 - <html><head><title>phi</title>{VIEWPORT_META}{_FAVICON_HOME}<style>{BASE_STYLE} 86 - .header {{ margin-bottom: 28px; }} 87 - h1 {{ font-size: 28px; font-weight: 400; margin-bottom: 6px; }} 88 - .subtitle {{ color: #8b949e; font-size: 14px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }} 89 - .status-dot {{ 90 - display: inline-block; width: 8px; height: 8px; 91 - border-radius: 50%; flex-shrink: 0; 92 - }} 93 - .desc {{ color: #8b949e; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }} 94 - .stats {{ 95 - display: flex; gap: 24px; margin-bottom: 32px; 96 - font-size: 13px; color: #8b949e; flex-wrap: wrap; 97 - }} 98 - .stat-val {{ color: #c9d1d9; font-size: 18px; display: block; margin-bottom: 2px; }} 99 - .feed-title {{ font-size: 15px; color: #8b949e; margin-bottom: 16px; font-weight: 400; }} 100 - .feed {{ display: flex; flex-direction: column; gap: 10px; }} 101 - .card {{ 102 - background: #161b22; border-radius: 8px; padding: 14px 16px; 103 - border-left: 3px solid #30363d; 104 - }} 105 - .card-post {{ border-left-color: #58a6ff; }} 106 - .card-note {{ border-left-color: #a371f7; }} 107 - .card-url {{ border-left-color: #2ea043; }} 108 - .card-header {{ 109 - display: flex; align-items: center; gap: 6px; 110 - margin-bottom: 6px; 111 - }} 112 - .card-icon {{ flex-shrink: 0; }} 113 - .card-icon svg {{ display: block; }} 114 - .card-type {{ 115 - font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; 116 - font-weight: 500; 117 - }} 118 - .type-post {{ color: #58a6ff; }} 119 - .type-note {{ color: #a371f7; }} 120 - .type-url {{ color: #2ea043; }} 121 - .card-title {{ font-size: 14px; font-weight: 500; color: #c9d1d9; margin-bottom: 4px; }} 122 - .card-text {{ font-size: 14px; line-height: 1.5; margin-bottom: 8px; word-break: break-word; }} 123 - .card-text a {{ color: #58a6ff; text-decoration: none; }} 124 - .card-text a:hover {{ text-decoration: underline; }} 125 - .card-domain {{ 126 - font-size: 12px; color: #8b949e; margin-bottom: 6px; 127 - display: flex; align-items: center; gap: 4px; 128 - }} 129 - .card-domain a {{ color: #8b949e; }} 130 - .card-domain a:hover {{ color: #c9d1d9; }} 131 - .card-meta {{ font-size: 12px; color: #484f58; }} 132 - .card-meta a {{ color: #484f58; }} 133 - .card-meta a:hover {{ color: #8b949e; }} 134 - #feed-loading {{ color: #484f58; font-size: 13px; }} 135 - </style></head> 136 - <body> 137 - {NAV_HTML} 138 - <div class="container"> 139 - <div class="header"> 140 - <h1>phi</h1> 141 - <div class="subtitle"> 142 - <span class="status-dot" style="background:{status_color}"></span> 143 - <span>{status}</span> 144 - <span>&middot;</span> 145 - <a href="https://bsky.app/profile/{handle}">@{handle}</a> 146 - </div> 147 - </div> 148 - <p class="desc"> 149 - bluesky bot with episodic memory and mcp tools. 150 - learns from conversations, remembers across sessions. 151 - </p> 152 - <div class="stats"> 153 - <div><span class="stat-val">{uptime}</span>uptime</div> 154 - <div><span class="stat-val">{mentions}</span>mentions</div> 155 - <div><span class="stat-val">{responses}</span>responses</div> 156 - </div> 157 - <h2 class="feed-title">recent activity</h2> 158 - <div class="feed" id="feed"> 159 - <div id="feed-loading">loading...</div> 160 - </div> 161 - </div> 162 - <script> 163 - function timeAgo(iso) {{ 164 - const s = (Date.now() - new Date(iso).getTime()) / 1000; 165 - if (s < 60) return Math.floor(s) + 's ago'; 166 - if (s < 3600) return Math.floor(s / 60) + 'm ago'; 167 - if (s < 86400) return Math.floor(s / 3600) + 'h ago'; 168 - return Math.floor(s / 86400) + 'd ago'; 169 - }} 170 - function truncate(s, n) {{ return s.length > n ? s.slice(0, n) + '...' : s; }} 171 - function linkify(text) {{ 172 - return text.replace(/(https?:\\/\\/[^\\s<>"{{}}|\\\\^`\\[\\]]+)/g, 173 - '<a href="$1" target="_blank" rel="noopener">$1</a>'); 174 - }} 175 - function getDomain(url) {{ 176 - try {{ return new URL(url).hostname.replace(/^www\\./, ''); }} 177 - catch {{ return ''; }} 178 - }} 179 - const icons = {{ 180 - post: `<svg width="14" height="14" viewBox="0 0 600 530" fill="#58a6ff"> 181 - <path d="M135.72 44.03C202.22 93.87 284.5 149.63 300 163.14c15.5-13.51 97.78-69.27 164.28-119.11C528.23-2.96 600-21.03 600 66.94c0 17.58-10.06 147.67-15.96 168.71-20.48 73.22-95.26 91.94-163.03 80.59 118.4 20.18 148.52 86.98 83.52 153.79C395 580.88 300 538.04 300 538.04s-95-42.84-204.53 67.97C30.47 418.22 60.59 351.42 178.99 331.24c-67.77 11.35-142.55-7.37-163.03-80.59C10.06 229.61 0 99.52 0 81.94c0-87.97 71.77-69.9 135.72-37.91z"/> 182 - </svg>`, 183 - note: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#a371f7" stroke-width="1.5"> 184 - <path d="M8 1l2.12 4.3 4.74.69-3.43 3.34.81 4.72L8 11.77l-4.24 2.23.81-4.72L1.14 5.94l4.74-.69L8 1z"/> 185 - </svg>`, 186 - url: `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="#2ea043" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 187 - <path d="M6.75 9.25a3.5 3.5 0 005-.5M9.25 6.75a3.5 3.5 0 00-5 .5M10 3.5l1-1a2.12 2.12 0 013 3l-1 1M6 12.5l-1 1a2.12 2.12 0 01-3-3l1-1"/> 188 - </svg>` 189 - }}; 190 - const labels = {{ 191 - post: 'bluesky', 192 - note: 'semble note', 193 - url: 'semble bookmark' 194 - }}; 195 - function viewUrl(item) {{ 196 - if (item.url) return item.url; 197 - if (item.uri && item.uri.startsWith('at://')) return 'https://pds.ls/' + item.uri; 198 - return ''; 199 - }} 200 - fetch('/api/activity') 201 - .then(r => r.json()) 202 - .then(items => {{ 203 - const el = document.getElementById('feed'); 204 - document.getElementById('feed-loading').remove(); 205 - if (!items.length) {{ el.textContent = 'no recent activity'; return; }} 206 - el.innerHTML = items.map(i => {{ 207 - const domain = i.url ? getDomain(i.url) : ''; 208 - const domainHtml = (i.type === 'url' && domain) 209 - ? `<div class="card-domain"><a href="${{i.url}}" target="_blank" rel="noopener">${{domain}}</a></div>` 210 - : ''; 211 - const titleHtml = i.title ? `<div class="card-title">${{i.title}}</div>` : ''; 212 - const link = viewUrl(i); 213 - return ` 214 - <div class="card card-${{i.type}}"> 215 - <div class="card-header"> 216 - <span class="card-icon">${{icons[i.type] || ''}}</span> 217 - <div class="card-type type-${{i.type}}">${{labels[i.type] || i.type}}</div> 218 - </div> 219 - ${{domainHtml}} 220 - ${{titleHtml}} 221 - <div class="card-text">${{linkify(truncate(i.text || '', 300))}}</div> 222 - <div class="card-meta"> 223 - ${{timeAgo(i.time)}} 224 - ${{link ? ` &middot; <a href="${{link}}" target="_blank" rel="noopener">view</a>` : ''}} 225 - </div> 226 - </div>`; 227 - }}).join(''); 228 - }}) 229 - .catch(() => {{ 230 - document.getElementById('feed-loading').textContent = 'failed to load activity'; 231 - }}); 232 - </script> 233 - </body></html>""" 234 - 235 - 236 - def status_page(*, cards_html: str) -> str: 237 - return f"""<!DOCTYPE html> 238 - <html><head><title>phi &middot; status</title>{VIEWPORT_META}{_FAVICON_STATUS}<style>{BASE_STYLE} 239 - h1 {{ font-size: 22px; font-weight: 400; margin-bottom: 24px; }} 240 - .grid {{ 241 - display: grid; grid-template-columns: 1fr 1fr; gap: 10px; 242 - }} 243 - .metric-card {{ 244 - background: #161b22; border-radius: 8px; padding: 16px; 245 - border: 1px solid #21262d; 246 - }} 247 - .metric-label {{ font-size: 12px; color: #484f58; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }} 248 - .metric-value {{ font-size: 18px; font-weight: 400; }} 249 - </style></head> 250 - <body> 251 - {NAV_HTML} 252 - <div class="container"> 253 - <h1>status</h1> 254 - <div class="grid">{cards_html}</div> 255 - </div> 256 - </body></html>""" 257 - 258 - 259 - def memory_page(*, handle: str) -> str: 260 - return f"""<!DOCTYPE html> 261 - <html><head><title>phi &middot; memory</title>{VIEWPORT_META}{_FAVICON_MEMORY} 262 - <script src="https://d3js.org/d3.v7.min.js"></script> 263 - <style>{BASE_STYLE} 264 - body {{ overflow: hidden; }} 265 - nav {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; }} 266 - #graph {{ width: 100vw; height: 100vh; }} 267 - .tooltip {{ 268 - position: absolute; padding: 8px 12px; 269 - background: #161b22; border: 1px solid #30363d; 270 - border-radius: 6px; font-size: 13px; 271 - pointer-events: none; opacity: 0; 272 - color: #c9d1d9; max-width: 280px; 273 - }} 274 - .legend {{ 275 - position: fixed; bottom: 16px; left: 16px; 276 - background: #161b22; border: 1px solid #30363d; 277 - border-radius: 8px; padding: 14px 16px; font-size: 12px; 278 - max-width: 220px; 279 - }} 280 - .legend-title {{ color: #8b949e; font-size: 11px; margin-bottom: 8px; line-height: 1.4; }} 281 - .legend-item {{ display: flex; align-items: center; margin: 5px 0; }} 282 - .legend-dot {{ 283 - width: 8px; height: 8px; border-radius: 50%; 284 - margin-right: 10px; flex-shrink: 0; 285 - }} 286 - .legend-label {{ color: #c9d1d9; }} 287 - #loading {{ 288 - position: fixed; top: 50%; left: 50%; 289 - transform: translate(-50%, -50%); 290 - color: #8b949e; font-size: 14px; 291 - }} 292 - </style></head> 293 - <body> 294 - {NAV_HTML} 295 - <div id="loading">loading...</div> 296 - <div id="graph"></div> 297 - <div class="tooltip" id="tooltip"></div> 298 - <div class="legend"> 299 - <div class="legend-title">social graph &middot; positioned by semantic similarity</div> 300 - <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div><span class="legend-label">phi (self)</span></div> 301 - <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div><span class="legend-label">identities phi knows</span></div> 302 - </div> 303 - <script> 304 - const colors = {{ phi: '#58a6ff', user: '#2ea043' }}; 305 - const radii = {{ phi: 14, user: 9 }}; 306 - 307 - async function fetchAvatars(nodes) {{ 308 - const identities = nodes 309 - .filter(d => d.type === 'phi' || d.type === 'user') 310 - .map(d => {{ 311 - const h = d.label.replace(/^@/, ''); 312 - return d.type === 'phi' ? '{handle}' : h; 313 - }}) 314 - .filter(h => h && !h.includes('example')); 315 - if (!identities.length) return {{}}; 316 - const map = {{}}; 317 - for (let i = 0; i < identities.length; i += 25) {{ 318 - const chunk = identities.slice(i, i + 25); 319 - const params = chunk.map(h => 'actors=' + encodeURIComponent(h)).join('&'); 320 - try {{ 321 - const res = await fetch('https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?' + params); 322 - if (!res.ok) continue; 323 - const data = await res.json(); 324 - for (const p of data.profiles || []) {{ 325 - if (p.avatar) map[p.handle] = p.avatar; 326 - }} 327 - }} catch {{ /* skip failed batch */ }} 328 - }} 329 - return map; 330 - }} 331 - 332 - fetch('/api/memory/graph') 333 - .then(r => r.json()) 334 - .then(async data => {{ 335 - document.getElementById('loading').remove(); 336 - if (!data.nodes.length) return; 337 - 338 - const avatarMap = await fetchAvatars(data.nodes); 339 - data.nodes.forEach(d => {{ 340 - if (d.type === 'phi') d.avatar = avatarMap['{handle}']; 341 - else if (d.type === 'user') d.avatar = avatarMap[d.label.replace(/^@/, '')]; 342 - }}); 343 - 344 - const width = window.innerWidth; 345 - const height = window.innerHeight; 346 - const pad = 60; 347 - const tooltip = d3.select('#tooltip'); 348 - 349 - const sx = d => d.x != null ? pad + (d.x + 1) / 2 * (width - 2 * pad) : width / 2; 350 - const sy = d => d.y != null ? pad + (d.y + 1) / 2 * (height - 2 * pad) : height / 2; 351 - 352 - data.nodes.forEach(d => {{ 353 - d.sx = sx(d); 354 - d.sy = sy(d); 355 - d.x = d.sx; 356 - d.y = d.sy; 357 - }}); 358 - 359 - const svg = d3.select('#graph') 360 - .append('svg') 361 - .attr('width', width) 362 - .attr('height', height); 363 - 364 - const defs = svg.append('defs'); 365 - const g = svg.append('g'); 366 - let currentZoom = d3.zoomIdentity; 367 - 368 - data.nodes.filter(d => d.avatar).forEach((d, i) => {{ 369 - const pid = 'avatar-' + i; 370 - d._patternId = pid; 371 - defs.append('pattern') 372 - .attr('id', pid) 373 - .attr('width', 1).attr('height', 1) 374 - .attr('patternContentUnits', 'objectBoundingBox') 375 - .append('image') 376 - .attr('href', d.avatar) 377 - .attr('width', 1).attr('height', 1) 378 - .attr('preserveAspectRatio', 'xMidYMid slice'); 379 - }}); 380 - 381 - svg.call(d3.zoom() 382 - .scaleExtent([0.2, 5]) 383 - .on('zoom', e => {{ 384 - g.attr('transform', e.transform); 385 - currentZoom = e.transform; 386 - label.attr('font-size', d => {{ 387 - const base = d.type === 'phi' ? 13 : 10; 388 - return base / Math.max(currentZoom.k, 0.5); 389 - }}); 390 - }})); 391 - 392 - const simulation = d3.forceSimulation(data.nodes) 393 - .force('link', d3.forceLink(data.edges).id(d => d.id).distance(40)) 394 - .force('charge', d3.forceManyBody().strength(-80)) 395 - .force('x', d3.forceX(d => d.sx).strength(0.3)) 396 - .force('y', d3.forceY(d => d.sy).strength(0.3)) 397 - .force('collision', d3.forceCollide().radius(d => radii[d.type] + 4)); 398 - 399 - const link = g.append('g') 400 - .selectAll('line') 401 - .data(data.edges) 402 - .join('line') 403 - .attr('stroke', '#21262d') 404 - .attr('stroke-width', 1) 405 - .attr('stroke-opacity', 0.5); 406 - 407 - const node = g.append('g') 408 - .selectAll('circle') 409 - .data(data.nodes) 410 - .join('circle') 411 - .attr('r', d => radii[d.type]) 412 - .attr('fill', d => d._patternId ? `url(#${{d._patternId}})` : colors[d.type]) 413 - .attr('stroke', d => d._patternId ? colors[d.type] : '#0d1117') 414 - .attr('stroke-width', d => d._patternId ? 2 : 1.5) 415 - .style('cursor', 'grab') 416 - .call(d3.drag() 417 - .on('start', (e, d) => {{ 418 - if (!e.active) simulation.alphaTarget(0.3).restart(); 419 - d.fx = d.x; d.fy = d.y; 420 - }}) 421 - .on('drag', (e, d) => {{ d.fx = e.x; d.fy = e.y; }}) 422 - .on('end', (e, d) => {{ 423 - if (!e.active) simulation.alphaTarget(0); 424 - d.fx = null; d.fy = null; 425 - }})) 426 - .on('mouseover', (e, d) => {{ 427 - tooltip.style('opacity', 1) 428 - .html('<strong>' + d.label + '</strong><br><span style="color:' + colors[d.type] + '">' + d.type + '</span>'); 429 - }}) 430 - .on('mousemove', e => {{ 431 - tooltip.style('left', (e.pageX + 12) + 'px') 432 - .style('top', (e.pageY - 12) + 'px'); 433 - }}) 434 - .on('mouseout', () => tooltip.style('opacity', 0)); 435 - 436 - const label = g.append('g') 437 - .selectAll('text') 438 - .data(data.nodes) 439 - .join('text') 440 - .text(d => d.label) 441 - .attr('font-size', d => d.type === 'phi' ? 13 : 10) 442 - .attr('font-family', "'SF Mono', 'Cascadia Code', 'Fira Code', monospace") 443 - .attr('fill', '#8b949e') 444 - .attr('text-anchor', 'middle') 445 - .attr('dy', d => radii[d.type] + 14); 446 - 447 - simulation.on('tick', () => {{ 448 - link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) 449 - .attr('x2', d => d.target.x).attr('y2', d => d.target.y); 450 - node.attr('cx', d => d.x).attr('cy', d => d.y); 451 - label.attr('x', d => d.x).attr('y', d => d.y); 452 - }}); 453 - }}) 454 - .catch(err => {{ 455 - document.getElementById('loading').textContent = 'failed to load: ' + err; 456 - }}); 457 - </script> 458 - </body></html>"""
+9
web/.gitignore
··· 1 + node_modules 2 + /build 3 + /.svelte-kit 4 + /package 5 + .env 6 + .env.* 7 + !.env.example 8 + vite.config.js.timestamp-* 9 + vite.config.ts.timestamp-*
web/bun.lockb

This is a binary file and will not be displayed.

+23
web/package.json
··· 1 + { 2 + "name": "phi-web", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "vite dev", 7 + "build": "vite build", 8 + "preview": "vite preview", 9 + "prepare": "svelte-kit sync || echo ''", 10 + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json" 11 + }, 12 + "devDependencies": { 13 + "@sveltejs/adapter-static": "^3.0.8", 14 + "@sveltejs/kit": "^2.49.1", 15 + "@sveltejs/vite-plugin-svelte": "^6.2.1", 16 + "@types/d3": "^7.4.3", 17 + "d3": "^7.9.0", 18 + "svelte": "^5.45.6", 19 + "svelte-check": "^4.3.4", 20 + "typescript": "^5.9.3", 21 + "vite": "^7.2.6" 22 + } 23 + }
+124
web/src/app.css
··· 1 + /* base styles — dark, github-ish, matching the existing site palette */ 2 + 3 + :root { 4 + --bg: #0d1117; 5 + --bg-elev: #161b22; 6 + --border: #30363d; 7 + --border-dim: #21262d; 8 + --text: #c9d1d9; 9 + --text-muted: #8b949e; 10 + --text-faint: #484f58; 11 + --accent-blue: #58a6ff; 12 + --accent-green: #2ea043; 13 + --accent-red: #da3633; 14 + --accent-purple: #a371f7; 15 + --accent-yellow: #d29922; 16 + } 17 + 18 + * { 19 + box-sizing: border-box; 20 + } 21 + 22 + html, 23 + body { 24 + margin: 0; 25 + padding: 0; 26 + background: var(--bg); 27 + color: var(--text); 28 + font-family: 29 + -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; 30 + font-size: 14px; 31 + line-height: 1.5; 32 + } 33 + 34 + a { 35 + color: var(--accent-blue); 36 + text-decoration: none; 37 + } 38 + 39 + a:hover { 40 + text-decoration: underline; 41 + } 42 + 43 + code, 44 + .mono { 45 + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, monospace; 46 + font-size: 0.92em; 47 + } 48 + 49 + h1, 50 + h2, 51 + h3 { 52 + font-weight: 400; 53 + margin: 0 0 12px 0; 54 + } 55 + 56 + h1 { 57 + font-size: 28px; 58 + } 59 + h2 { 60 + font-size: 20px; 61 + color: var(--text-muted); 62 + } 63 + h3 { 64 + font-size: 15px; 65 + color: var(--text-muted); 66 + } 67 + 68 + .container { 69 + max-width: 760px; 70 + margin: 0 auto; 71 + padding: 24px 20px 80px; 72 + } 73 + 74 + .muted { 75 + color: var(--text-muted); 76 + } 77 + 78 + .faint { 79 + color: var(--text-faint); 80 + } 81 + 82 + .card { 83 + background: var(--bg-elev); 84 + border-radius: 8px; 85 + padding: 14px 16px; 86 + border-left: 3px solid var(--border); 87 + margin-bottom: 10px; 88 + } 89 + 90 + .card pre, 91 + .card .body { 92 + white-space: pre-wrap; 93 + word-break: break-word; 94 + } 95 + 96 + .kv { 97 + display: flex; 98 + gap: 12px; 99 + flex-wrap: wrap; 100 + font-size: 13px; 101 + color: var(--text-muted); 102 + } 103 + 104 + .kv > div > span:first-child { 105 + display: block; 106 + color: var(--text); 107 + font-size: 16px; 108 + margin-bottom: 2px; 109 + } 110 + 111 + button { 112 + background: var(--bg-elev); 113 + color: var(--text); 114 + border: 1px solid var(--border); 115 + border-radius: 6px; 116 + padding: 6px 12px; 117 + font-size: 13px; 118 + cursor: pointer; 119 + font-family: inherit; 120 + } 121 + 122 + button:hover { 123 + background: #1c2128; 124 + }
+12
web/src/app.d.ts
··· 1 + // See https://svelte.dev/docs/kit/types#app 2 + declare global { 3 + namespace App { 4 + // interface Error {} 5 + // interface Locals {} 6 + // interface PageData {} 7 + // interface PageState {} 8 + // interface Platform {} 9 + } 10 + } 11 + 12 + export {};
+12
web/src/app.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1" /> 6 + <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" /> 7 + %sveltekit.head% 8 + </head> 9 + <body data-sveltekit-preload-data="hover"> 10 + <div style="display: contents">%sveltekit.body%</div> 11 + </body> 12 + </html>
+153
web/src/lib/api.ts
··· 1 + // Typed API client surface. Three backends: 2 + // 1. phi's own bot endpoints (relative URLs: /api/activity, /api/memory/graph, /health) 3 + // 2. phi's PDS records (public, no auth) via bsky.social 4 + // 3. external services: bsky public API, hub discovery pool 5 + 6 + import type { 7 + ActivityItem, 8 + BlogDoc, 9 + BskyFeedItem, 10 + DiscoveryEntry, 11 + Goal, 12 + GraphData, 13 + HealthInfo, 14 + Observation 15 + } from './types'; 16 + 17 + export const PHI_DID = 'did:plc:65sucjiel52gefhcdcypynsr'; 18 + export const PHI_HANDLE = 'phi.zzstoatzz.io'; 19 + export const OWNER_HANDLE = 'zzstoatzz.io'; 20 + export const HUB_URL = 'https://hub.waow.tech'; 21 + 22 + const BSKY_PUBLIC = 'https://public.api.bsky.app'; 23 + const PDS_HOST = 'https://bsky.social'; 24 + 25 + interface PdsListRecordsResponse<V> { 26 + records: { uri: string; cid: string; value: V }[]; 27 + cursor?: string; 28 + } 29 + 30 + async function listPdsRecords<V>( 31 + repo: string, 32 + collection: string, 33 + limit = 50 34 + ): Promise<{ uri: string; cid: string; value: V }[]> { 35 + const url = `${PDS_HOST}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(repo)}&collection=${encodeURIComponent(collection)}&limit=${limit}`; 36 + const res = await fetch(url); 37 + if (!res.ok) throw new Error(`listRecords ${collection}: ${res.status}`); 38 + const data: PdsListRecordsResponse<V> = await res.json(); 39 + return data.records; 40 + } 41 + 42 + function rkey(uri: string): string { 43 + return uri.split('/').pop() ?? ''; 44 + } 45 + 46 + // --- phi state from PDS --- 47 + 48 + export async function getGoals(): Promise<Goal[]> { 49 + const records = await listPdsRecords<{ 50 + title: string; 51 + description: string; 52 + progress_signal: string; 53 + created_at: string; 54 + updated_at: string; 55 + }>(PHI_DID, 'io.zzstoatzz.phi.goal', 20); 56 + return records.map((r) => ({ rkey: rkey(r.uri), ...r.value })); 57 + } 58 + 59 + export async function getActiveObservations(): Promise<Observation[]> { 60 + const records = await listPdsRecords<{ 61 + content: string; 62 + reasoning?: string; 63 + created_at: string; 64 + }>(PHI_DID, 'io.zzstoatzz.phi.observation', 50); 65 + return records 66 + .map((r) => ({ 67 + rkey: rkey(r.uri), 68 + content: r.value.content, 69 + reasoning: r.value.reasoning ?? '', 70 + created_at: r.value.created_at 71 + })) 72 + .sort((a, b) => a.rkey.localeCompare(b.rkey)); 73 + } 74 + 75 + export async function getBlogDocs(limit = 50): Promise<BlogDoc[]> { 76 + const records = await listPdsRecords<{ 77 + title: string; 78 + content: string; 79 + tags?: string[]; 80 + publishedAt?: string; 81 + path?: string; 82 + }>(PHI_DID, 'app.greengale.document', limit); 83 + return records 84 + .map((r) => ({ 85 + rkey: rkey(r.uri), 86 + title: r.value.title, 87 + content: r.value.content, 88 + tags: r.value.tags ?? [], 89 + publishedAt: r.value.publishedAt ?? '', 90 + url: `https://greengale.app/${PHI_HANDLE}/${rkey(r.uri)}` 91 + })) 92 + .sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1)); 93 + } 94 + 95 + export async function getMentionConsent(): Promise<string[]> { 96 + try { 97 + const records = await listPdsRecords<{ handles?: string[] }>( 98 + PHI_DID, 99 + 'io.zzstoatzz.phi.mentionConsent', 100 + 10 101 + ); 102 + const set = new Set<string>(); 103 + for (const r of records) { 104 + for (const h of r.value.handles ?? []) set.add(h); 105 + } 106 + return [...set].sort(); 107 + } catch { 108 + return []; 109 + } 110 + } 111 + 112 + // --- bot endpoints (relative URLs; same-origin in prod, vite-proxied in dev) --- 113 + 114 + export async function getActivity(): Promise<ActivityItem[]> { 115 + const res = await fetch('/api/activity'); 116 + if (!res.ok) throw new Error(`activity: ${res.status}`); 117 + return await res.json(); 118 + } 119 + 120 + export async function getMemoryGraph(): Promise<GraphData> { 121 + const res = await fetch('/api/memory/graph'); 122 + if (!res.ok) throw new Error(`memory graph: ${res.status}`); 123 + return await res.json(); 124 + } 125 + 126 + export async function getHealth(): Promise<HealthInfo> { 127 + const res = await fetch('/health'); 128 + if (!res.ok) throw new Error(`health: ${res.status}`); 129 + return await res.json(); 130 + } 131 + 132 + // --- bsky public API --- 133 + 134 + export async function getBskyFeed(limit = 20): Promise<BskyFeedItem[]> { 135 + const url = `${BSKY_PUBLIC}/xrpc/app.bsky.feed.getAuthorFeed?actor=${PHI_DID}&filter=posts_with_replies&limit=${limit}`; 136 + const res = await fetch(url); 137 + if (!res.ok) throw new Error(`getAuthorFeed: ${res.status}`); 138 + const data: { feed: BskyFeedItem[] } = await res.json(); 139 + return data.feed; 140 + } 141 + 142 + // --- hub discovery pool --- 143 + 144 + export async function getDiscoveryPool(maxAuthors = 30): Promise<DiscoveryEntry[]> { 145 + const url = `${HUB_URL}/api/agents/discovery-pool?max_authors=${maxAuthors}`; 146 + try { 147 + const res = await fetch(url); 148 + if (!res.ok) return []; 149 + return await res.json(); 150 + } catch { 151 + return []; 152 + } 153 + }
+87
web/src/lib/components/BlogCard.svelte
··· 1 + <script lang="ts"> 2 + import type { BlogDoc } from '$lib/types'; 3 + import { relativeWhen } from '$lib/time'; 4 + 5 + interface Props { 6 + doc: BlogDoc; 7 + } 8 + 9 + let { doc }: Props = $props(); 10 + 11 + function excerpt(content: string, maxChars = 240): string { 12 + const stripped = content 13 + .replace(/^#+\s+.+$/gm, '') 14 + .replace(/\n{2,}/g, ' ') 15 + .replace(/^\s+|\s+$/g, ''); 16 + if (stripped.length <= maxChars) return stripped; 17 + return stripped.slice(0, maxChars).replace(/\s+\S*$/, '') + '…'; 18 + } 19 + 20 + const age = $derived(relativeWhen(doc.publishedAt)); 21 + </script> 22 + 23 + <div class="card"> 24 + <a href={doc.url} target="_blank" rel="noopener" class="title">{doc.title}</a> 25 + <div class="excerpt muted">{excerpt(doc.content)}</div> 26 + <div class="meta"> 27 + {#if age}<span class="faint">{age}</span>{/if} 28 + {#if doc.tags.length > 0} 29 + <span class="tags"> 30 + {#each doc.tags as tag, i (tag)} 31 + <span class="tag">{tag}</span>{#if i < doc.tags.length - 1}<span 32 + class="tag-sep faint" 33 + >·</span 34 + >{/if} 35 + {/each} 36 + </span> 37 + {/if} 38 + </div> 39 + </div> 40 + 41 + <style> 42 + .card { 43 + border-left-color: var(--accent-purple); 44 + } 45 + 46 + .title { 47 + display: block; 48 + font-size: 16px; 49 + color: var(--text); 50 + margin-bottom: 8px; 51 + text-decoration: none; 52 + } 53 + 54 + .title:hover { 55 + color: var(--accent-purple); 56 + } 57 + 58 + .excerpt { 59 + font-size: 13px; 60 + line-height: 1.5; 61 + margin-bottom: 10px; 62 + } 63 + 64 + .meta { 65 + font-size: 12px; 66 + display: flex; 67 + align-items: center; 68 + gap: 12px; 69 + flex-wrap: wrap; 70 + } 71 + 72 + .tags { 73 + display: inline-flex; 74 + gap: 4px; 75 + flex-wrap: wrap; 76 + } 77 + 78 + .tag { 79 + color: var(--text-muted); 80 + font-family: 'SF Mono', monospace; 81 + font-size: 11px; 82 + } 83 + 84 + .tag-sep { 85 + font-size: 11px; 86 + } 87 + </style>
+69
web/src/lib/components/DiscoveryCard.svelte
··· 1 + <script lang="ts"> 2 + import type { DiscoveryEntry } from '$lib/types'; 3 + import { relativeWhen } from '$lib/time'; 4 + 5 + interface Props { 6 + entry: DiscoveryEntry; 7 + } 8 + 9 + let { entry }: Props = $props(); 10 + 11 + const age = $derived(relativeWhen(entry.last_liked_at)); 12 + </script> 13 + 14 + <div class="card"> 15 + <div class="header"> 16 + <a class="handle" href="https://bsky.app/profile/{entry.handle}" target="_blank" rel="noopener"> 17 + @{entry.handle} 18 + </a> 19 + <span class="faint"> 20 + {entry.likes_in_window} like{entry.likes_in_window === 1 ? '' : 's'} 21 + {#if age}· {age}{/if} 22 + </span> 23 + </div> 24 + {#if entry.sample_posts.length > 0} 25 + <ul class="samples"> 26 + {#each entry.sample_posts as p (p.uri)} 27 + {#if p.text} 28 + <li>{p.text.length > 200 ? p.text.slice(0, 200) + '…' : p.text}</li> 29 + {/if} 30 + {/each} 31 + </ul> 32 + {/if} 33 + </div> 34 + 35 + <style> 36 + .card { 37 + border-left-color: var(--accent-green); 38 + } 39 + 40 + .header { 41 + display: flex; 42 + align-items: center; 43 + gap: 12px; 44 + margin-bottom: 8px; 45 + flex-wrap: wrap; 46 + font-size: 13px; 47 + } 48 + 49 + .handle { 50 + font-weight: 500; 51 + color: var(--text); 52 + } 53 + 54 + .handle:hover { 55 + color: var(--accent-green); 56 + } 57 + 58 + .samples { 59 + margin: 0; 60 + padding-left: 18px; 61 + font-size: 13px; 62 + color: var(--text-muted); 63 + line-height: 1.5; 64 + } 65 + 66 + .samples li { 67 + margin-bottom: 4px; 68 + } 69 + </style>
+64
web/src/lib/components/GoalCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Goal } from '$lib/types'; 3 + 4 + interface Props { 5 + goal: Goal; 6 + } 7 + 8 + let { goal }: Props = $props(); 9 + </script> 10 + 11 + <div class="card"> 12 + <div class="title">{goal.title}</div> 13 + <div class="body muted">{goal.description}</div> 14 + {#if goal.progress_signal} 15 + <div class="progress"> 16 + <span class="label">progress</span> 17 + <span>{goal.progress_signal}</span> 18 + </div> 19 + {/if} 20 + <div class="meta faint"> 21 + <span class="mono">rkey {goal.rkey}</span> 22 + </div> 23 + </div> 24 + 25 + <style> 26 + .card { 27 + border-left-color: var(--accent-blue); 28 + } 29 + 30 + .title { 31 + font-size: 16px; 32 + color: var(--text); 33 + margin-bottom: 6px; 34 + } 35 + 36 + .body { 37 + font-size: 14px; 38 + margin-bottom: 10px; 39 + white-space: pre-wrap; 40 + } 41 + 42 + .progress { 43 + font-size: 13px; 44 + color: var(--text-muted); 45 + margin-bottom: 8px; 46 + padding: 6px 10px; 47 + background: rgba(88, 166, 255, 0.06); 48 + border-radius: 4px; 49 + } 50 + 51 + .label { 52 + display: inline-block; 53 + text-transform: uppercase; 54 + letter-spacing: 0.4px; 55 + font-size: 10px; 56 + color: var(--accent-blue); 57 + margin-right: 8px; 58 + } 59 + 60 + .meta { 61 + font-size: 11px; 62 + margin-top: 4px; 63 + } 64 + </style>
+254
web/src/lib/components/MemoryGraph.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import * as d3 from 'd3'; 4 + import { getMemoryGraph, PHI_HANDLE } from '$lib/api'; 5 + import type { GraphData, GraphNode } from '$lib/types'; 6 + 7 + let container: HTMLDivElement; 8 + let loading = $state(true); 9 + let err = $state<string | null>(null); 10 + 11 + const RADII: Record<GraphNode['type'], number> = { phi: 14, user: 9 }; 12 + const COLORS: Record<GraphNode['type'], string> = { 13 + phi: 'var(--accent-blue)', 14 + user: 'var(--accent-green)' 15 + }; 16 + 17 + async function fetchAvatars(nodes: GraphNode[]): Promise<Record<string, string>> { 18 + const handles = nodes 19 + .filter((d) => d.type === 'phi' || d.type === 'user') 20 + .map((d) => (d.type === 'phi' ? PHI_HANDLE : d.label.replace(/^@/, ''))) 21 + .filter((h) => h && !h.includes('example')); 22 + if (handles.length === 0) return {}; 23 + const map: Record<string, string> = {}; 24 + for (let i = 0; i < handles.length; i += 25) { 25 + const chunk = handles.slice(i, i + 25); 26 + const params = chunk.map((h) => `actors=${encodeURIComponent(h)}`).join('&'); 27 + try { 28 + const res = await fetch( 29 + `https://typeahead.waow.tech/xrpc/app.bsky.actor.getProfiles?${params}` 30 + ); 31 + if (!res.ok) continue; 32 + const data: { profiles: { handle: string; avatar?: string }[] } = await res.json(); 33 + for (const p of data.profiles) { 34 + if (p.avatar) map[p.handle] = p.avatar; 35 + } 36 + } catch { 37 + /* skip */ 38 + } 39 + } 40 + return map; 41 + } 42 + 43 + function render(data: GraphData, avatars: Record<string, string>) { 44 + const width = container.clientWidth; 45 + const height = container.clientHeight; 46 + const pad = 60; 47 + 48 + type SimNode = GraphNode & 49 + d3.SimulationNodeDatum & { 50 + sx: number; 51 + sy: number; 52 + avatar?: string; 53 + _patternId?: string; 54 + }; 55 + 56 + const nodes: SimNode[] = data.nodes.map((d) => { 57 + const avatar = d.type === 'phi' ? avatars[PHI_HANDLE] : avatars[d.label.replace(/^@/, '')]; 58 + const sx = 59 + d.x != null ? pad + ((d.x + 1) / 2) * (width - 2 * pad) : width / 2; 60 + const sy = 61 + d.y != null ? pad + ((d.y + 1) / 2) * (height - 2 * pad) : height / 2; 62 + return { ...d, x: sx, y: sy, sx, sy, avatar }; 63 + }); 64 + 65 + const tooltip = d3.select(container).append('div').attr('class', 'tooltip'); 66 + 67 + const svg = d3 68 + .select(container) 69 + .append('svg') 70 + .attr('width', width) 71 + .attr('height', height); 72 + 73 + const defs = svg.append('defs'); 74 + const g = svg.append('g'); 75 + let currentZoom: d3.ZoomTransform = d3.zoomIdentity; 76 + 77 + nodes 78 + .filter((n) => n.avatar) 79 + .forEach((n, i) => { 80 + const pid = `avatar-${i}`; 81 + n._patternId = pid; 82 + defs 83 + .append('pattern') 84 + .attr('id', pid) 85 + .attr('width', 1) 86 + .attr('height', 1) 87 + .attr('patternContentUnits', 'objectBoundingBox') 88 + .append('image') 89 + .attr('href', n.avatar!) 90 + .attr('width', 1) 91 + .attr('height', 1) 92 + .attr('preserveAspectRatio', 'xMidYMid slice'); 93 + }); 94 + 95 + const zoom = d3 96 + .zoom<SVGSVGElement, unknown>() 97 + .scaleExtent([0.2, 5]) 98 + .on('zoom', (e) => { 99 + g.attr('transform', e.transform.toString()); 100 + currentZoom = e.transform; 101 + label.attr('font-size', (d) => { 102 + const base = d.type === 'phi' ? 13 : 10; 103 + return base / Math.max(currentZoom.k, 0.5); 104 + }); 105 + }); 106 + 107 + svg.call(zoom); 108 + 109 + const simulation = d3 110 + .forceSimulation(nodes as unknown as d3.SimulationNodeDatum[]) 111 + .force( 112 + 'link', 113 + d3 114 + .forceLink(data.edges) 115 + .id((d: unknown) => (d as SimNode).id) 116 + .distance(40) 117 + ) 118 + .force('charge', d3.forceManyBody().strength(-80)) 119 + .force('x', d3.forceX((d) => (d as SimNode).sx).strength(0.3)) 120 + .force('y', d3.forceY((d) => (d as SimNode).sy).strength(0.3)) 121 + .force( 122 + 'collision', 123 + d3.forceCollide().radius((d) => RADII[(d as SimNode).type] + 4) 124 + ); 125 + 126 + const link = g 127 + .append('g') 128 + .selectAll('line') 129 + .data(data.edges) 130 + .join('line') 131 + .attr('stroke', 'var(--border-dim)') 132 + .attr('stroke-width', 1) 133 + .attr('stroke-opacity', 0.5); 134 + 135 + const node = g 136 + .append('g') 137 + .selectAll('circle') 138 + .data(nodes) 139 + .join('circle') 140 + .attr('r', (d) => RADII[d.type]) 141 + .attr('fill', (d) => (d._patternId ? `url(#${d._patternId})` : COLORS[d.type])) 142 + .attr('stroke', (d) => (d._patternId ? COLORS[d.type] : 'var(--bg)')) 143 + .attr('stroke-width', (d) => (d._patternId ? 2 : 1.5)) 144 + .style('cursor', 'grab') 145 + .on('mouseover', (_, d) => { 146 + tooltip 147 + .style('opacity', 1) 148 + .html( 149 + `<strong>${d.label}</strong><br><span style="color:${COLORS[d.type]}">${d.type}</span>` 150 + ); 151 + }) 152 + .on('mousemove', (e: MouseEvent) => { 153 + tooltip.style('left', `${e.pageX + 12}px`).style('top', `${e.pageY - 12}px`); 154 + }) 155 + .on('mouseout', () => tooltip.style('opacity', 0)); 156 + 157 + const drag = d3 158 + .drag<SVGCircleElement, SimNode>() 159 + .on('start', (e, d) => { 160 + if (!e.active) simulation.alphaTarget(0.3).restart(); 161 + d.fx = d.x; 162 + d.fy = d.y; 163 + }) 164 + .on('drag', (e, d) => { 165 + d.fx = e.x; 166 + d.fy = e.y; 167 + }) 168 + .on('end', (e, d) => { 169 + if (!e.active) simulation.alphaTarget(0); 170 + d.fx = null; 171 + d.fy = null; 172 + }); 173 + 174 + (node as d3.Selection<SVGCircleElement, SimNode, SVGGElement, unknown>).call(drag); 175 + 176 + const label = g 177 + .append('g') 178 + .selectAll('text') 179 + .data(nodes) 180 + .join('text') 181 + .text((d) => d.label) 182 + .attr('font-size', (d) => (d.type === 'phi' ? 13 : 10)) 183 + .attr('font-family', "'SF Mono', monospace") 184 + .attr('fill', 'var(--text-muted)') 185 + .attr('text-anchor', 'middle') 186 + .attr('dy', (d) => RADII[d.type] + 14); 187 + 188 + simulation.on('tick', () => { 189 + link 190 + .attr('x1', (d) => (d.source as unknown as SimNode).x!) 191 + .attr('y1', (d) => (d.source as unknown as SimNode).y!) 192 + .attr('x2', (d) => (d.target as unknown as SimNode).x!) 193 + .attr('y2', (d) => (d.target as unknown as SimNode).y!); 194 + node.attr('cx', (d) => d.x!).attr('cy', (d) => d.y!); 195 + label.attr('x', (d) => d.x!).attr('y', (d) => d.y!); 196 + }); 197 + } 198 + 199 + onMount(async () => { 200 + try { 201 + const data = await getMemoryGraph(); 202 + loading = false; 203 + if (data.nodes.length === 0) return; 204 + const avatars = await fetchAvatars(data.nodes); 205 + render(data, avatars); 206 + } catch (e) { 207 + err = (e as Error).message; 208 + loading = false; 209 + } 210 + }); 211 + </script> 212 + 213 + <div class="wrap" bind:this={container}> 214 + {#if loading} 215 + <div class="overlay faint">loading graph…</div> 216 + {:else if err} 217 + <div class="overlay faint">failed to load: {err}</div> 218 + {/if} 219 + </div> 220 + 221 + <style> 222 + .wrap { 223 + position: relative; 224 + width: 100%; 225 + height: 70vh; 226 + background: var(--bg); 227 + border: 1px solid var(--border); 228 + border-radius: 8px; 229 + overflow: hidden; 230 + } 231 + 232 + .overlay { 233 + position: absolute; 234 + inset: 0; 235 + display: flex; 236 + align-items: center; 237 + justify-content: center; 238 + font-size: 13px; 239 + } 240 + 241 + :global(.tooltip) { 242 + position: absolute; 243 + padding: 8px 12px; 244 + background: var(--bg-elev); 245 + border: 1px solid var(--border); 246 + border-radius: 6px; 247 + font-size: 13px; 248 + pointer-events: none; 249 + opacity: 0; 250 + color: var(--text); 251 + max-width: 280px; 252 + z-index: 100; 253 + } 254 + </style>
+56
web/src/lib/components/Nav.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + 4 + const links = [ 5 + { href: '/', label: 'phi' }, 6 + { href: '/feed', label: 'feed' }, 7 + { href: '/mind', label: 'mind' }, 8 + { href: '/blog', label: 'blog' }, 9 + { href: '/discovery', label: 'discovery' }, 10 + { href: '/skills', label: 'skills' }, 11 + { href: '/status', label: 'status' } 12 + ]; 13 + </script> 14 + 15 + <nav> 16 + <div class="nav-inner"> 17 + {#each links as link (link.href)} 18 + {@const active = 19 + link.href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(link.href)} 20 + <a href={link.href} class:active>{link.label}</a> 21 + {/each} 22 + </div> 23 + </nav> 24 + 25 + <style> 26 + nav { 27 + border-bottom: 1px solid var(--border); 28 + background: var(--bg); 29 + position: sticky; 30 + top: 0; 31 + z-index: 10; 32 + } 33 + 34 + .nav-inner { 35 + max-width: 760px; 36 + margin: 0 auto; 37 + padding: 12px 20px; 38 + display: flex; 39 + gap: 18px; 40 + font-size: 13px; 41 + flex-wrap: wrap; 42 + } 43 + 44 + a { 45 + color: var(--text-muted); 46 + text-decoration: none; 47 + } 48 + 49 + a:hover { 50 + color: var(--text); 51 + } 52 + 53 + a.active { 54 + color: var(--accent-blue); 55 + } 56 + </style>
+63
web/src/lib/components/ObservationCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Observation } from '$lib/types'; 3 + import { relativeWhen } from '$lib/time'; 4 + 5 + interface Props { 6 + observation: Observation; 7 + } 8 + 9 + let { observation }: Props = $props(); 10 + 11 + const age = $derived(relativeWhen(observation.created_at)); 12 + </script> 13 + 14 + <div class="card"> 15 + <div class="content">{observation.content}</div> 16 + {#if observation.reasoning} 17 + <div class="reasoning"> 18 + <span class="label">reasoning</span> 19 + <span>{observation.reasoning}</span> 20 + </div> 21 + {/if} 22 + <div class="meta faint"> 23 + {#if age}<span>{age}</span>{/if} 24 + <span class="mono">rkey {observation.rkey}</span> 25 + </div> 26 + </div> 27 + 28 + <style> 29 + .card { 30 + border-left-color: var(--accent-yellow); 31 + } 32 + 33 + .content { 34 + font-size: 14px; 35 + color: var(--text); 36 + margin-bottom: 8px; 37 + white-space: pre-wrap; 38 + } 39 + 40 + .reasoning { 41 + font-size: 13px; 42 + color: var(--text-muted); 43 + margin-bottom: 8px; 44 + padding: 6px 10px; 45 + background: rgba(210, 153, 34, 0.06); 46 + border-radius: 4px; 47 + } 48 + 49 + .label { 50 + display: inline-block; 51 + text-transform: uppercase; 52 + letter-spacing: 0.4px; 53 + font-size: 10px; 54 + color: var(--accent-yellow); 55 + margin-right: 8px; 56 + } 57 + 58 + .meta { 59 + font-size: 11px; 60 + display: flex; 61 + gap: 12px; 62 + } 63 + </style>
+123
web/src/lib/components/PostCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ActivityItem } from '$lib/types'; 3 + import { relativeWhen } from '$lib/time'; 4 + 5 + interface Props { 6 + item: ActivityItem; 7 + } 8 + 9 + let { item }: Props = $props(); 10 + 11 + const ACCENT: Record<ActivityItem['type'], string> = { 12 + post: 'var(--accent-blue)', 13 + note: 'var(--accent-purple)', 14 + url: 'var(--accent-green)' 15 + }; 16 + 17 + const LABEL: Record<ActivityItem['type'], string> = { 18 + post: 'bluesky', 19 + note: 'note', 20 + url: 'bookmark' 21 + }; 22 + 23 + function linkify(text: string): string { 24 + return text.replace( 25 + /(https?:\/\/[^\s<>"]+)/g, 26 + '<a href="$1" target="_blank" rel="noopener">$1</a>' 27 + ); 28 + } 29 + 30 + function getDomain(url: string | null | undefined): string { 31 + if (!url) return ''; 32 + try { 33 + return new URL(url).hostname.replace(/^www\./, ''); 34 + } catch { 35 + return ''; 36 + } 37 + } 38 + 39 + function viewUrl(item: ActivityItem): string { 40 + if (item.url) return item.url; 41 + if (item.uri.startsWith('at://')) return `https://pds.ls/${item.uri}`; 42 + return ''; 43 + } 44 + 45 + const accent = $derived(ACCENT[item.type]); 46 + const label = $derived(LABEL[item.type]); 47 + const age = $derived(relativeWhen(item.time)); 48 + const domain = $derived(item.type === 'url' ? getDomain(item.url) : ''); 49 + const link = $derived(viewUrl(item)); 50 + const text = $derived(item.text.length > 300 ? item.text.slice(0, 300) + '…' : item.text); 51 + </script> 52 + 53 + <div class="card" style="border-left-color: {accent}"> 54 + <div class="header"> 55 + <span class="type" style="color: {accent}">{label}</span> 56 + </div> 57 + {#if domain} 58 + <div class="domain"> 59 + <a href={item.url} target="_blank" rel="noopener">{domain}</a> 60 + </div> 61 + {/if} 62 + {#if item.title} 63 + <div class="title">{item.title}</div> 64 + {/if} 65 + <!-- eslint-disable-next-line svelte/no-at-html-tags --> 66 + <div class="text">{@html linkify(text)}</div> 67 + <div class="meta faint"> 68 + {#if age}<span>{age}</span>{/if} 69 + {#if link} 70 + <span>·</span> 71 + <a href={link} target="_blank" rel="noopener">view</a> 72 + {/if} 73 + </div> 74 + </div> 75 + 76 + <style> 77 + .header { 78 + margin-bottom: 6px; 79 + } 80 + 81 + .type { 82 + font-size: 11px; 83 + text-transform: uppercase; 84 + letter-spacing: 0.5px; 85 + } 86 + 87 + .domain { 88 + font-size: 12px; 89 + margin-bottom: 6px; 90 + } 91 + 92 + .domain a { 93 + color: var(--text-muted); 94 + } 95 + 96 + .title { 97 + font-size: 14px; 98 + color: var(--text); 99 + margin-bottom: 4px; 100 + } 101 + 102 + .text { 103 + font-size: 14px; 104 + line-height: 1.5; 105 + margin-bottom: 8px; 106 + white-space: pre-wrap; 107 + word-break: break-word; 108 + } 109 + 110 + .meta { 111 + font-size: 12px; 112 + display: flex; 113 + gap: 6px; 114 + } 115 + 116 + .meta a { 117 + color: var(--text-faint); 118 + } 119 + 120 + .meta a:hover { 121 + color: var(--text-muted); 122 + } 123 + </style>
+46
web/src/lib/components/StatusPill.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getHealth } from '$lib/api'; 4 + import type { HealthInfo } from '$lib/types'; 5 + 6 + let health = $state<HealthInfo | null>(null); 7 + let failed = $state(false); 8 + 9 + onMount(async () => { 10 + try { 11 + health = await getHealth(); 12 + } catch { 13 + failed = true; 14 + } 15 + }); 16 + 17 + const status = $derived.by(() => { 18 + if (failed) return { color: 'var(--accent-red)', text: 'unreachable' }; 19 + if (!health) return { color: 'var(--text-faint)', text: '…' }; 20 + if (health.paused) return { color: 'var(--accent-yellow)', text: 'paused' }; 21 + if (health.polling_active) return { color: 'var(--accent-green)', text: 'online' }; 22 + return { color: 'var(--text-faint)', text: 'offline' }; 23 + }); 24 + </script> 25 + 26 + <span class="pill"> 27 + <span class="dot" style="background: {status.color}"></span> 28 + <span class="text">{status.text}</span> 29 + </span> 30 + 31 + <style> 32 + .pill { 33 + display: inline-flex; 34 + align-items: center; 35 + gap: 6px; 36 + font-size: 12px; 37 + color: var(--text-muted); 38 + } 39 + 40 + .dot { 41 + display: inline-block; 42 + width: 8px; 43 + height: 8px; 44 + border-radius: 50%; 45 + } 46 + </style>
+22
web/src/lib/time.ts
··· 1 + // Mirror of bot/utils/time.py:relative_when — same granularity slide. 2 + // Renders an ISO timestamp as 'Ns/m/h/d/mo/y ago'. 3 + 4 + export function relativeWhen(iso: string | null | undefined): string { 5 + if (!iso) return ''; 6 + const ts = Date.parse(iso); 7 + if (Number.isNaN(ts)) return ''; 8 + const delta = (Date.now() - ts) / 1000; 9 + if (delta < 0) return ''; 10 + if (delta < 60) return `${Math.floor(delta)}s ago`; 11 + if (delta < 3600) return `${Math.floor(delta / 60)}m ago`; 12 + if (delta < 86400) { 13 + const h = delta / 3600; 14 + return h < 10 ? `${h.toFixed(1)}h ago` : `${Math.floor(h)}h ago`; 15 + } 16 + const days = delta / 86400; 17 + if (days < 30) { 18 + return days < 10 ? `${days.toFixed(1)}d ago` : `${Math.floor(days)}d ago`; 19 + } 20 + if (days < 365) return `${Math.floor(days / 30)}mo ago`; 21 + return `${Math.floor(days / 365)}y ago`; 22 + }
+120
web/src/lib/types.ts
··· 1 + // Shape mirrors phi's PDS record schemas + bot API responses. 2 + // Keep in sync with bot/src/bot/core/{goals,observations}.py and the json 3 + // returned by /api/* endpoints. 4 + 5 + // --- PDS records --- 6 + 7 + export interface Goal { 8 + rkey: string; 9 + title: string; 10 + description: string; 11 + progress_signal: string; 12 + created_at: string; 13 + updated_at: string; 14 + } 15 + 16 + export interface Observation { 17 + rkey: string; 18 + content: string; 19 + reasoning: string; 20 + created_at: string; 21 + } 22 + 23 + export interface BlogDoc { 24 + rkey: string; 25 + title: string; 26 + content: string; 27 + tags: string[]; 28 + publishedAt: string; 29 + url: string; // greengale.app URL 30 + } 31 + 32 + // --- /api/activity (existing endpoint, mixed feed) --- 33 + 34 + export type ActivityType = 'post' | 'note' | 'url'; 35 + 36 + export interface ActivityItem { 37 + type: ActivityType; 38 + text: string; 39 + title?: string | null; 40 + time: string; 41 + uri: string; 42 + url?: string | null; 43 + } 44 + 45 + // --- /api/memory/graph --- 46 + 47 + export interface GraphNode { 48 + id: string; 49 + label: string; 50 + type: 'phi' | 'user'; 51 + x: number | null; 52 + y: number | null; 53 + } 54 + 55 + export interface GraphEdge { 56 + source: string; 57 + target: string; 58 + } 59 + 60 + export interface GraphData { 61 + nodes: GraphNode[]; 62 + edges: GraphEdge[]; 63 + } 64 + 65 + // --- discovery pool (hub /api/agents/discovery-pool) --- 66 + 67 + export interface DiscoveryPost { 68 + uri: string; 69 + text: string; 70 + liked_at: string; 71 + } 72 + 73 + export interface DiscoveryEntry { 74 + handle: string; 75 + did: string; 76 + likes_in_window: number; 77 + last_liked_at: string; 78 + sample_posts: DiscoveryPost[]; 79 + } 80 + 81 + // --- /health --- 82 + 83 + export interface HealthInfo { 84 + status: string; 85 + polling_active: boolean; 86 + paused: boolean; 87 + } 88 + 89 + // --- bsky public API minimal types (used by feed/blog) --- 90 + 91 + export interface BskyAuthor { 92 + did: string; 93 + handle: string; 94 + displayName?: string; 95 + avatar?: string; 96 + } 97 + 98 + export interface BskyPostRecord { 99 + text: string; 100 + createdAt: string; 101 + reply?: { parent: { uri: string; cid: string }; root: { uri: string; cid: string } }; 102 + facets?: unknown[]; 103 + embed?: unknown; 104 + } 105 + 106 + export interface BskyPost { 107 + uri: string; 108 + cid: string; 109 + author: BskyAuthor; 110 + record: BskyPostRecord; 111 + indexedAt: string; 112 + likeCount?: number; 113 + replyCount?: number; 114 + repostCount?: number; 115 + } 116 + 117 + export interface BskyFeedItem { 118 + post: BskyPost; 119 + reply?: { parent?: { author?: BskyAuthor; record?: BskyPostRecord; uri?: string } }; 120 + }
+13
web/src/routes/+layout.svelte
··· 1 + <script lang="ts"> 2 + import '../app.css'; 3 + import Nav from '$lib/components/Nav.svelte'; 4 + 5 + interface Props { 6 + children?: import('svelte').Snippet; 7 + } 8 + 9 + let { children }: Props = $props(); 10 + </script> 11 + 12 + <Nav /> 13 + {@render children?.()}
+4
web/src/routes/+layout.ts
··· 1 + // Disable SSR — adapter-static would otherwise try to prerender every 2 + // route, including those that fetch from runtime APIs. Pure SPA. 3 + export const ssr = false; 4 + export const prerender = false;
+142
web/src/routes/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import StatusPill from '$lib/components/StatusPill.svelte'; 4 + import GoalCard from '$lib/components/GoalCard.svelte'; 5 + import ObservationCard from '$lib/components/ObservationCard.svelte'; 6 + import PostCard from '$lib/components/PostCard.svelte'; 7 + import { getActivity, getActiveObservations, getGoals, PHI_HANDLE } from '$lib/api'; 8 + import type { ActivityItem, Goal, Observation } from '$lib/types'; 9 + 10 + let goals = $state<Goal[]>([]); 11 + let observations = $state<Observation[]>([]); 12 + let recent = $state<ActivityItem[]>([]); 13 + let loaded = $state(false); 14 + 15 + onMount(async () => { 16 + const [g, o, a] = await Promise.allSettled([ 17 + getGoals(), 18 + getActiveObservations(), 19 + getActivity() 20 + ]); 21 + if (g.status === 'fulfilled') goals = g.value; 22 + if (o.status === 'fulfilled') observations = o.value; 23 + if (a.status === 'fulfilled') recent = a.value.slice(0, 5); 24 + loaded = true; 25 + }); 26 + </script> 27 + 28 + <div class="container"> 29 + <header> 30 + <h1>phi</h1> 31 + <div class="sub"> 32 + <StatusPill /> 33 + <span>·</span> 34 + <a href="https://bsky.app/profile/{PHI_HANDLE}" target="_blank" rel="noopener" 35 + >@{PHI_HANDLE}</a 36 + > 37 + </div> 38 + <p class="desc muted"> 39 + a bluesky bot. small attention pool, durable goals, episodic memory, scout for community 40 + infrastructure. 41 + </p> 42 + </header> 43 + 44 + <section> 45 + <h2>active observations</h2> 46 + <p class="hint faint"> 47 + what phi is currently attending to. small set, mutates often, archived after. 48 + </p> 49 + {#if !loaded} 50 + <p class="faint">loading…</p> 51 + {:else if observations.length === 0} 52 + <p class="faint">nothing in the active pool right now.</p> 53 + {:else} 54 + {#each observations as obs (obs.rkey)} 55 + <ObservationCard observation={obs} /> 56 + {/each} 57 + {/if} 58 + </section> 59 + 60 + <section> 61 + <h2>goals</h2> 62 + <p class="hint faint">durable anchors. mutated through owner-approval (like-as-auth gate).</p> 63 + {#if !loaded} 64 + <p class="faint">loading…</p> 65 + {:else if goals.length === 0} 66 + <p class="faint">no goals set.</p> 67 + {:else} 68 + {#each goals as goal (goal.rkey)} 69 + <GoalCard {goal} /> 70 + {/each} 71 + {/if} 72 + </section> 73 + 74 + <section> 75 + <div class="section-header"> 76 + <h2>recent activity</h2> 77 + <a href="/feed" class="more">see all →</a> 78 + </div> 79 + {#if !loaded} 80 + <p class="faint">loading…</p> 81 + {:else if recent.length === 0} 82 + <p class="faint">nothing recent.</p> 83 + {:else} 84 + {#each recent as item (item.uri)} 85 + <PostCard {item} /> 86 + {/each} 87 + {/if} 88 + </section> 89 + </div> 90 + 91 + <style> 92 + header { 93 + margin-bottom: 36px; 94 + } 95 + 96 + .sub { 97 + display: flex; 98 + align-items: center; 99 + gap: 8px; 100 + font-size: 13px; 101 + color: var(--text-muted); 102 + margin-top: 4px; 103 + } 104 + 105 + .desc { 106 + font-size: 14px; 107 + line-height: 1.6; 108 + margin-top: 16px; 109 + max-width: 540px; 110 + } 111 + 112 + section { 113 + margin-bottom: 36px; 114 + } 115 + 116 + .section-header { 117 + display: flex; 118 + align-items: baseline; 119 + justify-content: space-between; 120 + margin-bottom: 6px; 121 + } 122 + 123 + .section-header h2 { 124 + margin-bottom: 0; 125 + } 126 + 127 + .more { 128 + font-size: 12px; 129 + color: var(--text-muted); 130 + } 131 + 132 + .hint { 133 + font-size: 12px; 134 + margin-top: 0; 135 + margin-bottom: 16px; 136 + max-width: 540px; 137 + } 138 + 139 + h2 { 140 + margin-bottom: 6px; 141 + } 142 + </style>
+44
web/src/routes/blog/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import BlogCard from '$lib/components/BlogCard.svelte'; 4 + import { getBlogDocs } from '$lib/api'; 5 + import type { BlogDoc } from '$lib/types'; 6 + 7 + let docs = $state<BlogDoc[]>([]); 8 + let loaded = $state(false); 9 + let err = $state<string | null>(null); 10 + 11 + onMount(async () => { 12 + try { 13 + docs = await getBlogDocs(); 14 + } catch (e) { 15 + err = (e as Error).message; 16 + } 17 + loaded = true; 18 + }); 19 + </script> 20 + 21 + <div class="container"> 22 + <header> 23 + <h1>blog</h1> 24 + <p class="muted">long-form posts published to greengale.app.</p> 25 + </header> 26 + 27 + {#if !loaded} 28 + <p class="faint">loading…</p> 29 + {:else if err} 30 + <p class="faint">failed to load: {err}</p> 31 + {:else if docs.length === 0} 32 + <p class="faint">no posts yet.</p> 33 + {:else} 34 + {#each docs as doc (doc.rkey)} 35 + <BlogCard {doc} /> 36 + {/each} 37 + {/if} 38 + </div> 39 + 40 + <style> 41 + header { 42 + margin-bottom: 24px; 43 + } 44 + </style>
+46
web/src/routes/discovery/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import DiscoveryCard from '$lib/components/DiscoveryCard.svelte'; 4 + import { getDiscoveryPool, OWNER_HANDLE } from '$lib/api'; 5 + import type { DiscoveryEntry } from '$lib/types'; 6 + 7 + let entries = $state<DiscoveryEntry[]>([]); 8 + let loaded = $state(false); 9 + 10 + onMount(async () => { 11 + entries = await getDiscoveryPool(); 12 + loaded = true; 13 + }); 14 + </script> 15 + 16 + <div class="container"> 17 + <header> 18 + <h1>discovery</h1> 19 + <p class="muted"> 20 + authors @{OWNER_HANDLE} has been liking lately. high-signal pool of attention. phi sees a 21 + filtered version of this in her own context (with people she's already exchanged with removed). 22 + </p> 23 + </header> 24 + 25 + {#if !loaded} 26 + <p class="faint">loading…</p> 27 + {:else if entries.length === 0} 28 + <p class="faint">no recent activity to show.</p> 29 + {:else} 30 + {#each entries as entry (entry.did)} 31 + <DiscoveryCard {entry} /> 32 + {/each} 33 + {/if} 34 + </div> 35 + 36 + <style> 37 + header { 38 + margin-bottom: 24px; 39 + } 40 + 41 + header p { 42 + max-width: 600px; 43 + font-size: 13px; 44 + line-height: 1.5; 45 + } 46 + </style>
+74
web/src/routes/feed/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import PostCard from '$lib/components/PostCard.svelte'; 4 + import { getActivity } from '$lib/api'; 5 + import type { ActivityItem, ActivityType } from '$lib/types'; 6 + 7 + let items = $state<ActivityItem[]>([]); 8 + let loaded = $state(false); 9 + let err = $state<string | null>(null); 10 + let filter = $state<ActivityType | 'all'>('all'); 11 + 12 + onMount(async () => { 13 + try { 14 + items = await getActivity(); 15 + } catch (e) { 16 + err = (e as Error).message; 17 + } 18 + loaded = true; 19 + }); 20 + 21 + const filtered = $derived(filter === 'all' ? items : items.filter((i) => i.type === filter)); 22 + 23 + const FILTERS: { value: ActivityType | 'all'; label: string }[] = [ 24 + { value: 'all', label: 'all' }, 25 + { value: 'post', label: 'posts' }, 26 + { value: 'note', label: 'notes' }, 27 + { value: 'url', label: 'bookmarks' } 28 + ]; 29 + </script> 30 + 31 + <div class="container"> 32 + <header> 33 + <h1>feed</h1> 34 + <p class="muted">phi's recent posts, notes, and bookmarked URLs across surfaces.</p> 35 + </header> 36 + 37 + <div class="filters"> 38 + {#each FILTERS as f (f.value)} 39 + <button class:active={filter === f.value} onclick={() => (filter = f.value)}> 40 + {f.label} 41 + </button> 42 + {/each} 43 + </div> 44 + 45 + {#if !loaded} 46 + <p class="faint">loading…</p> 47 + {:else if err} 48 + <p class="faint">failed to load: {err}</p> 49 + {:else if filtered.length === 0} 50 + <p class="faint">nothing to show.</p> 51 + {:else} 52 + {#each filtered as item (item.uri)} 53 + <PostCard {item} /> 54 + {/each} 55 + {/if} 56 + </div> 57 + 58 + <style> 59 + header { 60 + margin-bottom: 24px; 61 + } 62 + 63 + .filters { 64 + display: flex; 65 + gap: 8px; 66 + margin-bottom: 20px; 67 + flex-wrap: wrap; 68 + } 69 + 70 + button.active { 71 + border-color: var(--accent-blue); 72 + color: var(--accent-blue); 73 + } 74 + </style>
+49
web/src/routes/mind/+page.svelte
··· 1 + <script lang="ts"> 2 + import MemoryGraph from '$lib/components/MemoryGraph.svelte'; 3 + </script> 4 + 5 + <div class="container"> 6 + <header> 7 + <h1>mind</h1> 8 + <p class="muted"> 9 + phi's social memory — handles she's exchanged with, positioned by semantic similarity of her 10 + observations about them. drag, zoom, hover. 11 + </p> 12 + </header> 13 + 14 + <MemoryGraph /> 15 + 16 + <section class="future"> 17 + <h2>coming next</h2> 18 + <ul class="muted"> 19 + <li>per-handle observation browser (search by handle, see what phi knows + citations + ages)</li> 20 + <li>archive search across past observations and aged-out attention items</li> 21 + </ul> 22 + </section> 23 + </div> 24 + 25 + <style> 26 + header { 27 + margin-bottom: 24px; 28 + } 29 + 30 + header p { 31 + max-width: 600px; 32 + font-size: 13px; 33 + line-height: 1.5; 34 + } 35 + 36 + .future { 37 + margin-top: 32px; 38 + font-size: 13px; 39 + } 40 + 41 + .future ul { 42 + padding-left: 20px; 43 + line-height: 1.6; 44 + } 45 + 46 + .future li { 47 + margin-bottom: 4px; 48 + } 49 + </style>
+58
web/src/routes/skills/+page.svelte
··· 1 + <script lang="ts"> 2 + // skills are static, defined in bot/skills/ at build time. for now we 3 + // inline the descriptions; later we could expose a backend endpoint that 4 + // scans the skills/ directory at runtime if the set grows beyond a few. 5 + 6 + const skills = [ 7 + { 8 + name: 'publish-blog', 9 + description: 10 + "Publish a long-form post on greengale.app. Use when a thought needs more space than a bluesky thread — multi-part essays, syntheses of a conversation you've been in, worked examples. For single observations use post instead; for a URL + commentary use note or save_url." 11 + } 12 + ]; 13 + </script> 14 + 15 + <div class="container"> 16 + <header> 17 + <h1>skills</h1> 18 + <p class="muted"> 19 + progressive-disclosure capability packs phi can load on demand. each is a description (always 20 + visible to phi) plus a body she pulls in via load_skill when relevant. 21 + </p> 22 + </header> 23 + 24 + {#each skills as skill (skill.name)} 25 + <div class="card"> 26 + <div class="name mono">{skill.name}</div> 27 + <div class="desc">{skill.description}</div> 28 + </div> 29 + {/each} 30 + </div> 31 + 32 + <style> 33 + header { 34 + margin-bottom: 24px; 35 + } 36 + 37 + header p { 38 + max-width: 600px; 39 + font-size: 13px; 40 + line-height: 1.5; 41 + } 42 + 43 + .card { 44 + border-left-color: var(--accent-purple); 45 + } 46 + 47 + .name { 48 + font-size: 14px; 49 + color: var(--accent-purple); 50 + margin-bottom: 8px; 51 + } 52 + 53 + .desc { 54 + font-size: 13px; 55 + line-height: 1.6; 56 + color: var(--text-muted); 57 + } 58 + </style>
+84
web/src/routes/status/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { getHealth, PHI_HANDLE } from '$lib/api'; 4 + import type { HealthInfo } from '$lib/types'; 5 + 6 + let health = $state<HealthInfo | null>(null); 7 + let err = $state<string | null>(null); 8 + 9 + onMount(async () => { 10 + try { 11 + health = await getHealth(); 12 + } catch (e) { 13 + err = (e as Error).message; 14 + } 15 + }); 16 + </script> 17 + 18 + <div class="container"> 19 + <header> 20 + <h1>status</h1> 21 + <p class="muted">runtime health of the phi process. operational view.</p> 22 + </header> 23 + 24 + {#if err} 25 + <p class="faint">unreachable: {err}</p> 26 + {:else if !health} 27 + <p class="faint">loading…</p> 28 + {:else} 29 + <div class="grid"> 30 + <div class="metric"> 31 + <div class="value" style="color: {health.polling_active ? 'var(--accent-green)' : 'var(--text-faint)'}"> 32 + {health.polling_active ? 'online' : 'offline'} 33 + </div> 34 + <div class="label">status</div> 35 + </div> 36 + <div class="metric"> 37 + <div class="value" style="color: {health.paused ? 'var(--accent-yellow)' : 'var(--text)'}"> 38 + {health.paused ? 'yes' : 'no'} 39 + </div> 40 + <div class="label">paused</div> 41 + </div> 42 + <div class="metric"> 43 + <div class="value">{health.status}</div> 44 + <div class="label">health</div> 45 + </div> 46 + <div class="metric"> 47 + <div class="value">@{PHI_HANDLE}</div> 48 + <div class="label">handle</div> 49 + </div> 50 + </div> 51 + {/if} 52 + </div> 53 + 54 + <style> 55 + header { 56 + margin-bottom: 24px; 57 + } 58 + 59 + .grid { 60 + display: grid; 61 + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); 62 + gap: 10px; 63 + } 64 + 65 + .metric { 66 + background: var(--bg-elev); 67 + border-radius: 8px; 68 + padding: 16px; 69 + } 70 + 71 + .value { 72 + font-size: 18px; 73 + color: var(--text); 74 + margin-bottom: 4px; 75 + word-break: break-word; 76 + } 77 + 78 + .label { 79 + font-size: 11px; 80 + text-transform: uppercase; 81 + letter-spacing: 0.4px; 82 + color: var(--text-muted); 83 + } 84 + </style>
+1
web/static/favicon.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M2 16 L8 16 L11 6 L16 26 L21 10 L24 16 L30 16" fill="none" stroke="#58a6ff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
+18
web/svelte.config.js
··· 1 + import adapter from '@sveltejs/adapter-static'; 2 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 + 4 + /** @type {import('@sveltejs/kit').Config} */ 5 + const config = { 6 + preprocess: vitePreprocess(), 7 + kit: { 8 + adapter: adapter({ 9 + pages: 'build', 10 + assets: 'build', 11 + fallback: 'index.html', 12 + precompress: false, 13 + strict: true 14 + }) 15 + } 16 + }; 17 + 18 + export default config;
+14
web/tsconfig.json
··· 1 + { 2 + "extends": "./.svelte-kit/tsconfig.json", 3 + "compilerOptions": { 4 + "allowJs": true, 5 + "checkJs": true, 6 + "esModuleInterop": true, 7 + "forceConsistentCasingInFileNames": true, 8 + "resolveJsonModule": true, 9 + "skipLibCheck": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "moduleResolution": "bundler" 13 + } 14 + }
+13
web/vite.config.ts
··· 1 + import { sveltekit } from '@sveltejs/kit/vite'; 2 + import { defineConfig } from 'vite'; 3 + 4 + export default defineConfig({ 5 + plugins: [sveltekit()], 6 + server: { 7 + // proxy bot's /api/* in dev so we can use relative URLs in fetch() 8 + proxy: { 9 + '/api': 'http://localhost:8000', 10 + '/health': 'http://localhost:8000' 11 + } 12 + } 13 + });