fix: wire cognitive state to sensory bus (presence loop) #414

Merged
rockachopa merged 1 commits from kimi/issue-222 into main 2026-03-19 03:23:04 -04:00
2 changed files with 89 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ 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
@@ -169,9 +170,14 @@ class CognitiveTracker:
"""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
@@ -193,8 +199,40 @@ class CognitiveTracker:
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

View File

@@ -1,5 +1,8 @@
"""Tests for cognitive state tracking in src/timmy/cognitive_state.py."""
import asyncio
from unittest.mock import patch
from timmy.cognitive_state import (
ENGAGEMENT_LEVELS,
MOOD_VALUES,
@@ -166,3 +169,51 @@ class TestCognitiveTracker:
def test_engagement_values_are_valid(self):
for level in ENGAGEMENT_LEVELS:
assert isinstance(level, str)
async def test_update_emits_cognitive_state_changed(self):
"""CognitiveTracker.update() emits a sensory event."""
from timmy.event_bus import SensoryBus
mock_bus = SensoryBus()
received = []
mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e))
with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus):
tracker = CognitiveTracker()
tracker.update("debug the memory leak", "Looking at the stack trace now.")
# Give the fire-and-forget task a chance to run
await asyncio.sleep(0.05)
assert len(received) == 1
event = received[0]
assert event.source == "cognitive"
assert event.event_type == "cognitive_state_changed"
assert "mood" in event.data
assert "engagement" in event.data
assert "depth" in event.data
assert event.data["depth"] == 1
async def test_update_tracks_mood_change(self):
"""Event data includes whether mood/engagement changed."""
from timmy.event_bus import SensoryBus
mock_bus = SensoryBus()
received = []
mock_bus.subscribe("cognitive_state_changed", lambda e: received.append(e))
with patch("timmy.event_bus.get_sensory_bus", return_value=mock_bus):
tracker = CognitiveTracker()
# First message — "!" + "great" with high confidence → "energized"
tracker.update("wow", "That's a great discovery!")
await asyncio.sleep(0.05)
assert len(received) == 1
# Default mood is "settled", energized response → mood changes
assert received[0].data["mood"] == "energized"
assert received[0].data["mood_changed"] is True
def test_emit_skipped_without_event_loop(self):
"""Event emission gracefully skips when no async loop is running."""
tracker = CognitiveTracker()
# Should not raise — just silently skips
tracker.update("hello", "Hi there!")