forked from Rockachopa/Timmy-time-dashboard
## 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>
251 lines
8.2 KiB
Python
251 lines
8.2 KiB
Python
"""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()
|