1
0

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

## Summary
- CognitiveTracker.update() now emits `cognitive_state_changed` events to the SensoryBus
- WorkshopHeartbeat (and other subscribers) react immediately to mood/engagement changes
- Closes the sense → memory → react loop described in the Workshop architecture
- Fire-and-forget emission — never blocks the chat response path
- Gracefully skips when no event loop is running (sync contexts/tests)

## Test plan
- [x] 3 new tests: event emission, mood change tracking, graceful skip without loop
- [x] All 1935 unit tests pass
- [x] Lint + format clean

Fixes #222

Co-authored-by: kimi <kimi@localhost>
Reviewed-on: http://localhost:3000/rockachopa/Timmy-time-dashboard/pulls/414
Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-19 03:23:03 -04:00
committed by rockachopa
parent 76b26ead55
commit 332fa373b8
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