"""Observable cognitive state for Timmy. Tracks Timmy's internal cognitive signals — focus, engagement, mood, and active commitments — so external systems (Matrix avatar, dashboard) can render observable behaviour. State is published via ``workshop_state.py`` → ``presence.json`` and the WebSocket relay. The old ``~/.tower/timmy-state.txt`` file has been deprecated (see #384). """ import asyncio import json import logging from dataclasses import asdict, dataclass, field from timmy.confidence import estimate_confidence logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Schema # --------------------------------------------------------------------------- ENGAGEMENT_LEVELS = ("idle", "surface", "deep") MOOD_VALUES = ("curious", "settled", "hesitant", "energized") @dataclass class CognitiveState: """Observable snapshot of Timmy's cognitive state.""" focus_topic: str | None = None engagement: str = "idle" # idle | surface | deep mood: str = "settled" # curious | settled | hesitant | energized conversation_depth: int = 0 last_initiative: str | None = None active_commitments: list[str] = field(default_factory=list) # Internal tracking (not written to state file) _confidence_sum: float = field(default=0.0, repr=False) _confidence_count: int = field(default=0, repr=False) # ------------------------------------------------------------------ # Serialisation helpers # ------------------------------------------------------------------ def to_dict(self) -> dict: """Public fields only (exclude internal tracking).""" d = asdict(self) d.pop("_confidence_sum", None) d.pop("_confidence_count", None) return d # --------------------------------------------------------------------------- # Cognitive signal extraction # --------------------------------------------------------------------------- # Keywords that suggest deep engagement _DEEP_KEYWORDS = frozenset( { "architecture", "design", "implement", "refactor", "debug", "analyze", "investigate", "deep dive", "explain how", "walk me through", "step by step", } ) # Keywords that suggest initiative / commitment _COMMITMENT_KEYWORDS = frozenset( { "i will", "i'll", "let me", "i'm going to", "plan to", "commit to", "i propose", "i suggest", } ) def _infer_engagement(message: str, response: str) -> str: """Classify engagement level from the exchange.""" combined = (message + " " + response).lower() if any(kw in combined for kw in _DEEP_KEYWORDS): return "deep" # Short exchanges are surface-level if len(response.split()) < 15: return "surface" return "surface" def _infer_mood(response: str, confidence: float) -> str: """Derive mood from response signals.""" lower = response.lower() if confidence < 0.4: return "hesitant" if "!" in response and any(w in lower for w in ("great", "exciting", "love", "awesome")): return "energized" if "?" in response or any(w in lower for w in ("wonder", "interesting", "curious", "hmm")): return "curious" return "settled" def _extract_topic(message: str) -> str | None: """Best-effort topic extraction from the user message. Takes the first meaningful clause (up to 60 chars) as a topic label. """ text = message.strip() if not text: return None # Strip leading question words for prefix in ("what is ", "how do ", "can you ", "please ", "hey timmy "): if text.lower().startswith(prefix): text = text[len(prefix) :] # Truncate if len(text) > 60: text = text[:57] + "..." return text.strip() or None def _extract_commitments(response: str) -> list[str]: """Pull commitment phrases from Timmy's response.""" commitments: list[str] = [] lower = response.lower() for kw in _COMMITMENT_KEYWORDS: idx = lower.find(kw) if idx == -1: continue # Grab the rest of the sentence (up to period/newline, max 80 chars) start = idx end = len(lower) for sep in (".", "\n", "!"): pos = lower.find(sep, start) if pos != -1: end = min(end, pos) snippet = response[start : min(end, start + 80)].strip() if snippet: commitments.append(snippet) return commitments[:3] # Cap at 3 # --------------------------------------------------------------------------- # Tracker singleton # --------------------------------------------------------------------------- class CognitiveTracker: """Maintains Timmy's cognitive state. State is consumed via ``to_json()`` / ``get_state()`` and published externally by ``workshop_state.py`` → ``presence.json``. """ def __init__(self) -> None: self.state = CognitiveState() def update(self, user_message: str, response: str) -> CognitiveState: """Update cognitive state from a chat exchange. Called after each chat round-trip in ``session.py``. Emits a ``cognitive_state_changed`` event to the sensory bus so downstream consumers (WorkshopHeartbeat, etc.) react immediately. """ confidence = estimate_confidence(response) prev_mood = self.state.mood prev_engagement = self.state.engagement # Track running confidence average self.state._confidence_sum += confidence self.state._confidence_count += 1 self.state.conversation_depth += 1 self.state.focus_topic = _extract_topic(user_message) or self.state.focus_topic self.state.engagement = _infer_engagement(user_message, response) self.state.mood = _infer_mood(response, confidence) # Extract commitments from response new_commitments = _extract_commitments(response) if new_commitments: self.state.last_initiative = new_commitments[0] # Merge, keeping last 5 seen = set(self.state.active_commitments) for c in new_commitments: if c not in seen: self.state.active_commitments.append(c) seen.add(c) self.state.active_commitments = self.state.active_commitments[-5:] # Emit cognitive_state_changed to close the sense → react loop self._emit_change(prev_mood, prev_engagement) return self.state def _emit_change(self, prev_mood: str, prev_engagement: str) -> None: """Fire-and-forget sensory event for cognitive state change.""" try: from timmy.event_bus import get_sensory_bus from timmy.events import SensoryEvent event = SensoryEvent( source="cognitive", event_type="cognitive_state_changed", data={ "mood": self.state.mood, "engagement": self.state.engagement, "focus_topic": self.state.focus_topic or "", "depth": self.state.conversation_depth, "mood_changed": self.state.mood != prev_mood, "engagement_changed": self.state.engagement != prev_engagement, }, ) bus = get_sensory_bus() # Fire-and-forget — don't block the chat response try: loop = asyncio.get_running_loop() loop.create_task(bus.emit(event)) except RuntimeError: # No running loop (sync context / tests) — skip emission pass except Exception as exc: logger.debug("Cognitive event emission skipped: %s", exc) def get_state(self) -> CognitiveState: """Return current cognitive state.""" return self.state def reset(self) -> None: """Reset to idle state (e.g. on session reset).""" self.state = CognitiveState() def to_json(self) -> str: """Serialise current state as JSON (for API / WebSocket consumers).""" return json.dumps(self.state.to_dict()) # Module-level singleton cognitive_tracker = CognitiveTracker()