Files
Timmy-time-dashboard/src/timmy/workshop_state.py
Kimi Agent 4cdd82818b
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
refactor: break up get_state_dict into helpers (#632)
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
2026-03-20 17:01:16 -04:00

274 lines
8.8 KiB
Python

"""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 and issue #360 for the full v1 schema.
"""
import asyncio
import hashlib
import json
import logging
import time
from collections.abc import Awaitable, Callable
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 (issue #360 enum values)
_MOOD_MAP: dict[str, str] = {
"curious": "contemplative",
"settled": "calm",
"hesitant": "uncertain",
"energized": "excited",
}
# Activity mapping from cognitive engagement
_ACTIVITY_MAP: dict[str, str] = {
"idle": "idle",
"surface": "thinking",
"deep": "thinking",
}
# Module-level energy tracker — decays over time, resets on interaction
_energy_state: dict[str, float] = {"value": 0.8, "last_interaction": time.monotonic()}
# Startup timestamp for uptime calculation
_start_time = time.monotonic()
# Energy decay: 0.01 per minute without interaction (per issue #360)
_ENERGY_DECAY_PER_SECOND = 0.01 / 60.0
_ENERGY_MIN = 0.1
def _time_of_day(hour: int) -> str:
"""Map hour (0-23) to a time-of-day label."""
if 5 <= hour < 12:
return "morning"
if 12 <= hour < 17:
return "afternoon"
if 17 <= hour < 21:
return "evening"
if 21 <= hour or hour < 2:
return "night"
return "deep-night"
def reset_energy() -> None:
"""Reset energy to full (called on interaction)."""
_energy_state["value"] = 0.8
_energy_state["last_interaction"] = time.monotonic()
def _current_energy() -> float:
"""Compute current energy with time-based decay."""
elapsed = time.monotonic() - _energy_state["last_interaction"]
decayed = _energy_state["value"] - (elapsed * _ENERGY_DECAY_PER_SECOND)
return max(_ENERGY_MIN, min(1.0, decayed))
def _pip_snapshot(mood: str, confidence: float) -> dict:
"""Tick Pip and return his current snapshot dict.
Feeds Timmy's mood and confidence into Pip's behavioral AI so the
familiar reacts to Timmy's cognitive state.
"""
from timmy.familiar import pip_familiar
pip_familiar.on_mood_change(mood, confidence=confidence)
pip_familiar.tick()
return pip_familiar.snapshot().to_dict()
def _resolve_mood(state) -> str:
"""Map cognitive mood/engagement to a presence mood string."""
if state.engagement == "idle" and state.mood == "settled":
return "calm"
return _MOOD_MAP.get(state.mood, "calm")
def _resolve_confidence(state) -> float:
"""Compute normalised confidence from cognitive tracker state."""
if state._confidence_count > 0:
raw = state._confidence_sum / state._confidence_count
else:
raw = 0.7
return round(max(0.0, min(1.0, raw)), 2)
def _build_active_threads(state) -> list[dict]:
"""Convert active commitments into presence thread dicts."""
return [
{"type": "thinking", "ref": c[:80], "status": "active"}
for c in state.active_commitments[:10]
]
def _build_environment() -> dict:
"""Return the environment section using local wall-clock time."""
local_now = datetime.now()
return {
"time_of_day": _time_of_day(local_now.hour),
"local_time": local_now.strftime("%-I:%M %p"),
"day_of_week": local_now.strftime("%A"),
}
def get_state_dict() -> dict:
"""Build presence state dict from current cognitive state.
Returns a v1 presence schema dict suitable for JSON serialisation.
Includes the full schema from issue #360: identity, mood, activity,
attention, interaction, environment, and meta sections.
"""
from timmy.cognitive_state import cognitive_tracker
state = cognitive_tracker.get_state()
now = datetime.now(UTC)
mood = _resolve_mood(state)
confidence = _resolve_confidence(state)
activity = _ACTIVITY_MAP.get(state.engagement, "idle")
return {
"version": 1,
"liveness": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"current_focus": state.focus_topic or "",
"active_threads": _build_active_threads(state),
"recent_events": [],
"concerns": [],
"mood": mood,
"confidence": confidence,
"energy": round(_current_energy(), 2),
"identity": {
"name": "Timmy",
"title": "The Workshop Wizard",
"uptime_seconds": int(time.monotonic() - _start_time),
},
"activity": {
"current": activity,
"detail": state.focus_topic or "",
},
"interaction": {
"visitor_present": False,
"conversation_turns": state.conversation_depth,
},
"environment": _build_environment(),
"familiar": _pip_snapshot(mood, confidence),
"meta": {
"schema_version": 1,
"updated_at": now.strftime("%Y-%m-%dT%H:%M:%SZ"),
"writer": "timmy-loop",
},
}
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 volatile timestamps."""
stable = {k: v for k, v in state_dict.items() if k not in ("liveness", "meta")}
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,
on_change: Callable[[dict], Awaitable[None]] | 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()
self._on_change = on_change
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
await self._write_if_changed()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.error("Workshop heartbeat error: %s", exc)
async 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)
if self._on_change:
try:
await self._on_change(state_dict)
except Exception as exc:
logger.warning("on_change callback failed: %s", exc)
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)