forked from Rockachopa/Timmy-time-dashboard
feat: Workshop state heartbeat for presence.json (#377)
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
155
src/timmy/workshop_state.py
Normal file
155
src/timmy/workshop_state.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Workshop presence heartbeat — periodic writer for ``~/.timmy/presence.json``.
|
||||
|
||||
Maintains Timmy's observable presence state for the Workshop 3D renderer.
|
||||
Writes the presence file every 30 seconds (or on cognitive state change),
|
||||
skipping writes when state is unchanged.
|
||||
|
||||
See ADR-023 for the schema contract.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PRESENCE_FILE = Path.home() / ".timmy" / "presence.json"
|
||||
HEARTBEAT_INTERVAL = 30 # seconds
|
||||
|
||||
# Cognitive mood → presence mood mapping
|
||||
_MOOD_MAP = {
|
||||
"curious": "exploring",
|
||||
"settled": "focused",
|
||||
"hesitant": "uncertain",
|
||||
"energized": "excited",
|
||||
}
|
||||
|
||||
|
||||
def get_state_dict() -> dict:
|
||||
"""Build presence state dict from current cognitive state.
|
||||
|
||||
Returns a v1 presence schema dict suitable for JSON serialisation.
|
||||
"""
|
||||
from timmy.cognitive_state import cognitive_tracker
|
||||
|
||||
state = cognitive_tracker.get_state()
|
||||
|
||||
# Map cognitive engagement to presence mood fallback
|
||||
mood = _MOOD_MAP.get(state.mood, "focused")
|
||||
if state.engagement == "idle" and state.mood == "settled":
|
||||
mood = "idle"
|
||||
|
||||
# Build active threads from commitments
|
||||
threads = []
|
||||
for commitment in state.active_commitments[:10]:
|
||||
threads.append({"type": "thinking", "ref": commitment[:80], "status": "active"})
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"liveness": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"current_focus": state.focus_topic or "",
|
||||
"active_threads": threads,
|
||||
"recent_events": [],
|
||||
"concerns": [],
|
||||
"mood": mood,
|
||||
}
|
||||
|
||||
|
||||
def write_state(state_dict: dict | None = None, path: Path | None = None) -> None:
|
||||
"""Write presence state to ``~/.timmy/presence.json``.
|
||||
|
||||
Gracefully degrades if the file cannot be written.
|
||||
"""
|
||||
if state_dict is None:
|
||||
state_dict = get_state_dict()
|
||||
target = path or PRESENCE_FILE
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(json.dumps(state_dict, indent=2) + "\n")
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to write presence state: %s", exc)
|
||||
|
||||
|
||||
def _state_hash(state_dict: dict) -> str:
|
||||
"""Compute hash of state dict, ignoring liveness timestamp."""
|
||||
stable = {k: v for k, v in state_dict.items() if k != "liveness"}
|
||||
return hashlib.md5(json.dumps(stable, sort_keys=True).encode()).hexdigest()
|
||||
|
||||
|
||||
class WorkshopHeartbeat:
|
||||
"""Async background task that keeps ``presence.json`` fresh.
|
||||
|
||||
- Writes every ``interval`` seconds (default 30).
|
||||
- Reacts to cognitive state changes via sensory bus.
|
||||
- Skips write if state hasn't changed (hash comparison).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
interval: int = HEARTBEAT_INTERVAL,
|
||||
path: Path | None = None,
|
||||
) -> None:
|
||||
self._interval = interval
|
||||
self._path = path or PRESENCE_FILE
|
||||
self._last_hash: str | None = None
|
||||
self._task: asyncio.Task | None = None
|
||||
self._trigger = asyncio.Event()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the heartbeat background loop."""
|
||||
self._subscribe_to_events()
|
||||
self._task = asyncio.create_task(self._run())
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Cancel the heartbeat task gracefully."""
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
|
||||
def notify(self) -> None:
|
||||
"""Signal an immediate state write (e.g. on cognitive state change)."""
|
||||
self._trigger.set()
|
||||
|
||||
async def _run(self) -> None:
|
||||
"""Main loop: write state on interval or trigger."""
|
||||
await asyncio.sleep(1) # Initial stagger
|
||||
while True:
|
||||
try:
|
||||
# Wait for interval OR early trigger
|
||||
try:
|
||||
await asyncio.wait_for(self._trigger.wait(), timeout=self._interval)
|
||||
self._trigger.clear()
|
||||
except TimeoutError:
|
||||
pass # Normal periodic tick
|
||||
|
||||
self._write_if_changed()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Workshop heartbeat error: %s", exc)
|
||||
|
||||
def _write_if_changed(self) -> None:
|
||||
"""Build state, compare hash, write only if changed."""
|
||||
state_dict = get_state_dict()
|
||||
current_hash = _state_hash(state_dict)
|
||||
if current_hash == self._last_hash:
|
||||
return
|
||||
self._last_hash = current_hash
|
||||
write_state(state_dict, self._path)
|
||||
|
||||
def _subscribe_to_events(self) -> None:
|
||||
"""Subscribe to cognitive state change events on the sensory bus."""
|
||||
try:
|
||||
from timmy.event_bus import get_sensory_bus
|
||||
|
||||
bus = get_sensory_bus()
|
||||
bus.subscribe("cognitive_state_changed", lambda _: self.notify())
|
||||
except Exception as exc:
|
||||
logger.debug("Heartbeat event subscription skipped: %s", exc)
|
||||
Reference in New Issue
Block a user