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.

memory graph visualization, persistent status, extraction prompt fix

- add /memory page with D3 force-directed graph (phi, users, tags, episodic)
- add /api/memory/graph JSON endpoint
- restyle all pages: monospace font, consistent nav, lowercase aesthetic
- persist status counters to /data/status.json on fly volume
- rewrite extraction prompt: examples-based approach fixes misattribution
of bot statements to users (3/3 regression tests pass)
- add phi.zzstoatzz.io custom domain (fly cert + DNS)

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

zzstoatzz 1b75a7a3 8e865648

+469 -99
+4
fly.toml
··· 7 7 [deploy] 8 8 strategy = "rolling" 9 9 10 + [mounts] 11 + source = "phi_data" 12 + destination = "/data" 13 + 10 14 [[vm]] 11 15 size = "shared-cpu-1x" 12 16 memory = "512mb"
+248 -75
src/bot/main.py
··· 1 1 """FastAPI application for phi.""" 2 2 3 + import asyncio 3 4 import logging 4 5 from contextlib import asynccontextmanager 5 6 from datetime import datetime 6 7 7 8 import logfire 8 9 from fastapi import FastAPI 9 - from fastapi.responses import HTMLResponse 10 + from fastapi.responses import HTMLResponse, JSONResponse 10 11 11 12 from bot.config import settings 12 13 from bot.core.atproto_client import bot_client 13 14 from bot.core.profile_manager import ProfileManager 14 15 from bot.logging_config import _clear_uvicorn_handlers 16 + from bot.memory import NamespaceMemory 15 17 from bot.services.notification_poller import NotificationPoller 16 18 from bot.status import bot_status 17 19 ··· 65 67 logfire.instrument_fastapi(app) 66 68 67 69 68 - @app.get("/") 70 + NAV_HTML = '<nav><a href="/">phi</a><a href="/status">status</a><a href="/memory">memory</a></nav>' 71 + 72 + BASE_STYLE = """ 73 + * { margin: 0; padding: 0; box-sizing: border-box; } 74 + body { 75 + font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; 76 + background: #0d1117; color: #c9d1d9; 77 + } 78 + nav { 79 + padding: 12px 20px; 80 + border-bottom: 1px solid #30363d; 81 + background: #0d1117; 82 + } 83 + nav a { 84 + color: #8b949e; text-decoration: none; margin-right: 20px; 85 + font-size: 13px; letter-spacing: 0.5px; 86 + } 87 + nav a:hover { color: #c9d1d9; } 88 + .container { max-width: 720px; margin: 0 auto; padding: 40px 20px; } 89 + a { color: #58a6ff; text-decoration: none; } 90 + a:hover { text-decoration: underline; } 91 + """ 92 + 93 + 94 + @app.get("/", response_class=HTMLResponse) 69 95 async def root(): 70 - """Root endpoint.""" 71 - return { 72 - "name": settings.bot_name, 73 - "status": "running", 74 - "handle": settings.bluesky_handle, 75 - "architecture": "mcp + episodic memory", 76 - } 96 + """Landing page.""" 97 + status = "online" if bot_status.polling_active else "offline" 98 + status_color = "#2ea043" if bot_status.polling_active else "#da3633" 99 + return f"""<!DOCTYPE html> 100 + <html><head><title>phi</title><style>{BASE_STYLE} 101 + h1 {{ font-size: 24px; font-weight: 300; margin-bottom: 8px; }} 102 + .subtitle {{ color: #8b949e; font-size: 13px; margin-bottom: 32px; }} 103 + .status-dot {{ 104 + display: inline-block; width: 8px; height: 8px; 105 + border-radius: 50%; margin-right: 6px; 106 + }} 107 + .links {{ margin-top: 24px; }} 108 + .links a {{ 109 + display: inline-block; color: #8b949e; margin-right: 20px; 110 + font-size: 13px; padding: 6px 0; 111 + }} 112 + .links a:hover {{ color: #c9d1d9; text-decoration: none; }} 113 + </style></head> 114 + <body> 115 + {NAV_HTML} 116 + <div class="container"> 117 + <h1>phi</h1> 118 + <div class="subtitle"> 119 + <span class="status-dot" style="background:{status_color}"></span>{status} 120 + &middot; <a href="https://bsky.app/profile/{settings.bluesky_handle}">@{settings.bluesky_handle}</a> 121 + </div> 122 + <p style="color:#8b949e;font-size:13px;line-height:1.6;max-width:480px"> 123 + bluesky bot with mcp tools and episodic memory. 124 + learns from conversations, remembers across sessions. 125 + </p> 126 + <div class="links"> 127 + <a href="/status">status</a> 128 + <a href="/memory">memory graph</a> 129 + <a href="/health">health</a> 130 + <a href="/docs">api docs</a> 131 + </div> 132 + </div> 133 + </body></html>""" 77 134 78 135 79 136 @app.get("/health") ··· 84 141 85 142 @app.get("/status", response_class=HTMLResponse) 86 143 async def status_page(): 87 - """Simple status page.""" 144 + """Status page.""" 88 145 89 146 def format_time_ago(timestamp): 90 147 if not timestamp: 91 - return "Never" 148 + return "never" 92 149 delta = (datetime.now() - timestamp).total_seconds() 93 150 if delta < 60: 94 151 return f"{int(delta)}s ago" ··· 97 154 else: 98 155 return f"{int(delta / 3600)}h ago" 99 156 100 - html = f""" 101 - <!DOCTYPE html> 102 - <html> 103 - <head> 104 - <title>{settings.bot_name} Status</title> 105 - <style> 106 - body {{ 107 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 108 - max-width: 800px; 109 - margin: 40px auto; 110 - padding: 20px; 111 - background: #0d1117; 112 - color: #c9d1d9; 113 - }} 114 - .status {{ 115 - padding: 20px; 116 - background: #161b22; 117 - border-radius: 6px; 118 - border: 1px solid #30363d; 119 - margin-bottom: 20px; 120 - }} 121 - .active {{ border-left: 4px solid #2ea043; }} 122 - .inactive {{ border-left: 4px solid #da3633; }} 123 - h1 {{ margin-top: 0; }} 124 - .metric {{ margin: 10px 0; }} 125 - .label {{ color: #8b949e; }} 126 - </style> 127 - </head> 128 - <body> 129 - <h1>{settings.bot_name}</h1> 130 - <div class="status {'active' if bot_status.polling_active else 'inactive'}"> 131 - <div class="metric"> 132 - <span class="label">Status:</span> 133 - <strong>{'Active' if bot_status.polling_active else 'Inactive'}</strong> 134 - </div> 135 - <div class="metric"> 136 - <span class="label">Handle:</span> @{settings.bluesky_handle} 137 - </div> 138 - <div class="metric"> 139 - <span class="label">Uptime:</span> {bot_status.uptime_str} 140 - </div> 141 - <div class="metric"> 142 - <span class="label">Mentions received:</span> {bot_status.mentions_received} 143 - </div> 144 - <div class="metric"> 145 - <span class="label">Responses sent:</span> {bot_status.responses_sent} 146 - </div> 147 - <div class="metric"> 148 - <span class="label">Last mention:</span> {format_time_ago(bot_status.last_mention_time)} 149 - </div> 150 - <div class="metric"> 151 - <span class="label">Last response:</span> {format_time_ago(bot_status.last_response_time)} 152 - </div> 153 - <div class="metric"> 154 - <span class="label">Errors:</span> {bot_status.errors} 155 - </div> 156 - <div class="metric"> 157 - <span class="label">Architecture:</span> MCP-enabled with episodic memory (TurboPuffer) 158 - </div> 159 - </div> 160 - </body> 161 - </html> 162 - """ 163 - return html 157 + active = bot_status.polling_active 158 + indicator = f'<span style="color:{"#2ea043" if active else "#da3633"}">{"active" if active else "inactive"}</span>' 159 + 160 + rows = [ 161 + ("status", indicator), 162 + ("handle", f"@{settings.bluesky_handle}"), 163 + ("uptime", bot_status.uptime_str), 164 + ("mentions", str(bot_status.mentions_received)), 165 + ("responses", str(bot_status.responses_sent)), 166 + ("last mention", format_time_ago(bot_status.last_mention_time)), 167 + ("last response", format_time_ago(bot_status.last_response_time)), 168 + ("errors", str(bot_status.errors)), 169 + ] 170 + metrics_html = "\n".join( 171 + f'<tr><td style="color:#8b949e;padding:6px 16px 6px 0">{k}</td><td>{v}</td></tr>' 172 + for k, v in rows 173 + ) 174 + 175 + return f"""<!DOCTYPE html> 176 + <html><head><title>phi &middot; status</title><style>{BASE_STYLE} 177 + h1 {{ font-size: 18px; font-weight: 400; margin-bottom: 24px; }} 178 + table {{ font-size: 13px; border-collapse: collapse; }} 179 + </style></head> 180 + <body> 181 + {NAV_HTML} 182 + <div class="container"> 183 + <h1>status</h1> 184 + <table>{metrics_html}</table> 185 + </div> 186 + </body></html>""" 187 + 188 + 189 + @app.get("/api/memory/graph") 190 + async def memory_graph_data(): 191 + """Return graph nodes and edges as JSON.""" 192 + try: 193 + memory = NamespaceMemory(api_key=settings.turbopuffer_api_key) 194 + loop = asyncio.get_event_loop() 195 + data = await loop.run_in_executor(None, memory.get_graph_data) 196 + return JSONResponse(data) 197 + except Exception as e: 198 + logger.warning(f"memory graph failed: {e}") 199 + return JSONResponse({"nodes": [], "edges": [], "error": str(e)}, status_code=500) 200 + 201 + 202 + @app.get("/memory", response_class=HTMLResponse) 203 + async def memory_page(): 204 + """Interactive memory graph visualization.""" 205 + return f"""<!DOCTYPE html> 206 + <html><head><title>phi &middot; memory</title> 207 + <script src="https://d3js.org/d3.v7.min.js"></script> 208 + <style>{BASE_STYLE} 209 + body {{ overflow: hidden; }} 210 + nav {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; }} 211 + #graph {{ width: 100vw; height: 100vh; }} 212 + .tooltip {{ 213 + position: absolute; padding: 6px 10px; 214 + background: #161b22; border: 1px solid #30363d; 215 + border-radius: 4px; font-size: 12px; 216 + pointer-events: none; opacity: 0; 217 + color: #c9d1d9; max-width: 280px; 218 + }} 219 + .legend {{ 220 + position: fixed; bottom: 16px; left: 16px; 221 + background: #161b22; border: 1px solid #30363d; 222 + border-radius: 4px; padding: 10px 14px; font-size: 11px; 223 + }} 224 + .legend-item {{ display: flex; align-items: center; margin: 3px 0; }} 225 + .legend-dot {{ 226 + width: 8px; height: 8px; border-radius: 50%; 227 + margin-right: 8px; flex-shrink: 0; 228 + }} 229 + #loading {{ 230 + position: fixed; top: 50%; left: 50%; 231 + transform: translate(-50%, -50%); 232 + color: #8b949e; font-size: 13px; 233 + }} 234 + </style></head> 235 + <body> 236 + {NAV_HTML} 237 + <div id="loading">loading...</div> 238 + <div id="graph"></div> 239 + <div class="tooltip" id="tooltip"></div> 240 + <div class="legend"> 241 + <div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div>phi</div> 242 + <div class="legend-item"><div class="legend-dot" style="background:#2ea043"></div>user</div> 243 + <div class="legend-item"><div class="legend-dot" style="background:#8b949e"></div>tag</div> 244 + <div class="legend-item"><div class="legend-dot" style="background:#a371f7"></div>episodic</div> 245 + </div> 246 + <script> 247 + const colors = {{ phi: '#58a6ff', user: '#2ea043', tag: '#8b949e', episodic: '#a371f7' }}; 248 + const radii = {{ phi: 14, user: 9, tag: 5, episodic: 7 }}; 249 + 250 + fetch('/api/memory/graph') 251 + .then(r => r.json()) 252 + .then(data => {{ 253 + document.getElementById('loading').remove(); 254 + if (!data.nodes.length) return; 255 + 256 + const width = window.innerWidth; 257 + const height = window.innerHeight; 258 + const tooltip = d3.select('#tooltip'); 259 + 260 + const svg = d3.select('#graph') 261 + .append('svg') 262 + .attr('width', width) 263 + .attr('height', height); 264 + 265 + const g = svg.append('g'); 266 + 267 + svg.call(d3.zoom() 268 + .scaleExtent([0.2, 5]) 269 + .on('zoom', e => g.attr('transform', e.transform))); 270 + 271 + const simulation = d3.forceSimulation(data.nodes) 272 + .force('link', d3.forceLink(data.edges).id(d => d.id).distance(80)) 273 + .force('charge', d3.forceManyBody().strength(-200)) 274 + .force('center', d3.forceCenter(width / 2, height / 2)) 275 + .force('collision', d3.forceCollide().radius(d => radii[d.type] + 4)); 276 + 277 + const link = g.append('g') 278 + .selectAll('line') 279 + .data(data.edges) 280 + .join('line') 281 + .attr('stroke', '#21262d') 282 + .attr('stroke-width', 1) 283 + .attr('stroke-opacity', 0.5); 284 + 285 + const node = g.append('g') 286 + .selectAll('circle') 287 + .data(data.nodes) 288 + .join('circle') 289 + .attr('r', d => radii[d.type]) 290 + .attr('fill', d => colors[d.type]) 291 + .attr('stroke', '#0d1117') 292 + .attr('stroke-width', 1.5) 293 + .style('cursor', 'grab') 294 + .call(d3.drag() 295 + .on('start', (e, d) => {{ 296 + if (!e.active) simulation.alphaTarget(0.3).restart(); 297 + d.fx = d.x; d.fy = d.y; 298 + }}) 299 + .on('drag', (e, d) => {{ d.fx = e.x; d.fy = e.y; }}) 300 + .on('end', (e, d) => {{ 301 + if (!e.active) simulation.alphaTarget(0); 302 + d.fx = null; d.fy = null; 303 + }})) 304 + .on('mouseover', (e, d) => {{ 305 + tooltip.style('opacity', 1) 306 + .html('<strong>' + d.label + '</strong><br><span style="color:' + colors[d.type] + '">' + d.type + '</span>'); 307 + }}) 308 + .on('mousemove', e => {{ 309 + tooltip.style('left', (e.pageX + 12) + 'px') 310 + .style('top', (e.pageY - 12) + 'px'); 311 + }}) 312 + .on('mouseout', () => tooltip.style('opacity', 0)); 313 + 314 + const label = g.append('g') 315 + .selectAll('text') 316 + .data(data.nodes.filter(d => d.type === 'phi' || d.type === 'user')) 317 + .join('text') 318 + .text(d => d.label) 319 + .attr('font-size', d => d.type === 'phi' ? 13 : 10) 320 + .attr('font-family', "'SF Mono', 'Cascadia Code', 'Fira Code', monospace") 321 + .attr('fill', '#8b949e') 322 + .attr('text-anchor', 'middle') 323 + .attr('dy', d => radii[d.type] + 14); 324 + 325 + simulation.on('tick', () => {{ 326 + link.attr('x1', d => d.source.x).attr('y1', d => d.source.y) 327 + .attr('x2', d => d.target.x).attr('y2', d => d.target.y); 328 + node.attr('cx', d => d.x).attr('cy', d => d.y); 329 + label.attr('x', d => d.x).attr('y', d => d.y); 330 + }}); 331 + }}) 332 + .catch(err => {{ 333 + document.getElementById('loading').textContent = 'failed to load: ' + err; 334 + }}); 335 + </script> 336 + </body></html>"""
+171 -17
src/bot/memory/namespace_memory.py
··· 1 1 """Namespace-based memory with structured observation extraction.""" 2 2 3 + import asyncio 3 4 import hashlib 4 5 import logging 5 6 from datetime import datetime 6 7 from typing import ClassVar 7 8 8 9 from openai import AsyncOpenAI 9 - from pydantic import BaseModel 10 + from pydantic import BaseModel, Field 10 11 from pydantic_ai import Agent 11 12 from turbopuffer import Turbopuffer 12 13 ··· 16 17 17 18 18 19 class Observation(BaseModel): 19 - """A single extracted fact about a user or conversation.""" 20 + """A single fact about the user, extracted from what the USER said or did.""" 20 21 21 - content: str # "interested in rust programming" 22 - tags: list[str] # ["interest", "programming"] 22 + content: str = Field(description="one atomic fact about the user, stated as a short sentence") 23 + tags: list[str] = Field(description="1-3 lowercase tags categorizing this fact") 23 24 24 25 25 26 class ExtractionResult(BaseModel): 26 - """Result of extracting observations from a conversation.""" 27 + """Observations extracted from a conversation. Empty list if nothing worth keeping.""" 27 28 28 29 observations: list[Observation] = [] 29 30 30 31 31 32 EXTRACTION_SYSTEM_PROMPT = """\ 32 - extract factual observations about the USER from this conversation exchange. 33 - focus on things the user explicitly stated or clearly demonstrated: 34 - - interests they expressed (not topics the bot brought up) 35 - - preferences, opinions, facts about themselves 36 - - what they asked about and WHY (e.g. "curious about current events" not the specific events listed) 37 - skip: 38 - - greetings, filler, things only meaningful in the moment 39 - - content the bot retrieved or generated (trending topics, search results, etc.) — those are NOT the user's interests 40 - - circumstantial details from bot tool output 41 - each observation should be a standalone fact useful in a future conversation. 42 - use short, lowercase tags. return an empty list if nothing is worth extracting. 43 - deduplicate against the existing observations provided.""" 33 + You extract facts about the USER from a conversation between a user and a bot. 34 + 35 + Only extract what the user explicitly said, asked, or demonstrated. The bot's statements, preferences, and actions are never observations about the user. 36 + 37 + <examples> 38 + <example> 39 + user: have you considered following anyone yet? 40 + bot: following one account currently — bsky.app itself. 41 + observations: [] 42 + reason: the user asked a question. the bot answered about itself. nothing here is about the user. 43 + </example> 44 + <example> 45 + user: can you delete that follow record? 46 + bot: deleted it — following nobody now. 47 + observations: [] 48 + reason: the user made a request to the bot. the bot performed the action. the user didn't delete anything. 49 + </example> 50 + <example> 51 + user: what do you think about the strait of hormuz situation? 52 + bot: trump considered a blockade, major shipping implications. 53 + observations: [{"content": "interested in geopolitical events around the strait of hormuz", "tags": ["interests", "geopolitics"]}] 54 + reason: the user asked about a specific topic, showing interest. the bot's answer content is not attributed to the user. 55 + </example> 56 + <example> 57 + user: i've been learning rust lately, it's been great for my systems work 58 + bot: rust is excellent for systems programming. 59 + observations: [{"content": "learning rust for systems programming", "tags": ["interests", "programming"]}] 60 + reason: the user stated something about themselves directly. 61 + </example> 62 + </examples> 63 + 64 + Deduplicate against existing observations provided in the prompt. Return an empty list when the exchange is just greetings, filler, or the user only asked questions without revealing anything about themselves.""" 44 65 45 66 _extraction_agent: Agent[None, ExtractionResult] | None = None 46 67 ··· 411 432 tags = f" [{', '.join(r['tags'])}]" if r.get("tags") else "" 412 433 lines.append(f"- {r['content']}{tags}") 413 434 return "\n".join(lines) 435 + 436 + async def search_unified(self, handle: str, query: str, top_k: int = 8) -> list[dict]: 437 + """Search both user namespace and episodic namespace concurrently.""" 438 + query_embedding = await self._get_embedding(query) 439 + 440 + user_ns = self.get_user_namespace(handle) 441 + loop = asyncio.get_event_loop() 442 + 443 + async def _search_user() -> list[dict]: 444 + try: 445 + response = await loop.run_in_executor( 446 + None, 447 + lambda: user_ns.query( 448 + rank_by=("vector", "ANN", query_embedding), 449 + top_k=top_k, 450 + include_attributes=["content", "kind", "tags", "created_at"], 451 + ), 452 + ) 453 + results = [] 454 + if response.rows: 455 + for row in response.rows: 456 + results.append({ 457 + "content": row.content, 458 + "kind": getattr(row, "kind", "unknown"), 459 + "tags": getattr(row, "tags", []), 460 + "created_at": getattr(row, "created_at", ""), 461 + "_source": "user", 462 + }) 463 + return results 464 + except Exception as e: 465 + if "was not found" in str(e): 466 + return [] 467 + logger.warning(f"unified search user namespace failed for @{handle}: {e}") 468 + return [] 469 + 470 + async def _search_episodic() -> list[dict]: 471 + try: 472 + response = await loop.run_in_executor( 473 + None, 474 + lambda: self.namespaces["episodic"].query( 475 + rank_by=("vector", "ANN", query_embedding), 476 + top_k=top_k, 477 + include_attributes=["content", "tags", "source", "created_at"], 478 + ), 479 + ) 480 + results = [] 481 + if response.rows: 482 + for row in response.rows: 483 + results.append({ 484 + "content": row.content, 485 + "tags": getattr(row, "tags", []), 486 + "source": getattr(row, "source", "unknown"), 487 + "created_at": getattr(row, "created_at", ""), 488 + "_source": "episodic", 489 + }) 490 + return results 491 + except Exception as e: 492 + if "was not found" in str(e): 493 + return [] 494 + logger.warning(f"unified search episodic namespace failed: {e}") 495 + return [] 496 + 497 + user_results, episodic_results = await asyncio.gather( 498 + _search_user(), _search_episodic() 499 + ) 500 + return user_results + episodic_results 501 + 502 + def get_graph_data(self) -> dict: 503 + """Build graph nodes and edges from memory namespaces (sync, no embeddings needed).""" 504 + nodes = [{"id": "phi", "label": "phi", "type": "phi"}] 505 + edges = [] 506 + tag_set: set[str] = set() 507 + user_tags: dict[str, set[str]] = {} # handle -> tags 508 + 509 + # discover user namespaces 510 + user_prefix = f"{self.NAMESPACES['users']}-" 511 + try: 512 + page = self.client.namespaces(prefix=user_prefix) 513 + for ns_summary in page.namespaces: 514 + handle = ns_summary.id.removeprefix(user_prefix).replace("_", ".") 515 + nodes.append({"id": f"user:{handle}", "label": f"@{handle}", "type": "user"}) 516 + edges.append({"source": "phi", "target": f"user:{handle}"}) 517 + 518 + # get observations for this user to extract tags 519 + user_ns = self.client.namespace(ns_summary.id) 520 + try: 521 + response = user_ns.query( 522 + rank_by=("vector", "ANN", [0.5] * 1536), 523 + top_k=50, 524 + filters={"kind": ["Eq", "observation"]}, 525 + include_attributes=["tags"], 526 + ) 527 + if response.rows: 528 + for row in response.rows: 529 + for tag in getattr(row, "tags", []) or []: 530 + tag_set.add(tag) 531 + user_tags.setdefault(handle, set()).add(tag) 532 + except Exception: 533 + pass # old namespace or no observations 534 + except Exception as e: 535 + logger.warning(f"failed to list user namespaces: {e}") 536 + 537 + # add tag nodes and user→tag edges 538 + for tag in tag_set: 539 + nodes.append({"id": f"tag:{tag}", "label": tag, "type": "tag"}) 540 + for handle, tags in user_tags.items(): 541 + for tag in tags: 542 + edges.append({"source": f"user:{handle}", "target": f"tag:{tag}"}) 543 + 544 + # episodic memories — group by top tags 545 + episodic_tags: set[str] = set() 546 + try: 547 + response = self.namespaces["episodic"].query( 548 + rank_by=("vector", "ANN", [0.5] * 1536), 549 + top_k=100, 550 + include_attributes=["tags"], 551 + ) 552 + if response.rows: 553 + for row in response.rows: 554 + for tag in getattr(row, "tags", []) or []: 555 + episodic_tags.add(tag) 556 + except Exception: 557 + pass 558 + 559 + for tag in episodic_tags: 560 + node_id = f"episodic:{tag}" 561 + nodes.append({"id": node_id, "label": tag, "type": "episodic"}) 562 + edges.append({"source": "phi", "target": node_id}) 563 + # bridge to user tags if shared 564 + if tag in tag_set: 565 + edges.append({"source": f"tag:{tag}", "target": node_id}) 566 + 567 + return {"nodes": nodes, "edges": edges} 414 568 415 569 async def after_interaction(self, handle: str, user_text: str, bot_text: str): 416 570 """Post-interaction hook: store interaction then extract observations."""
+46 -7
src/bot/status.py
··· 1 - """Bot status tracking""" 1 + """Bot status tracking with persistence.""" 2 2 3 + import json 4 + import logging 3 5 from dataclasses import dataclass, field 4 6 from datetime import datetime 7 + from pathlib import Path 8 + 9 + logger = logging.getLogger("bot.status") 10 + 11 + STATUS_FILE = Path("/data/status.json") 5 12 6 13 7 14 @dataclass 8 15 class BotStatus: 9 - """Tracks bot status and activity""" 16 + """Tracks bot status and activity, persisted to disk.""" 10 17 11 18 start_time: datetime = field(default_factory=datetime.now) 12 19 mentions_received: int = 0 ··· 19 26 20 27 @property 21 28 def uptime_seconds(self) -> float: 22 - """Get uptime in seconds""" 23 29 return (datetime.now() - self.start_time).total_seconds() 24 30 25 31 @property 26 32 def uptime_str(self) -> str: 27 - """Get human-readable uptime""" 28 33 seconds = int(self.uptime_seconds) 29 34 days = seconds // 86400 30 35 hours = (seconds % 86400) // 3600 ··· 43 48 return " ".join(parts) 44 49 45 50 def record_mention(self): 46 - """Record a mention received""" 47 51 self.mentions_received += 1 48 52 self.last_mention_time = datetime.now() 53 + self._save() 49 54 50 55 def record_response(self): 51 - """Record a response sent""" 52 56 self.responses_sent += 1 53 57 self.last_response_time = datetime.now() 58 + self._save() 54 59 55 60 def record_error(self): 56 - """Record an error""" 57 61 self.errors += 1 62 + self._save() 63 + 64 + def _save(self): 65 + """Persist counters to disk.""" 66 + if not STATUS_FILE.parent.exists(): 67 + return 68 + try: 69 + data = { 70 + "mentions_received": self.mentions_received, 71 + "responses_sent": self.responses_sent, 72 + "errors": self.errors, 73 + "last_mention_time": self.last_mention_time.isoformat() if self.last_mention_time else None, 74 + "last_response_time": self.last_response_time.isoformat() if self.last_response_time else None, 75 + } 76 + STATUS_FILE.write_text(json.dumps(data)) 77 + except Exception as e: 78 + logger.warning(f"failed to save status: {e}") 79 + 80 + def _load(self): 81 + """Restore counters from disk.""" 82 + if not STATUS_FILE.exists(): 83 + return 84 + try: 85 + data = json.loads(STATUS_FILE.read_text()) 86 + self.mentions_received = data.get("mentions_received", 0) 87 + self.responses_sent = data.get("responses_sent", 0) 88 + self.errors = data.get("errors", 0) 89 + if data.get("last_mention_time"): 90 + self.last_mention_time = datetime.fromisoformat(data["last_mention_time"]) 91 + if data.get("last_response_time"): 92 + self.last_response_time = datetime.fromisoformat(data["last_response_time"]) 93 + logger.info(f"restored status: {self.mentions_received} mentions, {self.responses_sent} responses") 94 + except Exception as e: 95 + logger.warning(f"failed to load status: {e}") 58 96 59 97 60 98 # Global status instance 61 99 bot_status = BotStatus() 100 + bot_status._load()