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.

[OPERATIONAL HISTORY] — phi knows when she was paused and resumed

When phi was paused for ~3d and the operator unpaused, the next batch
contained 3 days of accumulated unread and she processed it as one
giant cognitive event with no catchup awareness — three top-level posts
back-to-back instead of one summary or measured silence.

Fix is forward-looking: give her the data, trust her to calibrate.

- BotStatus tracks paused_at + resumed_at (UTC, persisted to /data/status.json).
- /api/control/pause and /resume call the new recorders instead of
setting the flag directly.
- New inject_pause_history dynamic system prompt renders one line —
"[OPERATIONAL HISTORY]: paused YYYY-MM-DD HH:MM UTC, resumed
YYYY-MM-DD HH:MM UTC (offline 3d 20h)." — only when the pause was
>10min and resume was within the last 24h. Otherwise empty.
- humanize_duration(timedelta) -> "3d 20h" / "45m" added to utils.time.

No prescription, no negative prompts. Phi sees the facts and decides
how to handle the batch.

Also delete two orphan .pyc files (curiosity_queue, feed_scanner) whose
.py source is already gone.

+78 -6
+30 -2
src/bot/agent.py
··· 3 3 import contextlib 4 4 import logging 5 5 import os 6 - from datetime import UTC, datetime 6 + from datetime import UTC, datetime, timedelta 7 7 from pathlib import Path 8 8 9 9 from pydantic_ai import Agent, ImageUrl, RunContext ··· 24 24 from bot.memory.extraction import EXTRACTION_SYSTEM_PROMPT, ExtractionResult 25 25 from bot.memory.namespace_memory import InteractionRow 26 26 from bot.memory.review import REVIEW_SYSTEM_PROMPT, ReviewResult 27 + from bot.status import bot_status 27 28 from bot.tools import PhiDeps, _check_services_impl, register_all 28 29 from bot.tools.bluesky import fetch_relay_names 29 - from bot.utils.time import relative_when 30 + from bot.utils.time import humanize_duration, relative_when 30 31 31 32 logger = logging.getLogger("bot.agent") 32 33 ··· 184 185 def inject_today() -> str: 185 186 now = datetime.now(UTC) 186 187 return f"[NOW]: {now.strftime('%Y-%m-%d %H:%M UTC')}" 188 + 189 + @self.agent.system_prompt(dynamic=True) 190 + def inject_pause_history() -> str: 191 + """[OPERATIONAL HISTORY] — most recent pause cycle, when relevant. 192 + 193 + Surfaced only if she was actually offline for a meaningful window 194 + (>10min) and we resumed within the last 24h. Phi reasons about 195 + whether this batch is catchup; we don't prescribe behavior. 196 + """ 197 + paused_at = bot_status.paused_at 198 + resumed_at = bot_status.resumed_at 199 + if not paused_at or not resumed_at: 200 + return "" 201 + if resumed_at <= paused_at: 202 + return "" # currently paused, or never resumed since this pause 203 + offline = resumed_at - paused_at 204 + since_resume = datetime.now(UTC) - resumed_at 205 + if offline < timedelta(minutes=10): 206 + return "" # too brief to matter 207 + if since_resume > timedelta(hours=24): 208 + return "" # ancient history; the catchup is over 209 + return ( 210 + "[OPERATIONAL HISTORY]: paused " 211 + f"{paused_at.strftime('%Y-%m-%d %H:%M UTC')}, resumed " 212 + f"{resumed_at.strftime('%Y-%m-%d %H:%M UTC')} " 213 + f"(offline {humanize_duration(offline)})." 214 + ) 187 215 188 216 @self.agent.system_prompt(dynamic=True) 189 217 async def inject_known_relays() -> str:
+2 -2
src/bot/main.py
··· 138 138 """Pause notification processing. Unread notifications accumulate until resumed.""" 139 139 if err := _check_control_token(request): 140 140 return err 141 - bot_status.paused = True 141 + bot_status.record_paused() 142 142 logger.info("paused via API") 143 143 if pm := getattr(app.state, "profile_manager", None): 144 144 await pm.set_online_status(False) ··· 150 150 """Resume notification processing. Queued notifications will be processed on next poll.""" 151 151 if err := _check_control_token(request): 152 152 return err 153 - bot_status.paused = False 153 + bot_status.record_resumed() 154 154 logger.info("resumed via API") 155 155 if pm := getattr(app.state, "profile_manager", None): 156 156 await pm.set_online_status(True)
+21 -1
src/bot/status.py
··· 3 3 import json 4 4 import logging 5 5 from dataclasses import dataclass, field 6 - from datetime import datetime 6 + from datetime import UTC, datetime 7 7 from pathlib import Path 8 8 9 9 logger = logging.getLogger("bot.status") ··· 24 24 ai_enabled: bool = False 25 25 polling_active: bool = False 26 26 paused: bool = False 27 + # Most recent pause/resume timestamps (UTC). Surfaced to phi so she 28 + # knows when she was offline — informs how to handle a catchup batch. 29 + paused_at: datetime | None = None 30 + resumed_at: datetime | None = None 27 31 28 32 @property 29 33 def uptime_seconds(self) -> float: ··· 62 66 self.errors += 1 63 67 self._save() 64 68 69 + def record_paused(self): 70 + self.paused = True 71 + self.paused_at = datetime.now(UTC) 72 + self._save() 73 + 74 + def record_resumed(self): 75 + self.paused = False 76 + self.resumed_at = datetime.now(UTC) 77 + self._save() 78 + 65 79 def _save(self): 66 80 """Persist counters to disk.""" 67 81 if not STATUS_FILE.parent.exists(): ··· 77 91 "last_response_time": self.last_response_time.isoformat() 78 92 if self.last_response_time 79 93 else None, 94 + "paused_at": self.paused_at.isoformat() if self.paused_at else None, 95 + "resumed_at": self.resumed_at.isoformat() if self.resumed_at else None, 80 96 } 81 97 STATUS_FILE.write_text(json.dumps(data)) 82 98 except Exception as e: ··· 99 115 self.last_response_time = datetime.fromisoformat( 100 116 data["last_response_time"] 101 117 ) 118 + if data.get("paused_at"): 119 + self.paused_at = datetime.fromisoformat(data["paused_at"]) 120 + if data.get("resumed_at"): 121 + self.resumed_at = datetime.fromisoformat(data["resumed_at"]) 102 122 logger.info( 103 123 f"restored status: {self.mentions_received} mentions, {self.responses_sent} responses" 104 124 )
+25 -1
src/bot/utils/time.py
··· 7 7 helper in `tools/_helpers.py:_relative_age` for that — different shape). 8 8 """ 9 9 10 - from datetime import UTC, datetime 10 + from datetime import UTC, datetime, timedelta 11 + 12 + 13 + def humanize_duration(delta: timedelta) -> str: 14 + """Render a timedelta compactly: '3d 20h', '45m', '12s', etc. 15 + 16 + For positive deltas only. Drops zero-leading units. Returns '0s' for 17 + zero or negative. 18 + """ 19 + seconds = int(delta.total_seconds()) 20 + if seconds <= 0: 21 + return "0s" 22 + days, seconds = divmod(seconds, 86400) 23 + hours, seconds = divmod(seconds, 3600) 24 + minutes, seconds = divmod(seconds, 60) 25 + parts: list[str] = [] 26 + if days: 27 + parts.append(f"{days}d") 28 + if hours: 29 + parts.append(f"{hours}h") 30 + if minutes and not days: 31 + parts.append(f"{minutes}m") 32 + if seconds and not (days or hours): 33 + parts.append(f"{seconds}s") 34 + return " ".join(parts) or f"{seconds}s" 11 35 12 36 13 37 def relative_when(iso_ts: str) -> str: