fix: add cognitive state as observable signal for Matrix avatar (#358)

Co-authored-by: Kimi Agent <kimi@timmy.local>
Co-committed-by: Kimi Agent <kimi@timmy.local>
This commit is contained in:
2026-03-18 21:37:17 -04:00
committed by hermes
parent c7198b1254
commit 560aed78c3
3 changed files with 450 additions and 0 deletions

View File

@@ -0,0 +1,248 @@
"""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 persists to ``~/.tower/timmy-state.txt`` alongside the existing
loop coordination fields.
Schema (YAML-ish in the state file)::
FOCUS_TOPIC: three-phase loop architecture
ENGAGEMENT: deep
MOOD: curious
CONVERSATION_DEPTH: 42
LAST_INITIATIVE: proposed Unsplash API exploration
ACTIVE_COMMITMENTS: draft skeleton ticket
"""
import json
import logging
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from timmy.confidence import estimate_confidence
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------
ENGAGEMENT_LEVELS = ("idle", "surface", "deep")
MOOD_VALUES = ("curious", "settled", "hesitant", "energized")
STATE_FILE = Path.home() / ".tower" / "timmy-state.txt"
@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
def to_state_lines(self) -> list[str]:
"""Format for ``~/.tower/timmy-state.txt``."""
lines = [
f"LAST_UPDATED: {datetime.now(UTC).strftime('%Y-%m-%dT%H:%M:%SZ')}",
f"FOCUS_TOPIC: {self.focus_topic or 'none'}",
f"ENGAGEMENT: {self.engagement}",
f"MOOD: {self.mood}",
f"CONVERSATION_DEPTH: {self.conversation_depth}",
]
if self.last_initiative:
lines.append(f"LAST_INITIATIVE: {self.last_initiative}")
if self.active_commitments:
lines.append(f"ACTIVE_COMMITMENTS: {'; '.join(self.active_commitments)}")
return lines
# ---------------------------------------------------------------------------
# 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 and persists Timmy's cognitive state."""
def __init__(self, state_file: Path | None = None) -> None:
self.state = CognitiveState()
self._state_file = state_file or STATE_FILE
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``.
"""
confidence = estimate_confidence(response)
# 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:]
# Persist to disk (best-effort)
self._write_state_file()
return self.state
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()
self._write_state_file()
def _write_state_file(self) -> None:
"""Persist state to ``~/.tower/timmy-state.txt``."""
try:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
self._state_file.write_text("\n".join(self.state.to_state_lines()) + "\n")
except OSError as exc:
logger.warning("Failed to write cognitive state: %s", exc)
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()

View File

@@ -13,6 +13,7 @@ import re
import httpx
from timmy.cognitive_state import cognitive_tracker
from timmy.confidence import estimate_confidence
from timmy.session_logger import get_session_logger
@@ -119,6 +120,9 @@ async def chat(message: str, session_id: str | None = None) -> str:
# Record Timmy response after getting it
session_logger.record_message("timmy", response_text, confidence=confidence)
# Update cognitive state (observable signal for Matrix avatar)
cognitive_tracker.update(message, response_text)
# Flush session logs to disk
session_logger.flush()