forked from Rockachopa/Timmy-time-dashboard
334 lines
10 KiB
Python
334 lines
10 KiB
Python
"""Presence state serializer — transforms ADR-023 presence dicts for consumers.
|
|
|
|
Converts the raw presence schema (version, liveness, mood, energy, etc.)
|
|
into the camelCase world-state payload consumed by the Workshop 3D renderer
|
|
and WebSocket gateway.
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from datetime import UTC, datetime
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Default Pip familiar state (used when familiar module unavailable)
|
|
DEFAULT_PIP_STATE = {
|
|
"name": "Pip",
|
|
"mood": "sleepy",
|
|
"energy": 0.5,
|
|
"color": "0x00b450", # emerald green
|
|
"trail_color": "0xdaa520", # gold
|
|
}
|
|
|
|
|
|
def _get_familiar_state() -> dict:
|
|
"""Get Pip familiar state from familiar module, with graceful fallback.
|
|
|
|
Returns a dict with name, mood, energy, color, and trail_color.
|
|
Falls back to default state if familiar module unavailable or raises.
|
|
"""
|
|
try:
|
|
from timmy.familiar import pip_familiar
|
|
|
|
snapshot = pip_familiar.snapshot()
|
|
# Map PipSnapshot fields to the expected agent_state format
|
|
return {
|
|
"name": snapshot.name,
|
|
"mood": snapshot.state,
|
|
"energy": DEFAULT_PIP_STATE["energy"], # Pip doesn't track energy yet
|
|
"color": DEFAULT_PIP_STATE["color"],
|
|
"trail_color": DEFAULT_PIP_STATE["trail_color"],
|
|
}
|
|
except Exception as exc:
|
|
logger.warning("Familiar state unavailable, using default: %s", exc)
|
|
return DEFAULT_PIP_STATE.copy()
|
|
|
|
|
|
# Valid bark styles for Matrix protocol
|
|
BARK_STYLES = {"speech", "thought", "whisper", "shout"}
|
|
|
|
|
|
def produce_bark(agent_id: str, text: str, reply_to: str = None, style: str = "speech") -> dict:
|
|
"""Format a chat response as a Matrix bark message.
|
|
|
|
Barks appear as floating text above agents in the Matrix 3D world with
|
|
typing animation. This function formats the text for the Matrix protocol.
|
|
|
|
Parameters
|
|
----------
|
|
agent_id:
|
|
Unique identifier for the agent (e.g. ``"timmy"``).
|
|
text:
|
|
The chat response text to display as a bark.
|
|
reply_to:
|
|
Optional message ID or reference this bark is replying to.
|
|
style:
|
|
Visual style of the bark. One of: "speech" (default), "thought",
|
|
"whisper", "shout". Invalid styles fall back to "speech".
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Bark message with keys ``type``, ``agent_id``, ``data`` (containing
|
|
``text``, ``reply_to``, ``style``), and ``ts``.
|
|
|
|
Examples
|
|
--------
|
|
>>> produce_bark("timmy", "Hello world!")
|
|
{
|
|
"type": "bark",
|
|
"agent_id": "timmy",
|
|
"data": {"text": "Hello world!", "reply_to": None, "style": "speech"},
|
|
"ts": 1742529600,
|
|
}
|
|
"""
|
|
# Validate and normalize style
|
|
if style not in BARK_STYLES:
|
|
style = "speech"
|
|
|
|
# Truncate text to 280 characters (bark, not essay)
|
|
truncated_text = text[:280] if text else ""
|
|
|
|
return {
|
|
"type": "bark",
|
|
"agent_id": agent_id,
|
|
"data": {
|
|
"text": truncated_text,
|
|
"reply_to": reply_to,
|
|
"style": style,
|
|
},
|
|
"ts": int(time.time()),
|
|
}
|
|
|
|
|
|
def produce_thought(
|
|
agent_id: str, thought_text: str, thought_id: int, chain_id: str = None
|
|
) -> dict:
|
|
"""Format a thinking engine thought as a Matrix thought message.
|
|
|
|
Thoughts appear as subtle floating text in the 3D world, streaming from
|
|
Timmy's thinking engine (/thinking/api). This function wraps thoughts in
|
|
Matrix protocol format.
|
|
|
|
Parameters
|
|
----------
|
|
agent_id:
|
|
Unique identifier for the agent (e.g. ``"timmy"``).
|
|
thought_text:
|
|
The thought text to display. Truncated to 500 characters.
|
|
thought_id:
|
|
Unique identifier for this thought (sequence number).
|
|
chain_id:
|
|
Optional chain identifier grouping related thoughts.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Thought message with keys ``type``, ``agent_id``, ``data`` (containing
|
|
``text``, ``thought_id``, ``chain_id``), and ``ts``.
|
|
|
|
Examples
|
|
--------
|
|
>>> produce_thought("timmy", "Considering the options...", 42, "chain-123")
|
|
{
|
|
"type": "thought",
|
|
"agent_id": "timmy",
|
|
"data": {"text": "Considering the options...", "thought_id": 42, "chain_id": "chain-123"},
|
|
"ts": 1742529600,
|
|
}
|
|
"""
|
|
# Truncate text to 500 characters (thoughts can be longer than barks)
|
|
truncated_text = thought_text[:500] if thought_text else ""
|
|
|
|
return {
|
|
"type": "thought",
|
|
"agent_id": agent_id,
|
|
"data": {
|
|
"text": truncated_text,
|
|
"thought_id": thought_id,
|
|
"chain_id": chain_id,
|
|
},
|
|
"ts": int(time.time()),
|
|
}
|
|
|
|
|
|
def serialize_presence(presence: dict) -> dict:
|
|
"""Transform an ADR-023 presence dict into the world-state API shape.
|
|
|
|
Parameters
|
|
----------
|
|
presence:
|
|
Raw presence dict as written by
|
|
:func:`~timmy.workshop_state.get_state_dict` or read from
|
|
``~/.timmy/presence.json``.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
CamelCase world-state payload with ``timmyState``, ``familiar``,
|
|
``activeThreads``, ``recentEvents``, ``concerns``, ``visitorPresent``,
|
|
``updatedAt``, and ``version`` keys.
|
|
"""
|
|
return {
|
|
"timmyState": {
|
|
"mood": presence.get("mood", "calm"),
|
|
"activity": presence.get("current_focus", "idle"),
|
|
"energy": presence.get("energy", 0.5),
|
|
"confidence": presence.get("confidence", 0.7),
|
|
},
|
|
"familiar": presence.get("familiar"),
|
|
"activeThreads": presence.get("active_threads", []),
|
|
"recentEvents": presence.get("recent_events", []),
|
|
"concerns": presence.get("concerns", []),
|
|
"visitorPresent": False,
|
|
"updatedAt": presence.get("liveness", datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")),
|
|
"version": presence.get("version", 1),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Status mapping: ADR-023 current_focus → Matrix agent status
|
|
# ---------------------------------------------------------------------------
|
|
_STATUS_KEYWORDS: dict[str, str] = {
|
|
"thinking": "thinking",
|
|
"speaking": "speaking",
|
|
"talking": "speaking",
|
|
"idle": "idle",
|
|
}
|
|
|
|
|
|
def _derive_status(current_focus: str) -> str:
|
|
"""Map a free-text current_focus value to a Matrix status enum.
|
|
|
|
Returns one of: online, idle, thinking, speaking.
|
|
"""
|
|
focus_lower = current_focus.lower()
|
|
for keyword, status in _STATUS_KEYWORDS.items():
|
|
if keyword in focus_lower:
|
|
return status
|
|
if current_focus and current_focus != "idle":
|
|
return "online"
|
|
return "idle"
|
|
|
|
|
|
def produce_agent_state(agent_id: str, presence: dict) -> dict:
|
|
"""Build a Matrix-compatible ``agent_state`` message from presence data.
|
|
|
|
Parameters
|
|
----------
|
|
agent_id:
|
|
Unique identifier for the agent (e.g. ``"timmy"``).
|
|
presence:
|
|
Raw ADR-023 presence dict.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Message with keys ``type``, ``agent_id``, ``data``, and ``ts``.
|
|
"""
|
|
return {
|
|
"type": "agent_state",
|
|
"agent_id": agent_id,
|
|
"data": {
|
|
"display_name": presence.get("display_name", agent_id.title()),
|
|
"role": presence.get("role", "assistant"),
|
|
"status": _derive_status(presence.get("current_focus", "idle")),
|
|
"mood": presence.get("mood", "calm"),
|
|
"energy": presence.get("energy", 0.5),
|
|
"bark": presence.get("bark", ""),
|
|
"familiar": _get_familiar_state(),
|
|
},
|
|
"ts": int(time.time()),
|
|
}
|
|
|
|
|
|
def produce_system_status() -> dict:
|
|
"""Generate a system_status message for the Matrix.
|
|
|
|
Returns a dict with system health metrics including agent count,
|
|
visitor count, uptime, thinking engine status, and memory count.
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
Message with keys ``type``, ``data`` (containing ``agents_online``,
|
|
``visitors``, ``uptime_seconds``, ``thinking_active``, ``memory_count``),
|
|
and ``ts``.
|
|
|
|
Examples
|
|
--------
|
|
>>> produce_system_status()
|
|
{
|
|
"type": "system_status",
|
|
"data": {
|
|
"agents_online": 5,
|
|
"visitors": 2,
|
|
"uptime_seconds": 3600,
|
|
"thinking_active": True,
|
|
"memory_count": 150,
|
|
},
|
|
"ts": 1742529600,
|
|
}
|
|
"""
|
|
# Count agents with status != offline
|
|
agents_online = 0
|
|
try:
|
|
from timmy.agents.loader import list_agents
|
|
|
|
agents = list_agents()
|
|
agents_online = sum(1 for a in agents if a.get("status", "") not in ("offline", ""))
|
|
except Exception as exc:
|
|
logger.debug("Failed to count agents: %s", exc)
|
|
|
|
# Count visitors from WebSocket clients
|
|
visitors = 0
|
|
try:
|
|
from dashboard.routes.world import _ws_clients
|
|
|
|
visitors = len(_ws_clients)
|
|
except Exception as exc:
|
|
logger.debug("Failed to count visitors: %s", exc)
|
|
|
|
# Calculate uptime
|
|
uptime_seconds = 0
|
|
try:
|
|
from datetime import UTC
|
|
|
|
from config import APP_START_TIME
|
|
|
|
uptime_seconds = int((datetime.now(UTC) - APP_START_TIME).total_seconds())
|
|
except Exception as exc:
|
|
logger.debug("Failed to calculate uptime: %s", exc)
|
|
|
|
# Check thinking engine status
|
|
thinking_active = False
|
|
try:
|
|
from config import settings
|
|
from timmy.thinking import thinking_engine
|
|
|
|
thinking_active = settings.thinking_enabled and thinking_engine is not None
|
|
except Exception as exc:
|
|
logger.debug("Failed to check thinking status: %s", exc)
|
|
|
|
# Count memories in vector store
|
|
memory_count = 0
|
|
try:
|
|
from timmy.memory_system import get_memory_stats
|
|
|
|
stats = get_memory_stats()
|
|
memory_count = stats.get("total_entries", 0)
|
|
except Exception as exc:
|
|
logger.debug("Failed to count memories: %s", exc)
|
|
|
|
return {
|
|
"type": "system_status",
|
|
"data": {
|
|
"agents_online": agents_online,
|
|
"visitors": visitors,
|
|
"uptime_seconds": uptime_seconds,
|
|
"thinking_active": thinking_active,
|
|
"memory_count": memory_count,
|
|
},
|
|
"ts": int(time.time()),
|
|
}
|