diff --git a/.gitignore b/.gitignore
index ea0951b..178e358 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,8 @@ venv/
# Local env
.env
+
+# Conversation scrollback for the Tirma interface (human-facing only —
+# the coaching memory lives in the memory store, not here)
+.conversations.json
+.conversations.json.tmp
diff --git a/README.md b/README.md
index bbb20da..2e9a25a 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,42 @@ See [`stretch-goals.md`](./stretch-goals.md).
- Tie memory to a customer ID via metadata so the agent has per-tenant memory.
- Use Files API to attach growing document sets across multiple sessions and watch context grow.
+## The interface — Tirma
+
+There is a web front end for all of this in [`ui/`](./ui/README.md), served by
+the same FastAPI app. It shows the conversation, the memory store live beside it,
+and the athlete drawn from that memory — a pixel-art lifter that gets stronger as
+sessions are logged and puts on wrist wraps when the memory says the wrist is hurt.
+
+From the repository root:
+
+```bash
+ANTHROPIC_API_KEY="sk-ant-..." uvicorn api.main:app --reload
+```
+
+Then . The REST API and `/docs` are unchanged.
+
+No API key, or want a version that cannot fail on stage:
+
+```bash
+TIRMA_DEMO=1 uvicorn api.main:app --reload
+```
+
+Full detail, including how memory maps onto the athlete, in
+[`ui/README.md`](./ui/README.md).
+
## Two-minute demo
-Side-by-side terminal windows:
+**On screen (the interface).** Start with the right-hand panel: seven sessions on
+record, two live constraints, and an athlete already wearing wrist wraps and knee
+sleeves because the memory says so. Then type one line — *"physio says the knee is
+fully cleared now"* — and let the room watch three things happen at once: the
+coach reads the training file, closes the knee entry while leaving the wrist
+alone, and the sleeves come off the athlete. The wraps stay on, because the wrist
+clearance was only partial. That is the whole pitch in fifteen seconds.
+
+**In the terminal (the mechanism), if you want to show the plumbing.**
+Side-by-side windows:
- Left: session 1 answer
- Right: session 2 answer (same question, after memory + new context)
@@ -96,7 +129,15 @@ institutional-memory/
├── inspect_memory.py (demo helper — prints what's in the memory store)
├── stretch_memory_curator.py (stretch: curator sub-agent)
├── memory_backend.py (deprecated — the old client-side backend; safe to delete)
+├── api/ (FastAPI: the coaching API, plus the interface it serves)
+│ ├── coach.py (Managed Agents — sessions, memory stores, streaming)
+│ ├── registry.py (one client, one memory store)
+│ ├── ui.py (the endpoints the interface calls)
+│ ├── ui_state.py (memory → athlete; the only place that decides this)
+│ ├── ui_demo.py (scripted coach — same interface, no API key)
+│ └── conversations.py (chat scrollback)
+├── ui/ (the Tirma interface — see ui/README.md)
└── synthetic-data/
- ├── round1/ (initial context — onboarding handbook, policies, customer cases)
+ ├── round1/ (initial context — intake, injuries, preferences)
└── round2/ (updates and contradictions)
```
diff --git a/api/conversations.py b/api/conversations.py
new file mode 100644
index 0000000..de9abed
--- /dev/null
+++ b/api/conversations.py
@@ -0,0 +1,148 @@
+"""
+Conversation threads for the Tirma interface.
+
+The coaching API creates a fresh Managed Agents session for every
+message on purpose — continuity comes from the memory store, not from a
+long-lived conversation. That is the right call for the agent and the
+wrong shape for a chat window, which needs a scrollback and a history.
+
+So this module keeps the human-facing half: threads of messages grouped
+into named conversations, per client. It holds no coaching state and no
+memory. If you delete the file behind it, the coach loses nothing — the
+client's training file is still in their memory store, which is the
+whole point of the architecture.
+
+Backed by a JSON file, matching `registry.py`. Swapping in a database
+means replacing this module and nothing else.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+STORE_PATH = Path(".conversations.json")
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def title_from(text: str, limit: int = 46) -> str:
+ """A conversation names itself after its first message."""
+ clean = " ".join(text.split())
+ if len(clean) <= limit:
+ return clean or "New conversation"
+ return clean[:limit].rstrip(" ,.;:") + "…"
+
+
+class ConversationStore:
+ def __init__(self, path: Path = STORE_PATH) -> None:
+ self._path = path
+ self._lock = asyncio.Lock()
+
+ # ── persistence ──
+
+ def _read(self) -> dict[str, dict[str, Any]]:
+ if not self._path.exists():
+ return {}
+ try:
+ return json.loads(self._path.read_text())
+ except json.JSONDecodeError:
+ # A corrupt scratch file must never take the demo down.
+ return {}
+
+ def _write(self, data: dict[str, dict[str, Any]]) -> None:
+ tmp = self._path.with_suffix(".json.tmp")
+ tmp.write_text(json.dumps(data, indent=2, default=str))
+ tmp.replace(self._path)
+
+ # ── reads ──
+
+ async def for_client(self, client_id: str) -> list[dict[str, Any]]:
+ """Summaries for the session rail, newest first."""
+ async with self._lock:
+ data = self._read()
+ rows = [c for c in data.values() if c["client_id"] == client_id]
+ rows.sort(key=lambda c: c["started_at"], reverse=True)
+ return [self._summary(c) for c in rows]
+
+ async def get(self, conversation_id: str) -> dict[str, Any] | None:
+ async with self._lock:
+ return self._read().get(conversation_id)
+
+ @staticmethod
+ def _summary(c: dict[str, Any]) -> dict[str, Any]:
+ return {
+ "id": c["id"],
+ "client_id": c["client_id"],
+ "title": c["title"],
+ "started_at": c["started_at"],
+ "message_count": len([m for m in c["messages"] if m["role"] in ("user", "agent")]),
+ "memory_writes": c.get("memory_writes", 0),
+ }
+
+ # ── writes ──
+
+ async def create(self, client_id: str, title: str = "New conversation") -> dict[str, Any]:
+ async with self._lock:
+ data = self._read()
+ conversation = {
+ "id": f"con_{uuid.uuid4().hex[:12]}",
+ "client_id": client_id,
+ "title": title,
+ "started_at": _now(),
+ "messages": [],
+ "memory_writes": 0,
+ "named": False,
+ }
+ data[conversation["id"]] = conversation
+ self._write(data)
+ return conversation
+
+ async def add_message(
+ self, conversation_id: str, role: str, text: str
+ ) -> dict[str, Any] | None:
+ """Append a message, naming the conversation after the first one."""
+ async with self._lock:
+ data = self._read()
+ conversation = data.get(conversation_id)
+ if conversation is None:
+ return None
+ message = {
+ "id": f"msg_{uuid.uuid4().hex[:10]}",
+ "role": role,
+ "text": text,
+ "at": _now(),
+ }
+ conversation["messages"].append(message)
+ if role == "user" and not conversation.get("named"):
+ conversation["title"] = title_from(text)
+ conversation["named"] = True
+ self._write(data)
+ return self._summary(conversation)
+
+ async def record_writes(self, conversation_id: str, count: int) -> dict[str, Any] | None:
+ if count <= 0:
+ return None
+ async with self._lock:
+ data = self._read()
+ conversation = data.get(conversation_id)
+ if conversation is None:
+ return None
+ conversation["memory_writes"] = conversation.get("memory_writes", 0) + count
+ self._write(data)
+ return self._summary(conversation)
+
+ async def latest_or_new(self, client_id: str) -> dict[str, Any]:
+ """The conversation to land in when a client is selected."""
+ existing = await self.for_client(client_id)
+ if existing:
+ full = await self.get(existing[0]["id"])
+ if full:
+ return full
+ return await self.create(client_id)
diff --git a/api/main.py b/api/main.py
index c8cacc4..a7b42a1 100644
--- a/api/main.py
+++ b/api/main.py
@@ -5,6 +5,7 @@
uvicorn api.main:app --reload
Interactive docs at http://127.0.0.1:8000/docs
+The Tirma interface at http://127.0.0.1:8000/
"""
from __future__ import annotations
@@ -13,8 +14,10 @@
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI, HTTPException, Path as PathParam
-from fastapi.responses import StreamingResponse
+from fastapi.responses import FileResponse, StreamingResponse
+from fastapi.staticfiles import StaticFiles
+from . import ui
from .coach import Coach
from .models import (
Client,
@@ -23,15 +26,24 @@
MessageRequest,
)
from .registry import ClientRegistry
+from .ui_demo import DemoCoach
-coach = Coach()
+# TIRMA_DEMO=1 swaps in a scripted coach that needs no API key. Same
+# interface, same events, real memory writes — it exists so the interface
+# can be worked on without burning tokens, and so a live demo has
+# something to fall back on.
+coach = DemoCoach() if ui.demo_mode() else Coach()
registry = ClientRegistry()
+ui.install(coach, registry)
@asynccontextmanager
async def lifespan(app: FastAPI):
- if not os.environ.get("ANTHROPIC_API_KEY"):
- raise RuntimeError("Set ANTHROPIC_API_KEY before starting the API.")
+ if not ui.demo_mode() and not os.environ.get("ANTHROPIC_API_KEY"):
+ raise RuntimeError(
+ "Set ANTHROPIC_API_KEY before starting the API, "
+ "or run with TIRMA_DEMO=1 for the scripted coach."
+ )
await coach.ensure_infrastructure()
yield
@@ -63,11 +75,32 @@ async def get_client(
async def health() -> dict:
return {
"status": "ok",
+ "demo": ui.demo_mode(),
"agent_id": coach.agent_id,
"environment_id": coach.environment_id,
}
+# ── The Tirma interface ─────────────────────────────────────────────
+# The router adds the two things a browser needs and the agent does not:
+# conversation scrollback, and the athlete derived from memory. The REST
+# API below is untouched and still usable on its own.
+
+app.include_router(ui.router)
+app.mount("/static", StaticFiles(directory=ui.UI_DIR / "static"), name="static")
+
+
+@app.get("/", include_in_schema=False)
+async def interface() -> FileResponse:
+ return ui.index()
+
+
+@app.get("/sprite-lab.html", include_in_schema=False)
+async def sprite_lab() -> FileResponse:
+ """A bench for inspecting the pixel athlete. Not part of the product."""
+ return FileResponse(ui.UI_DIR / "sprite-lab.html")
+
+
@app.post("/clients", response_model=Client, status_code=201, tags=["clients"])
async def create_client(body: CreateClientRequest) -> Client:
"""Register a client and provision a memory store for them."""
diff --git a/api/ui.py b/api/ui.py
new file mode 100644
index 0000000..a4973d9
--- /dev/null
+++ b/api/ui.py
@@ -0,0 +1,319 @@
+"""
+The endpoints the Tirma interface calls.
+
+This router sits on top of the coaching API rather than beside it. It
+adds exactly two things the browser needs and the agent does not:
+
+ · conversations — a scrollback, because the coach runs a fresh session
+ per message and so has no thread of its own;
+ · the athlete — the pixel figure, derived from the memory store by
+ `ui_state` so the drawing can never disagree with what is remembered.
+
+Everything else is passed straight through to `Coach` (or `DemoCoach`,
+which is interface-compatible). The coaching turn itself is not
+reimplemented here — this router relays the coach's own SSE frames and
+appends its own once the turn is done.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from pathlib import Path
+from typing import Any, AsyncIterator
+
+from fastapi import APIRouter, Body, HTTPException
+from fastapi.responses import FileResponse, StreamingResponse
+
+from .conversations import ConversationStore
+from .models import Client, CreateClientRequest, Document
+from .registry import ClientRegistry
+from .ui_demo import DEMO_CLIENT_NAME, DEMO_STORE_ID, SUGGESTS, DemoCoach
+from .ui_state import MemoryWatcher, derive_athlete, now_iso, parse_memory
+
+UI_DIR = Path(__file__).resolve().parent.parent / "ui"
+
+router = APIRouter(prefix="/ui/api", tags=["interface"])
+
+conversations = ConversationStore()
+watcher = MemoryWatcher()
+
+# Set by `install()` at import time in main.py.
+_coach: Any = None
+_registry: ClientRegistry | None = None
+
+
+def demo_mode() -> bool:
+ return os.environ.get("TIRMA_DEMO", "").lower() in ("1", "true", "yes", "on")
+
+
+def install(coach: Any, registry: ClientRegistry) -> None:
+ """Hand the router the coach and registry the app is already using."""
+ global _coach, _registry
+ _coach, _registry = coach, registry
+
+
+def _sse(event: str, payload: dict) -> str:
+ return f"event: {event}\ndata: {json.dumps(payload)}\n\n"
+
+
+async def _ensure_demo_client() -> Client:
+ """
+ In demo mode there is one client on the shelf, pointed at the seeded
+ store, so the interface has something to show on first load.
+ """
+ assert _registry is not None
+ for client in await _registry.list():
+ if client.memory_store_id == DEMO_STORE_ID:
+ return client
+ return await _registry.add(DEMO_CLIENT_NAME, DEMO_STORE_ID)
+
+
+async def _clients() -> list[Client]:
+ assert _registry is not None
+ clients = await _registry.list()
+ if not clients and demo_mode():
+ return [await _ensure_demo_client()]
+ return clients
+
+
+async def _require_client(client_id: str) -> Client:
+ assert _registry is not None
+ client = await _registry.get(client_id)
+ if client is None:
+ raise HTTPException(404, f"No client with id {client_id!r}")
+ return client
+
+
+async def _memory_and_athlete(client: Client) -> tuple[dict, dict, list[str]]:
+ """
+ Read the client's memory store and derive everything the right-hand
+ panel shows. One read, both payloads, so the panel and the athlete
+ are always the same snapshot.
+ """
+ raw = await _coach.list_memories(client.memory_store_id, True)
+ entries = parse_memory([m.model_dump() for m in raw])
+ fresh = watcher.apply(client.memory_store_id, entries)
+ memory = {
+ "store_id": client.memory_store_id,
+ "updated_at": max((e["updated_at"] for e in entries if e["updated_at"]),
+ default=now_iso()),
+ "entries": entries,
+ }
+ athlete = derive_athlete(entries, name=client.name)
+ return memory, athlete, fresh
+
+
+# ── the page ────────────────────────────────────────────────────────
+
+
+@router.get("/bootstrap")
+async def bootstrap(client_id: str | None = None) -> dict[str, Any]:
+ """Everything the interface needs for a cold start, in one round trip."""
+ clients = await _clients()
+ if not clients:
+ return {
+ "demo": demo_mode(),
+ "agent": {"id": _coach.agent_id, "environment_id": _coach.environment_id},
+ "athletes": [],
+ "active": None,
+ }
+
+ active = next((c for c in clients if c.client_id == client_id), clients[0])
+ memory, athlete, _ = await _memory_and_athlete(active)
+ conversation = await conversations.latest_or_new(active.client_id)
+
+ return {
+ "demo": demo_mode(),
+ "agent": {"id": _coach.agent_id, "environment_id": _coach.environment_id},
+ "athletes": [
+ {"id": c.client_id, "name": c.name, "store_id": c.memory_store_id}
+ for c in clients
+ ],
+ "active": {
+ "id": active.client_id,
+ "name": active.name,
+ "store_id": active.memory_store_id,
+ },
+ "memory": memory,
+ "athlete": athlete,
+ "conversations": await conversations.for_client(active.client_id),
+ "active_conversation_id": conversation["id"],
+ "messages": conversation["messages"],
+ "suggests": SUGGESTS if demo_mode() else [
+ "Give me tomorrow's session",
+ "My left knee hurt during squats today",
+ "What do you actually remember about me?",
+ ],
+ }
+
+
+@router.get("/athletes/{client_id}")
+async def read_athlete(client_id: str) -> dict[str, Any]:
+ """Switch to another client: their memory, their athlete, their threads."""
+ client = await _require_client(client_id)
+ memory, athlete, _ = await _memory_and_athlete(client)
+ conversation = await conversations.latest_or_new(client_id)
+ return {
+ "active": {"id": client.client_id, "name": client.name,
+ "store_id": client.memory_store_id},
+ "memory": memory,
+ "athlete": athlete,
+ "conversations": await conversations.for_client(client_id),
+ "active_conversation_id": conversation["id"],
+ "messages": conversation["messages"],
+ }
+
+
+@router.post("/athletes", status_code=201)
+async def create_athlete(body: CreateClientRequest) -> dict[str, Any]:
+ """A new client, with a memory store of their own."""
+ assert _registry is not None
+ store_id = await _coach.create_memory_store(body.name)
+ client = await _registry.add(body.name, store_id)
+ memory, athlete, _ = await _memory_and_athlete(client)
+ conversation = await conversations.create(client.client_id)
+ return {
+ "active": {"id": client.client_id, "name": client.name, "store_id": store_id},
+ "memory": memory,
+ "athlete": athlete,
+ "conversations": await conversations.for_client(client.client_id),
+ "active_conversation_id": conversation["id"],
+ "messages": [],
+ }
+
+
+# ── conversations ───────────────────────────────────────────────────
+
+
+@router.get("/conversations/{conversation_id}")
+async def read_conversation(conversation_id: str) -> dict[str, Any]:
+ conversation = await conversations.get(conversation_id)
+ if conversation is None:
+ raise HTTPException(404, f"No conversation with id {conversation_id!r}")
+ return {"id": conversation["id"], "title": conversation["title"],
+ "started_at": conversation["started_at"],
+ "messages": conversation["messages"]}
+
+
+@router.post("/conversations", status_code=201)
+async def create_conversation(client_id: str = Body(..., embed=True)) -> dict[str, Any]:
+ await _require_client(client_id)
+ conversation = await conversations.create(client_id)
+ return {
+ "conversation": {
+ "id": conversation["id"], "client_id": client_id,
+ "title": conversation["title"], "started_at": conversation["started_at"],
+ "message_count": 0, "memory_writes": 0,
+ },
+ }
+
+
+@router.post("/conversations/{conversation_id}/messages")
+async def send(
+ conversation_id: str,
+ text: str = Body(..., embed=True),
+ documents: list[Document] = Body(default_factory=list),
+) -> StreamingResponse:
+ """
+ Run one coaching turn and stream it.
+
+ The coach's own frames (`status`, `tool_use`, `message`, `error`,
+ `done`) are relayed untouched. Once the turn ends we re-read the
+ memory store and append two more: `memory.updated` and
+ `athlete.updated`. That is the near-real-time link — the panel and
+ the pixel athlete change because the store changed, not because the
+ interface guessed.
+ """
+ conversation = await conversations.get(conversation_id)
+ if conversation is None:
+ raise HTTPException(404, f"No conversation with id {conversation_id!r}")
+ client = await _require_client(conversation["client_id"])
+
+ await conversations.add_message(conversation_id, "user", text)
+
+ async def relay() -> AsyncIterator[str]:
+ answer = ""
+ writes: list[str] = []
+ failed = False
+
+ try:
+ async for frame in _coach.stream_message(
+ client.memory_store_id, text, documents
+ ):
+ yield frame
+ # Peek at the frames we need to act on afterwards, without
+ # taking over the coach's contract.
+ if frame.startswith("event: done"):
+ payload = _payload(frame)
+ answer = payload.get("answer", "")
+ writes = payload.get("memory_writes", [])
+ elif frame.startswith("event: error"):
+ failed = True
+ except Exception as exc:
+ yield _sse("error", {"detail": f"{type(exc).__name__}: {exc}"})
+ failed = True
+
+ if answer:
+ await conversations.add_message(conversation_id, "agent", answer)
+ summary = await conversations.record_writes(conversation_id, len(writes))
+
+ # Re-read the store even on failure — a turn can write memory and
+ # then fall over, and the panel should show what landed.
+ try:
+ memory, athlete, fresh = await _memory_and_athlete(client)
+ yield _sse("memory.updated", {"memory": memory, "fresh_ids": fresh})
+ yield _sse("athlete.updated", {"athlete": athlete})
+ except Exception as exc:
+ yield _sse("error", {"detail": f"Could not re-read memory: {exc}"})
+
+ if summary is None:
+ full = await conversations.get(conversation_id)
+ summary = ConversationStore._summary(full) if full else None
+ if summary:
+ yield _sse("conversation.updated", {"conversation": summary})
+
+ yield _sse("turn.end", {"ok": not failed})
+
+ return StreamingResponse(
+ relay(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
+def _payload(frame: str) -> dict[str, Any]:
+ for line in frame.splitlines():
+ if line.startswith("data:"):
+ try:
+ return json.loads(line[5:].strip())
+ except json.JSONDecodeError:
+ return {}
+ return {}
+
+
+# ── memory ──────────────────────────────────────────────────────────
+
+
+@router.get("/athletes/{client_id}/memory")
+async def read_memory(client_id: str) -> dict[str, Any]:
+ """Poll the store directly. Used on demand, not on a timer."""
+ client = await _require_client(client_id)
+ memory, athlete, fresh = await _memory_and_athlete(client)
+ return {"memory": memory, "athlete": athlete, "fresh_ids": fresh}
+
+
+@router.delete("/athletes/{client_id}/memory/{memory_id}", status_code=200)
+async def forget(client_id: str, memory_id: str) -> dict[str, Any]:
+ """The 'forget that' half of memory management."""
+ client = await _require_client(client_id)
+ await _coach.delete_memory(client.memory_store_id, memory_id)
+ memory, athlete, _ = await _memory_and_athlete(client)
+ return {"memory": memory, "athlete": athlete}
+
+
+# ── the page itself ─────────────────────────────────────────────────
+
+
+def index() -> FileResponse:
+ return FileResponse(UI_DIR / "index.html")
diff --git a/api/ui_demo.py b/api/ui_demo.py
new file mode 100644
index 0000000..a7927ab
--- /dev/null
+++ b/api/ui_demo.py
@@ -0,0 +1,502 @@
+"""
+A demo coach that needs no API key.
+
+`DemoCoach` is interface-compatible with `coach.Coach`: same methods,
+same SSE frames. The UI router talks to whichever one it was handed and
+cannot tell the difference, so there is one code path rather than two.
+
+What is faked: the replies are scripted. What is *not* faked is the
+mechanism — memory files are really written, updates really overwrite
+the previous version, and the athlete is really re-derived from the
+resulting store by `ui_state`. Say "my left knee hurt during squats" and
+knee sleeves appear because a memory file now says the knee is flagged.
+
+This exists for two reasons. It lets the interface be built and reviewed
+without burning tokens, and it means a live demo has something to fall
+back on if the network or the API is having a bad day on stage.
+
+Enable it with TIRMA_DEMO=1.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+import uuid
+from dataclasses import dataclass, field
+from typing import Any, AsyncIterator
+
+from .models import Document, Memory
+
+DEMO_STORE_ID = "memstore_demo_marco_01H9Z"
+DEMO_CLIENT_NAME = "Marco Ferreira"
+
+
+def _sse(event: str, payload: dict) -> str:
+ return f"event: {event}\ndata: {json.dumps(payload)}\n\n"
+
+
+# ── Seed memory ─────────────────────────────────────────────────────
+# What the coach would plausibly have written after a few sessions on
+# the synthetic-data client. Kept in the coach's own voice — condensed
+# training-file notes, not copies of the source documents, which is what
+# the system prompt asks for.
+
+SEED: dict[str, str] = {
+ "/client-profile.md": """\
+# Marco Ferreira — profile
+
+34, backend engineer, ~9 hours seated a day. Trained 2023–2024, then 18 months
+off. Currently detrained, restarted June 2026.
+
+- Three sessions a week: Monday, Wednesday, Friday.
+- **30 minutes hard cap.** He has said more than once that a 45-minute plan is a
+ plan he will not do. Respect the cap.
+- Sessions at 06:30, before work. Warm-ups under 5 minutes.
+""",
+
+ "/goals.md": """\
+# Goals
+
+1. **Primary: general strength.** He wants to feel capable, not chase a number.
+ Do not turn this into a numbers programme.
+2. **Secondary: run a 5k without walking, by October 2026.** Conditioning work
+ lives on non-lifting days. Currently blocked — running is out until the knee
+ review on 2026-08-12.
+3. **Explicitly not a goal:** physique work, or anything involving a scale.
+""",
+
+ "/injuries.md": """\
+# Injuries and clearances — ACTIVE
+
+*Authoritative. Overrides programme design. Updated 2026-07-22.*
+
+## Right wrist — de Quervain's tenosynovitis
+
+Diagnosed 2026-03-14, under active physio. **Partially cleared 2026-07-15.**
+
+- Neutral-grip dumbbell pressing now permitted up to 16 kg per hand, up from 8 kg.
+- Neutral-grip rowing and pulling at full working load. Dead hangs to 30 s.
+- **Still contraindicated:** loaded wrist extension. No flat-palm push-ups, no
+ planks on hands, no bench dips, no front rack. The clearance is for grip
+ position, not wrist angle — this is not a general all-clear.
+- Reassessment 2026-09-01.
+
+## Left knee — active, new 2026-07-19
+
+Tweaked at five-a-side, assessed 2026-07-22 as minor medial irritation, not
+structural. Review 2026-08-12.
+
+- No deep knee flexion under load. No goblet squats below parallel, no walking
+ lunges, no step-ups above knee height, no Bulgarian split squats.
+- Permitted: box squats to a high box, hip hinges, hip thrusts, leg curls, calves.
+- **Running is out until the 2026-08-12 review.** This moves the 5k timeline.
+
+## Lower back — non-specific sensitivity
+
+No diagnosis. A bad day roughly monthly after long stretches of sitting.
+Avoid loaded spinal flexion. Hip hinges historically help rather than hurt.
+""",
+
+ "/equipment.md": """\
+# Equipment — as of 2026-07-24
+
+Available:
+
+- Adjustable dumbbells, 2–24 kg per hand
+- Pull-up bar, doorway-mounted, rated 100 kg
+- Kettlebell, 16 kg, single
+- Resistance bands: light, medium, heavy
+- Yoga mat
+
+**The adjustable bench broke on 2026-07-21.** Replacement due around 2026-08-20.
+Until then everything previously programmed on the bench needs a floor-based or
+standing substitute.
+
+Still none of: barbell, rack, cable machine, treadmill. Trains at home.
+""",
+
+ "/preferences.md": """\
+# How to coach this client
+
+- **Burpees: never.** He will skip an entire session that contains them. This is
+ adherence, not fitness — the preference stands on its own.
+- No steady-state cardio inside a strength session. Over 10 continuous minutes
+ will not get done. 5k work lives on non-lifting days.
+- **Absolute numbers, not RPE.** "3 sets of 8 at 14 kg" works. "at RPE 7" does not.
+- Supersets, upper paired with lower — kept him inside the time cap and he liked
+ them.
+- Fixed weekly template. Monday should look like last Monday. Novelty is not a
+ motivator; a varied programme is what he quit during in 2024.
+""",
+
+ "/training-log.md": """\
+# What has been programmed
+
+- **2026-07-27** — Lower, knee-safe. Box squats to high box 3x8 @ 20 kg,
+ hip thrusts 3x12 @ 24 kg, leg curls with band 3x15. 28 minutes.
+- **2026-07-24** — Upper, neutral grip throughout. DB floor press 3x8 @ 16 kg
+ (first session at the new clearance), DB row 3x10 @ 20 kg, dead hangs 3x20 s.
+- **2026-07-20** — Upper. DB floor press 3x8 @ 8 kg, band pull-aparts 3x15.
+ Bench had just broken; moved to the floor.
+- **2026-07-17** — Lower. Goblet squats 3x10 @ 16 kg. Programmed before the knee
+ was reported. Would not program this today.
+- **2026-07-13** — Upper, neutral grip. DB floor press 3x8 @ 8 kg.
+- **2026-07-10** — Lower. Hip hinges 3x10 @ 20 kg, calf raises 3x20.
+- **2026-07-06** — First session back. Bodyweight and bands only, 22 minutes.
+
+Adherence has been complete since 2026-07-06. Every session finished inside the
+30-minute cap.
+""",
+}
+
+
+SUGGESTS = [
+ "My left knee hurt during squats today",
+ "Physio says the knee is fully cleared now",
+ "Give me tomorrow's session",
+ "What do you actually remember about me?",
+]
+
+
+# ── The script ──────────────────────────────────────────────────────
+
+BODY_PARTS = {
+ "wrist": "Right wrist",
+ "knee": "Left knee",
+ "shoulder": "Shoulder",
+ "elbow": "Elbow",
+ "back": "Lower back",
+ "hip": "Hip",
+ "ankle": "Ankle",
+}
+
+PAIN = r"hurt|pain|sore|ache|tweak|twinge|flare|strain|sharp|niggl"
+RECOVERY = r"\bfine\b|better|healed|cleared|clearance|settled|good now|full(y)? (recovered|cleared)|back to normal|no pain"
+
+
+@dataclass
+class Plan:
+ reads: list[str] = field(default_factory=list)
+ reply: str = ""
+ writes: list[tuple[str, str, str]] = field(default_factory=list) # path, content, note
+
+
+class DemoCoach:
+ """Scripted stand-in for `Coach`. Same methods, same events."""
+
+ is_demo = True
+
+ def __init__(self) -> None:
+ self.agent_id = "agent_demo_tirma_coach"
+ self.environment_id = "env_demo_tirma"
+ self._stores: dict[str, dict[str, str]] = {DEMO_STORE_ID: dict(SEED)}
+
+ # ── provisioning ──
+
+ async def ensure_infrastructure(self) -> None:
+ return None
+
+ async def create_memory_store(self, client_name: str) -> str:
+ store_id = f"memstore_demo_{uuid.uuid4().hex[:10]}"
+ self._stores[store_id] = {
+ "/client-profile.md": f"# {client_name} — profile\n\nNew client. Nothing on "
+ f"record yet: no intake, no injuries, no equipment list.\n",
+ }
+ return store_id
+
+ # ── memory ──
+
+ async def list_memories(self, store_id: str, include_content: bool) -> list[Memory]:
+ files = self._stores.get(store_id, {})
+ return sorted(
+ (
+ Memory(
+ memory_id=f"mem_{re.sub(r'[^a-z0-9]+', '_', path.lower()).strip('_')}",
+ path=path,
+ size_bytes=len(content.encode("utf-8")),
+ content=content if include_content else None,
+ )
+ for path, content in files.items()
+ ),
+ key=lambda m: m.path,
+ )
+
+ async def delete_memory(self, store_id: str, memory_id: str) -> None:
+ files = self._stores.get(store_id, {})
+ for path in list(files):
+ slug = re.sub(r"[^a-z0-9]+", "_", path.lower()).strip("_")
+ if memory_id in (path, f"mem_{slug}"):
+ del files[path]
+ return
+
+ # ── the turn ──
+
+ async def stream_message(
+ self, memory_store_id: str, text: str, documents: list[Document]
+ ) -> AsyncIterator[str]:
+ session_id = f"ses_demo_{uuid.uuid4().hex[:8]}"
+ yield _sse("status", {"session_id": session_id, "state": "running"})
+ await asyncio.sleep(0.4)
+
+ plan = self._plan(memory_store_id, text, documents)
+
+ yield _sse("tool_use", {"tool": "bash", "target": "ls /mnt/memory/", "is_memory": True})
+ await asyncio.sleep(0.35)
+ for path in plan.reads:
+ yield _sse("tool_use", {"tool": "read", "target": f"/mnt/memory{path}",
+ "is_memory": True})
+ await asyncio.sleep(0.3)
+
+ await asyncio.sleep(0.35)
+ for chunk in _chunks(plan.reply):
+ yield _sse("message", {"text": chunk})
+ await asyncio.sleep(0.03)
+
+ writes: list[str] = []
+ files = self._stores.setdefault(memory_store_id, {})
+ for path, content, note in plan.writes:
+ await asyncio.sleep(0.6)
+ files[path] = content
+ writes.append(f"/mnt/memory{path}")
+ yield _sse("tool_use", {"tool": "write", "target": f"/mnt/memory{path}",
+ "is_memory": True, "note": note})
+
+ await asyncio.sleep(0.3)
+ yield _sse("done", {"session_id": session_id, "answer": plan.reply,
+ "memory_writes": writes})
+
+ # ── branches ──
+
+ def _plan(self, store_id: str, text: str, documents: list[Document]) -> Plan:
+ low = text.lower()
+ files = self._stores.get(store_id, {})
+ injuries = files.get("/injuries.md", "")
+
+ # A document was attached — the round-2 move: reconcile and update.
+ if documents:
+ names = ", ".join(d.name for d in documents)
+ return Plan(
+ reads=["/injuries.md", "/equipment.md", "/training-log.md"],
+ reply=f"Read {names} against what I already had.\n\n"
+ f"**This changes things.** I have updated the training file rather "
+ f"than filing the document alongside it — the file is what I read "
+ f"next session, and two versions of the truth is how a client gets "
+ f"hurt.\n\n"
+ f"Look at the memory panel: the entry that changed shows what it "
+ f"said before.",
+ writes=[("/injuries.md", injuries + f"\n\n*Reconciled against {names} "
+ f"on 2026-07-28.*\n",
+ "reconciled against attached document")],
+ )
+
+ # A new or worsening injury. The athlete visibly gains kit.
+ if re.search(PAIN, low):
+ for key, label in BODY_PARTS.items():
+ if key in low:
+ return self._injury(key, label, text, injuries)
+
+ # Something cleared. The kit comes back off.
+ if re.search(RECOVERY, low):
+ for key, label in BODY_PARTS.items():
+ if key in low:
+ return self._cleared(key, label, injuries)
+
+ if "burpee" in low:
+ return Plan(
+ reads=["/preferences.md", "/injuries.md"],
+ reply="No. It is in your file twice, and it is an adherence note, not a "
+ "fitness one — you have told me you will skip the whole session.\n\n"
+ "It is also contraindicated by your wrist: burpees load wrist "
+ "extension, which is still off the table even after the July "
+ "clearance.\n\n"
+ "If you want the same effect: kettlebell swings with the 16 kg, "
+ "30 seconds on and 30 off for 8 rounds. Hip hinge, no wrist "
+ "extension, no knee flexion under load. Nothing to write down here.",
+ )
+
+ if re.search(r"what do you (know|remember)|about me|your memory|remember about", low):
+ return Plan(
+ reads=["/client-profile.md", "/injuries.md", "/equipment.md",
+ "/preferences.md", "/training-log.md"],
+ reply="Seven sessions on record, all inside your 30-minute cap. In the "
+ "order that changes what I program:\n\n"
+ "**Two active constraints.** Right wrist, de Quervain's — partially "
+ "cleared 15 Jul to 16 kg neutral grip, but loaded wrist extension "
+ "is still out. Left knee, tweaked 19 Jul at five-a-side — no deep "
+ "flexion under load, and running is out until the 12 Aug review, "
+ "which moves your 5k.\n\n"
+ "**Your bench broke on 21 Jul.** Replacement around 20 Aug. "
+ "Everything is floor-based until then.\n\n"
+ "**Hard preferences.** No burpees, ever. Absolute numbers, not RPE. "
+ "Fixed weekly template — Monday looks like last Monday.\n\n"
+ "Nothing to write from that. You asked me to read my file, not to "
+ "add to it.",
+ )
+
+ # Default: program a session from memory, and log it.
+ return Plan(
+ reads=["/injuries.md", "/equipment.md", "/preferences.md", "/training-log.md"],
+ reply="Based on the left knee still being live and the bench still being "
+ "broken, tomorrow is upper, floor-based, neutral grip throughout:\n\n"
+ "1. **DB floor press** — 3x8 at 16 kg per hand.\n"
+ "2. **DB row**, neutral grip — 3x10 at 20 kg per hand.\n"
+ "3. **Dead hangs** from the doorway bar — 3x25 seconds.\n"
+ "4. **Band pull-aparts** — 3x15, heavy band.\n\n"
+ "Supersets 1 and 2, then 3 and 4. That lands at about 26 minutes with "
+ "a four-minute warm-up.\n\n"
+ "What shaped it: no flat-palm work because your wrist clearance covers "
+ "grip position and not wrist angle; nothing on a bench because you do "
+ "not currently have one; nothing loading knee flexion.\n\n"
+ "Logged.",
+ writes=[("/training-log.md", _prepend_log(
+ files.get("/training-log.md", "# What has been programmed\n"),
+ "- **2026-07-28** — Upper, floor-based, neutral grip. DB floor press "
+ "3x8 @ 16 kg, DB row 3x10 @ 20 kg, dead hangs 3x25 s, band pull-aparts "
+ "3x15. Supersetted. ~26 minutes."),
+ "session programmed and logged")],
+ )
+
+ def _injury(self, key: str, label: str, text: str, injuries: str) -> Plan:
+ side = "left" if "left" in text.lower() else ("right" if "right" in text.lower() else "")
+ named = f"{side.capitalize()} {label.split(' ', 1)[-1].lower()}".strip() if side else label
+
+ prescriptions = {
+ "knee": ("Box squats to a high box only, and the load comes down 20%. "
+ "No goblet squats below parallel, no lunges, no step-ups.",
+ "No deep knee flexion under load. Box squats to a high box, load "
+ "reduced 20%. Hip hinges, hip thrusts, leg curls and calves "
+ "permitted. Review in 7 days."),
+ "wrist": ("This is an escalation on a constraint that was only partially "
+ "cleared two weeks ago. Neutral-grip load drops back to 8 kg and "
+ "the dead hangs stop until your 1 Sep reassessment.",
+ "Escalated. Neutral-grip pressing back down to 8 kg per hand. Dead "
+ "hangs suspended. Loaded wrist extension remains contraindicated. "
+ "Flag to physio before 2026-09-01."),
+ "shoulder": ("Overhead work stops. Pressing goes to a slight incline on the "
+ "floor, neutral grip, and volume halves.",
+ "No overhead pressing. Slight-incline neutral-grip floor press "
+ "only, volume halved. Review in 7 days."),
+ "back": ("Hip hinges pause — which is unusual for you, since they normally "
+ "help. Nothing loading the spine for a week.",
+ "Loaded hip hinges paused. No spinal loading. Review in 7 days. "
+ "Note: hinges have historically helped this, so a flare during one "
+ "is worth telling the physio."),
+ "elbow": ("Pressing volume halves and the bottom of the range comes off. "
+ "No dead hangs.",
+ "Press volume halved, bottom range shortened, dead hangs suspended. "
+ "Review in 7 days."),
+ "hip": ("Hinges come down to bodyweight and the stance narrows.",
+ "Hip hinges at bodyweight only, narrower stance. Review in 7 days."),
+ "ankle": ("Heels get a plate under them and calf work stops.",
+ "Heel-elevated work only. Calf work paused. Review in 7 days."),
+ }
+ spoken, remembered = prescriptions.get(
+ key,
+ ("That comes out of the programme until it has been quiet for a full week.",
+ "Flagged. Loading reduced until quiet for a full week. Review in 7 days."),
+ )
+
+ block = (f"\n\n## {named} — active, reported 2026-07-28\n\n"
+ f"Reported directly by the client during a session. {remembered}\n")
+ return Plan(
+ reads=["/injuries.md", "/training-log.md"],
+ reply=f"**{named} is now flagged**, and it goes in your file — not just into "
+ f"tomorrow's session. A constraint I only work around once is a "
+ f"constraint I will have forgotten by next week.\n\n"
+ f"{spoken}\n\n"
+ f"Review in seven days. If it is worse rather than quieter before then, "
+ f"that is a physio conversation and not a programming one — I am not "
+ f"the person to assess it.\n\n"
+ f"Watch the right-hand panel: the athlete just picked up the kit that "
+ f"goes with this.",
+ writes=[("/injuries.md", (injuries or "# Injuries and clearances — ACTIVE\n")
+ + block, "new constraint recorded")],
+ )
+
+ def _cleared(self, key: str, label: str, injuries: str) -> Plan:
+ if key not in injuries.lower():
+ return Plan(
+ reads=["/injuries.md"],
+ reply=f"There is nothing active on the {label.lower()} in your file, so "
+ f"there is nothing for me to clear. If it was bothering you and I "
+ f"never wrote it down, tell me and I will fix that.",
+ )
+ updated, found = _clear_section(injuries, key, label)
+ if not found:
+ return Plan(
+ reads=["/injuries.md"],
+ reply=f"I have something about the {label.lower()} but not as its own "
+ f"entry, so I cannot cleanly close it. Send me what the physio "
+ f"actually wrote and I will rewrite the file properly.",
+ )
+ return Plan(
+ reads=["/injuries.md", "/training-log.md"],
+ reply=f"Good — and I am closing the {label.lower()} entry rather than leaving "
+ f"it open forever. A training file that only ever grows is not a "
+ f"training file, it is a pile.\n\n"
+ f"Back in carefully: two sessions at 60% of where you left off before "
+ f"we talk about the original timeline again. I am treating your physio's "
+ f"clearance as fact, and a clearance is permission to load, not "
+ f"permission to load like nothing happened.\n\n"
+ f"The protective kit just came off the athlete on the right. That is "
+ f"the memory store changing its mind, not a cosmetic reset.",
+ writes=[("/injuries.md", updated, "constraint cleared and closed")],
+ )
+
+
+def _clear_section(raw: str, key: str, label: str, date: str = "2026-07-28") -> tuple[str, bool]:
+ """
+ Rewrite the section for one body part as closed, leaving the others
+ alone. A coach keeps every constraint in one file, so clearing the
+ knee must not disturb the wrist.
+
+ Deliberately avoids the words that mark a constraint as still in
+ force ("active", "re-check", "remains out") — those are what
+ ui_state reads to decide whether the kit comes off.
+ """
+ out: list[str] = []
+ in_target = False
+ found = False
+ for line in raw.splitlines():
+ if line.startswith("## "):
+ in_target = key in line.lower()
+ if in_target:
+ found = True
+ out += [
+ f"## {label} — CLEARED {date}",
+ "",
+ f"Physio clearance reported by the client on {date}. Nothing further "
+ f"to program around here.",
+ "",
+ "Returning to load progressively: two sessions at 60% of the previous "
+ "working weight before normal progression resumes.",
+ "",
+ ]
+ continue
+ if not in_target:
+ out.append(line)
+ return "\n".join(out), found
+
+
+def _prepend_log(existing: str, line: str) -> str:
+ lines = existing.splitlines()
+ if lines and lines[0].startswith("#"):
+ return "\n".join([lines[0], "", line, *lines[1:]][:400])
+ return line + "\n" + existing
+
+
+def _chunks(text: str, size: int = 5) -> list[str]:
+ """Break the reply into word groups so it streams like a reply."""
+ out, buf, count = [], "", 0
+ for token in re.split(r"(\s+)", text):
+ buf += token
+ if token.strip():
+ count += 1
+ if count >= size:
+ out.append(buf)
+ buf, count = "", 0
+ if buf:
+ out.append(buf)
+ return out
diff --git a/api/ui_state.py b/api/ui_state.py
new file mode 100644
index 0000000..bec11a6
--- /dev/null
+++ b/api/ui_state.py
@@ -0,0 +1,435 @@
+"""
+Tirma UI · memory → athlete.
+
+One job: turn what is actually in a client's memory store into the
+athlete the interface draws. The browser never decides this. If the
+pixel athlete is wearing wrist wraps, it is because a memory entry says
+the wrist is hurt.
+
+The input is whatever `Coach.list_memories` returns — raw markdown files
+with a path and a body. Everything the UI shows (kind, title, summary,
+accessories, tier) is derived here, so the interface can't drift from
+what the agent chose to remember.
+
+Nothing in this module talks to the network, so it is straightforward to
+test: pass dicts in, assert on dicts out.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any, Iterable
+
+
+def now_iso() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+# ── Tiers ───────────────────────────────────────────────────────────
+# Driven by sessions the coach has actually logged in memory, not by
+# messages sent. Talking about training does not make anyone stronger.
+# Fitness is one of the few domains where levels are honest rather than
+# imposed — the progression is the point of the product.
+
+TIERS: list[dict[str, Any]] = [
+ {"label": "Twig", "at": 0, "blurb": "Day one. Bar only."},
+ {"label": "Sprout", "at": 2, "blurb": "Something is happening."},
+ {"label": "Solid", "at": 5, "blurb": "Shirts fit differently now."},
+ {"label": "Buff", "at": 10, "blurb": "Doorframes are a consideration."},
+ {"label": "Beast", "at": 18, "blurb": "The bar bends a little."},
+ {"label": "Absolute Unit", "at": 30, "blurb": "Gravity files a complaint."},
+]
+
+
+# ── Entry kinds ─────────────────────────────────────────────────────
+# The kind drives the semantic colour on the left edge of a memory card.
+# It is status and risk — an active injury is a different order of thing
+# from a stated preference — not an editorial category.
+
+KIND_SEVERITY = {
+ "injury": "critical",
+ "constraint": "warning",
+ "goal": "info",
+ "progress": "success",
+ "program": "info",
+ "preference": "muted",
+ "profile": "muted",
+}
+
+_KIND_PATTERNS: list[tuple[str, str]] = [
+ ("injury", r"injur|pain|tendon|sprain|strain|flare|physio|niggle|clearance|cleared for"),
+ ("progress", r"personal record|\bpr\b|1rm|best lift|logged session|training log|progress"),
+ ("program", r"program|programme|split|routine|block|mesocycle|workout plan"),
+ ("goal", r"\bgoal|target|aiming for|objective|wants to"),
+ ("constraint", r"equipment|kit|schedule|availabilit|constraint|no access|only has|travel"),
+ ("preference", r"prefer|dislike|hates|refuses|will not|likes"),
+]
+
+_STEM_KINDS = {
+ "injuries": "injury", "injury": "injury",
+ "limitation": "injury", "limitations": "injury",
+ "records": "progress", "record": "progress", "log": "progress", "progress": "progress",
+ "programme": "program", "program": "program",
+ "goals": "goal", "goal": "goal",
+ "constraints": "constraint", "constraint": "constraint", "equipment": "constraint",
+ "preferences": "preference", "preference": "preference",
+ "profile": "profile", "intake": "profile",
+}
+
+
+def infer_kind(path: str, content: str = "") -> str:
+ """Best guess at an entry's kind. The filename wins, then the text."""
+ stem = path.rsplit("/", 1)[-1].lower()
+ for key, kind in _STEM_KINDS.items():
+ if key in stem:
+ return kind
+ low = content.lower()
+ for kind, pattern in _KIND_PATTERNS:
+ if re.search(pattern, low):
+ return kind
+ return "profile"
+
+
+# ── Parsing a raw memory file ───────────────────────────────────────
+
+
+def _title_and_body(content: str, path: str) -> tuple[str, str]:
+ """The first markdown heading is the title; the rest is the summary."""
+ lines = [ln.rstrip() for ln in (content or "").splitlines()]
+ title, start = "", 0
+ for i, line in enumerate(lines):
+ if not line.strip():
+ continue
+ heading = re.match(r"^#{1,6}\s+(.*)$", line)
+ title = (heading.group(1) if heading else re.sub(r"^[-*]\s+", "", line)).strip(" *_#")
+ start = i + 1
+ break
+ if not title:
+ title = path.rsplit("/", 1)[-1].removesuffix(".md").replace("-", " ").capitalize()
+
+ body_lines = []
+ for line in lines[start:]:
+ if re.match(r"^#{1,6}\s+", line):
+ # Fold sub-headings into the flow rather than dropping them.
+ body_lines.append(re.sub(r"^#{1,6}\s+", "", line).strip() + ":")
+ else:
+ body_lines.append(re.sub(r"^[-*]\s+", "· ", line))
+ body = re.sub(r"\n{3,}", "\n\n", "\n".join(body_lines).strip())
+ return title[:120], body
+
+
+def parse_memory(raw: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
+ """
+ Normalise `Coach.list_memories` output into what the panel renders.
+
+ Accepts the `Memory` model dumped to dicts:
+ `{memory_id, path, size_bytes, content}`.
+ """
+ entries: list[dict[str, Any]] = []
+ for item in raw:
+ path = item.get("path") or ""
+ content = item.get("content") or ""
+ title, body = _title_and_body(content, path)
+ entries.append({
+ "id": item.get("memory_id") or item.get("id") or path,
+ "path": path,
+ "kind": infer_kind(path, content),
+ "title": title,
+ "body": body,
+ "raw": content,
+ "size_bytes": item.get("size_bytes") or len(content.encode("utf-8")),
+ "updated_at": None,
+ "previous": None,
+ "superseded_by": None,
+ "live_text": content,
+ "cleared": [],
+ })
+ for entry in entries:
+ resolve_sections(entry)
+ order = {k: i for i, k in enumerate(
+ ["injury", "constraint", "progress", "goal", "program", "preference", "profile"])}
+ entries.sort(key=lambda e: (order.get(e["kind"], 99), e["path"]))
+ return entries
+
+
+# ── Is a constraint still in force? ─────────────────────────────────
+# The system prompt tells the coach a constraint stays live until
+# something explicitly clears it, and to read partial clearances
+# precisely: "cleared for X" is not "cleared for everything near X".
+#
+# A coach writes one injuries file with several constraints in it, so
+# this has to work per section, not per file. Clearing the knee must
+# take the knee sleeves off the athlete without touching the wrist
+# wraps — and a *partial* clearance must take nothing off at all.
+
+_CLEARED = r"cleared|resolved|no longer|healed|discharged|fully recovered|back to full"
+
+# Anything here keeps a section in force, and beats the clearance words
+# above. "Partially cleared … still contraindicated" stays live.
+_STILL_LIVE = (
+ r"\bactive\b|\bongoing\b|re-?check|reassess|review \d|still contraindicated"
+ r"|remains? (out|off|contraindicated)|partial|do not read this"
+)
+
+_HEADING = re.compile(r"^#{2,6}\s+(.*)$", re.M)
+
+
+def split_sections(raw: str) -> list[tuple[str | None, str]]:
+ """Break a memory file into (heading, text). The preamble comes first."""
+ raw = raw or ""
+ marks = list(_HEADING.finditer(raw))
+ if not marks:
+ return [(None, raw)]
+ out: list[tuple[str | None, str]] = []
+ if marks[0].start() > 0:
+ out.append((None, raw[: marks[0].start()]))
+ for i, mark in enumerate(marks):
+ end = marks[i + 1].start() if i + 1 < len(marks) else len(raw)
+ out.append((mark.group(1).strip(), raw[mark.start(): end]))
+ return out
+
+
+def section_cleared(text: str) -> bool:
+ low = text.lower()
+ if re.search(_STILL_LIVE, low):
+ return False
+ return bool(re.search(_CLEARED, low))
+
+
+def resolve_sections(entry: dict[str, Any]) -> None:
+ """
+ Split an entry into constraints and work out which are still live.
+
+ Sets `live_text` (what the trait rules read) and `cleared` (the
+ headings that have been closed, so the panel can show them).
+ """
+ sections = split_sections(entry.get("raw", ""))
+ live, cleared = [], []
+ for heading, text in sections:
+ if heading and section_cleared(text):
+ cleared.append(heading)
+ else:
+ live.append(text)
+ entry["live_text"] = "\n".join(live)
+ entry["cleared"] = cleared
+ # Every constraint in the file is closed → the entry itself is closed.
+ if entry.get("kind") == "injury" and cleared and not any(
+ h for h, t in sections if h and not section_cleared(t)
+ ):
+ entry["superseded_by"] = "cleared"
+
+
+# ── Traits ──────────────────────────────────────────────────────────
+# Each rule reads memory and puts something on the athlete.
+# `require_kind` keeps a rule honest: a passing mention of "knee" in a
+# goal must not produce knee sleeves — only an injury entry does.
+
+
+@dataclass(frozen=True)
+class TraitRule:
+ trait: str
+ label: str
+ pattern: str
+ severity: str = "muted"
+ require_kind: tuple[str, ...] = ()
+ note: str = ""
+
+ def matches(self, entry: dict[str, Any]) -> bool:
+ if self.require_kind and entry.get("kind") not in self.require_kind:
+ return False
+ # `live_text` excludes constraints that have been cleared, so a
+ # healed knee stops producing knee sleeves.
+ text = entry.get("live_text", entry.get("raw", ""))
+ return bool(re.search(self.pattern, text.lower()))
+
+
+TRAIT_RULES: tuple[TraitRule, ...] = (
+ # On the body — injuries and load.
+ TraitRule("wrist_brace", "Wrist brace", r"wrist.{0,60}(brace|immobilis|immobiliz|splint)",
+ "critical", ("injury",), "Braced, not just wrapped"),
+ TraitRule("wrist_wrap", "Wrist wraps", r"wrist", "critical", ("injury",),
+ "Wraps on because the wrist is flagged"),
+ TraitRule("knee_sleeve", "Knee sleeves", r"knee", "critical", ("injury",),
+ "Sleeves on because the knee is flagged"),
+ TraitRule("elbow_sleeve", "Elbow sleeves", r"elbow", "critical", ("injury",),
+ "Sleeves on because the elbow is flagged"),
+ TraitRule("shoulder_tape", "Shoulder tape", r"shoulder|rotator|impinge", "critical", ("injury",),
+ "Taped because the shoulder is flagged"),
+ TraitRule("belt", "Lifting belt", r"deadlift|lower back|heavy squat|\bbelt",
+ "warning", ("program", "injury", "progress"), "Belt for the heavy pulls"),
+ TraitRule("headband", "Headband", r"\b5k\b|\brun|cardio|conditioning|zone 2|rowing",
+ "info", ("goal", "program"), "There is conditioning in the plan"),
+ TraitRule("cap", "Cap", r"outdoor|outside|\bpark\b|beach|\bsun\b", "info",
+ ("constraint", "program", "preference"), "Some of it happens outdoors"),
+ TraitRule("shades", "Shades", r"personal record|\bpr\b|1rm|personal best", "success",
+ ("progress",), "Earned by a personal record"),
+
+ # On the floor — the small human details.
+ TraitRule("chalk", "Chalk", r"chalk|grip slip|grip fail|grip strength", "muted", (),
+ "Grip came up in memory"),
+ TraitRule("water_bottle", "Water bottle", r"hydrat|water intake|drinks water", "muted", (),
+ "Hydration is tracked"),
+ TraitRule("coffee", "Coffee", r"coffee|espresso|caffeine|before work|0[4-7]:\d\d|early morning",
+ "muted", (), "Trains early, with coffee"),
+ TraitRule("notebook", "Training log", r"\blog\b|journal|notebook|writes it down|spreadsheet",
+ "muted", (), "Keeps a written log"),
+ TraitRule("towel", "Towel", r"towel|sweat", "muted", (), "Mentioned in memory"),
+ TraitRule("banana", "Banana", r"banana|snack|pre-?workout meal|fuel|breakfast", "muted", (),
+ "Pre-session fuel is in memory"),
+ TraitRule("cat", "The cat", r"home gym|garage|spare room|apartment|trains at home", "muted", (),
+ "Trains at home, and is not alone"),
+)
+
+
+# ── How many sessions are on record ─────────────────────────────────
+
+_DATE_PATTERNS = (
+ r"\b\d{4}-\d{2}-\d{2}\b",
+ r"\b\d{1,2}\s+(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b",
+)
+
+
+def count_sessions(entries: Iterable[dict[str, Any]]) -> int:
+ """
+ Count the sessions the coach has written down.
+
+ Every distinct date in a log, record or programme note counts once.
+ An explicit "session 12" wins if it is higher — some coaches number
+ them, and their number beats our inference.
+ """
+ dates: set[str] = set()
+ explicit = 0
+ for entry in entries:
+ if entry.get("kind") not in ("progress", "program"):
+ continue
+ low = entry.get("raw", "").lower()
+ for pattern in _DATE_PATTERNS:
+ dates.update(m.group(0) for m in re.finditer(pattern, low))
+ for m in re.finditer(r"session\s*#?\s*(\d{1,3})", low):
+ explicit = max(explicit, int(m.group(1)))
+ return max(len(dates), explicit)
+
+
+def tier_for(sessions_logged: int) -> tuple[int, float]:
+ """Return (tier index, progress toward the next tier as 0..1)."""
+ index = 0
+ for i, tier in enumerate(TIERS):
+ if sessions_logged >= tier["at"]:
+ index = i
+ if index >= len(TIERS) - 1:
+ return index, 1.0
+ here, nxt = TIERS[index]["at"], TIERS[index + 1]["at"]
+ span = max(1, nxt - here)
+ return index, min(1.0, max(0.0, (sessions_logged - here) / span))
+
+
+def derive_athlete(
+ entries: Iterable[dict[str, Any]],
+ *,
+ name: str = "Athlete",
+ sessions_logged: int | None = None,
+) -> dict[str, Any]:
+ """
+ Build the athlete the UI draws, from the memory store alone.
+
+ Entries marked cleared are skipped — a resolved injury takes the
+ wraps back off, which is the point of a memory that updates instead
+ of only appending.
+ """
+ entries = list(entries)
+ live = [e for e in entries if not e.get("superseded_by")]
+ if sessions_logged is None:
+ sessions_logged = count_sessions(live)
+
+ traits: list[dict[str, Any]] = []
+ seen: set[str] = set()
+ for rule in TRAIT_RULES:
+ if rule.trait in seen:
+ continue
+ for entry in live:
+ if not rule.matches(entry):
+ continue
+ # A brace supersedes plain wraps — don't draw both.
+ if rule.trait == "wrist_wrap" and "wrist_brace" in seen:
+ break
+ traits.append({
+ "id": rule.trait,
+ "label": rule.label,
+ "severity": rule.severity,
+ "source_id": entry.get("id"),
+ "source_path": entry.get("path"),
+ "note": rule.note or f"From {entry.get('path')}",
+ })
+ seen.add(rule.trait)
+ break
+
+ tier, progress = tier_for(sessions_logged)
+ has_injury = any(e.get("kind") == "injury" for e in live)
+ has_record = any(e.get("kind") == "progress" for e in live)
+
+ return {
+ "name": name,
+ "tier": tier,
+ "tier_label": TIERS[tier]["label"],
+ "blurb": TIERS[tier]["blurb"],
+ "progress": round(progress, 3),
+ "sessions_logged": sessions_logged,
+ "plates": min(3, 1 + tier // 2),
+ "mood": "neutral" if has_injury else ("grin" if has_record else "neutral"),
+ "traits": traits,
+ }
+
+
+# ── Watching a store change ─────────────────────────────────────────
+# The memory API carries no per-entry timestamp, and the demo needs to
+# show a memory being *changed*, not merely present. So we fingerprint
+# every entry the UI has shown: what changed, when, and what it said
+# before. That is what fills "updated 20 s ago" and the "what this said
+# before" disclosure — real diffs, not decoration.
+
+
+class MemoryWatcher:
+ """Per-store fingerprints of everything the UI has shown so far."""
+
+ def __init__(self) -> None:
+ self._seen: dict[str, dict[str, dict[str, Any]]] = {}
+
+ def apply(self, store_id: str, entries: list[dict[str, Any]]) -> list[str]:
+ """
+ Stamp `entries` in place with `updated_at` / `previous` and return
+ the ids that are new or changed since the last call. The first
+ call for a store establishes the baseline and reports nothing as
+ fresh — everything there predates the page being open.
+ """
+ first_pass = store_id not in self._seen
+ seen = self._seen.setdefault(store_id, {})
+ stamp = now_iso()
+ fresh: list[str] = []
+
+ for entry in entries:
+ digest = hashlib.sha256(entry.get("raw", "").encode("utf-8")).hexdigest()
+ record = seen.get(entry["path"])
+ if record is None:
+ seen[entry["path"]] = {
+ "digest": digest, "at": stamp, "body": entry["body"], "previous": None,
+ }
+ if not first_pass:
+ fresh.append(entry["id"])
+ elif record["digest"] != digest:
+ record.update(
+ digest=digest, at=stamp, previous=record["body"], body=entry["body"],
+ )
+ fresh.append(entry["id"])
+ entry["updated_at"] = seen[entry["path"]]["at"]
+ entry["previous"] = seen[entry["path"]]["previous"]
+
+ # Anything that vanished was deleted — the "forget that" half.
+ live_paths = {e["path"] for e in entries}
+ for path in list(seen):
+ if path not in live_paths:
+ del seen[path]
+
+ return fresh
diff --git a/ui/README.md b/ui/README.md
new file mode 100644
index 0000000..d22e8d1
--- /dev/null
+++ b/ui/README.md
@@ -0,0 +1,154 @@
+# Tirma — the interface
+
+The web front end for the coaching agent. A personal trainer that remembers,
+with the memory store on screen and the athlete drawn from it.
+
+Built by team **Tirma**. Brand tier **TAM-50**, piece type **proto** — see
+[`tam-decision.yml`](./tam-decision.yml) for every brand decision and its
+justification.
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ TIRMA Personal Trainer Agent Athlete ▾ agent · store ● Live │
+├───────────────┬─────────────────────────────────┬───────────────────┤
+│ Sessions │ Conversation │ Context │
+│ │ │ │
+│ · this one │ You: my left knee hurt… │ [pixel athlete] │
+│ · last week │ ▏memory read /injuries.md │ Marco Ferreira │
+│ · intake │ Tirma: knee is now flagged… │ ▓▓▓▓░░ SOLID │
+│ │ ▏memory written /injuries.md │ wraps · sleeves │
+│ │ │ ─────────────────│
+│ │ [ tell Tirma how it went ] │ Memory store │
+│ │ │ · injuries.md │
+│ │ │ · equipment.md │
+├───────────────┴─────────────────────────────────┴───────────────────┤
+│ ⌗ An initiative by The Agile Monkeys │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+## Run it
+
+Both commands from the repository root.
+
+With the real agent — needs a workspace API key:
+
+```bash
+ANTHROPIC_API_KEY="sk-ant-..." uvicorn api.main:app --reload
+```
+
+Without one, on the scripted coach:
+
+```bash
+TIRMA_DEMO=1 uvicorn api.main:app --reload
+```
+
+Then open . The REST API and its docs are unchanged and
+still at `/docs`.
+
+`?intro=0` skips the brand intro, which is what you want while developing.
+
+## What the athlete is
+
+The pixel figure is not decoration and it is not random. Everything about it is
+read out of the client's memory store by
+[`api/ui_state.py`](../api/ui_state.py):
+
+| What you see | Where it comes from |
+| --- | --- |
+| Build, from Twig to Absolute Unit | dated sessions the coach logged in a training-log or programme entry |
+| Plates on the bar | the tier, so a stronger athlete lifts more |
+| Wrist wraps, knee sleeves, elbow sleeves, shoulder tape | an **injury** entry naming that body part |
+| Lifting belt | deadlifts, heavy squats or a lower back note |
+| Headband | conditioning or a running goal in the plan |
+| Shades | a personal record on file |
+| Coffee, bottle, notebook, banana, towel, chalk, the cat | the small human details in profile and preference entries |
+
+Two rules make this honest rather than cosmetic:
+
+- **A constraint stays in force until something clears it.** The same rule the
+ coach's system prompt runs on. Clearing is resolved per section, so a file
+ holding wrist + knee + lower back can close one and keep the other two.
+- **A partial clearance clears nothing.** "Cleared for neutral grip up to 16 kg,
+ loaded wrist extension still contraindicated" keeps the wraps on. Reading a
+ partial clearance as a general all-clear is exactly the failure the agent is
+ built to avoid, so the drawing must not commit it either.
+
+Clicking a trait chip finds the memory entry that put it there.
+
+## Where things live
+
+```
+api/
+ main.py their REST API · edited only to mount this interface
+ coach.py Managed Agents: sessions, memory stores, SSE (theirs)
+ registry.py one client → one memory store (theirs)
+ ui.py the endpoints this interface calls
+ ui_state.py memory → athlete. Pure functions, no network
+ ui_demo.py DemoCoach — same interface as Coach, no API key
+ conversations.py chat scrollback, JSON-backed
+ui/
+ index.html
+ sprite-lab.html every tier, pose and accessory at a readable size
+ static/css/ tokens.css = the TAM layer + the Tirma palette
+ static/js/ avatar.js = the sprite · app.js = the app · icons.js = Carbon
+ static/brand/ Tirma wordmark, mark, favicon
+ static/fonts/ IBM Plex Mono + Sans, woff2, under the OFL
+```
+
+Two things worth knowing before changing anything:
+
+- **`ui_state.py` is the single source of truth for the athlete.** The browser
+ renders what it is given and derives nothing. If the sprite and the memory
+ disagree, the bug is in that one file.
+- **`DemoCoach` is interface-compatible with `Coach`.** Same methods, same SSE
+ frames. `api/ui.py` never branches on which one it has, so the demo path and
+ the real path exercise the same code.
+
+## The endpoints the browser uses
+
+All under `/ui/api`. The coaching turn relays the coach's own frames and appends
+its own, so the panel updates because the store changed — not because the
+interface guessed.
+
+| | |
+| --- | --- |
+| `GET /bootstrap` | everything for a cold start in one round trip |
+| `GET /athletes/{client_id}` | switch client: their memory, athlete, threads |
+| `POST /athletes` | new client, with a memory store of their own |
+| `GET /conversations/{id}` | one thread's messages |
+| `POST /conversations` | start a thread |
+| `POST /conversations/{id}/messages` | run a turn, stream it back |
+| `GET /athletes/{id}/memory` | read the store on demand |
+| `DELETE /athletes/{id}/memory/{memory_id}` | forget one entry |
+
+Frames on a turn, in order:
+
+```
+status session created, work starting (from Coach)
+tool_use a tool call; is_memory marks /mnt/memory/ reads and writes
+message a chunk of the reply
+done full answer + the memory paths written
+memory.updated the store re-read, with fresh_ids for what changed
+athlete.updated the figure re-derived from it
+conversation.updated
+turn.end
+error may appear at any point; the turn still finishes cleanly
+```
+
+## Checked
+
+Dark and light, 1440×900 and 1280×720, `prefers-reduced-motion` on and off,
+keyboard-only through every control. Session history, memory panel and athlete
+all survive a reload. A failed turn puts the typed message back in the box
+rather than losing it.
+
+Not checked: a real mobile device, and measured contrast ratios — see
+`warnings` in `tam-decision.yml`.
+
+## The sprite lab
+
+`/sprite-lab.html` renders the athlete outside the app: every tier, every frame
+of the rep, every accessory on its own, and the fully-loaded case. Filter it
+with `?group=tiers|phases|traits|loaded`. It is where two real bugs were caught
+— plates clipping off the top of the canvas at lockout, and shoulder tape being
+drawn under the arm.
diff --git a/ui/index.html b/ui/index.html
new file mode 100644
index 0000000..9eee39a
--- /dev/null
+++ b/ui/index.html
@@ -0,0 +1,153 @@
+
+
+
+ `
+ : "";
+
+ // A coach's training file runs long. Clamp it and let the reader open
+ // it — the panel is a summary of the store, not a document viewer.
+ const long = (entry.body || "").length > 240;
+ if (long) li.dataset.clamped = "true";
+
+ // Constraints closed inside a file that still holds live ones. This is
+ // the reconciliation made visible: the knee is cleared, the wrist is not.
+ const cleared = (entry.cleared || []).length
+ ? `
Everything in the right-hand panel came out of earlier sessions.
+ Say how training went, or report a niggle, and watch the memory
+ store — and the athlete — change as it lands.
+
`;
+ return;
+ }
+ el.thread.replaceChildren(...state.messages.map(messageNode));
+ scrollThread(true);
+}
+
+function append(node) {
+ if (!state.messages.length && !el.thread.querySelector(".msg, .trace")) {
+ el.thread.innerHTML = "";
+ }
+ el.thread.append(node);
+ scrollThread();
+ return node;
+}
+
+/* Working row. A real turn takes 30–90 s, so it counts up: a silent
+ minute reads as broken, a counted one reads as work. */
+let workTimer = null;
+function showWorking() {
+ hideWorking();
+ const started = Date.now();
+ const div = document.createElement("div");
+ div.className = "msg msg--agent";
+ div.id = "workingRow";
+ div.innerHTML = `
+
Add one with the + beside the picker. Each athlete gets a memory
+ store of their own, so one person's injuries can never turn up in
+ someone else's plan.
+
`;
+ setLive("live", state.demo ? "Demo" : "Ready");
+ return;
+ }
+
+ adopt(data);
+ // Reset the previous-athlete comparison so the first render never
+ // claims a level-up that only happened because the page loaded.
+ state.athlete = null;
+ renderAthlete(data.athlete);
+ setLive("live", state.demo ? "Demo" : "Live");
+ el.athleteBubble.dataset.show = "false";
+}
+
+async function switchAthlete(id) {
+ setLive("connecting", "Loading");
+ try {
+ state.athlete = null;
+ adopt(await api(`/ui/api/athletes/${encodeURIComponent(id)}`));
+ setLive("live", state.demo ? "Demo" : "Live");
+ } catch {
+ setLive("offline", "Failed");
+ toast("Could not load that athlete", "warning");
+ }
+}
+
+async function addAthlete() {
+ const name = prompt("Athlete's name");
+ if (!name || !name.trim()) return;
+ try {
+ const data = await api("/ui/api/athletes", {
+ method: "POST",
+ body: JSON.stringify({ name: name.trim() }),
+ });
+ state.athletes.push({ id: data.active.id, name: data.active.name, store_id: data.active.store_id });
+ el.picker.innerHTML = state.athletes
+ .map((a) => ``)
+ .join("");
+ state.athlete = null;
+ adopt(data);
+ toast(`${data.active.name} has a memory store of their own`, "add");
+ el.input.focus();
+ } catch (err) {
+ toast("Could not add that athlete", "warning");
+ }
+}
+
+async function openSession(id) {
+ try {
+ const data = await api(`/ui/api/conversations/${encodeURIComponent(id)}`);
+ state.conversationId = id;
+ state.messages = data.messages || [];
+ renderSessions();
+ renderSessionHeader();
+ renderThread();
+ } catch {
+ toast("Could not open that session", "warning");
+ }
+}
+
+async function newSession() {
+ if (!state.active) return;
+ const data = await api("/ui/api/conversations", {
+ method: "POST",
+ body: JSON.stringify({ client_id: state.active.id }),
+ });
+ state.conversations.unshift(data.conversation);
+ state.conversationId = data.conversation.id;
+ state.messages = [];
+ renderSessions();
+ renderSessionHeader();
+ renderThread();
+ el.input.focus();
+}
+
+/* ── the turn ────────────────────────────────────────────────────── */
+
+async function send(text) {
+ const body = text.trim();
+ if (!body || state.busy || !state.active) return;
+
+ if (!state.conversationId) await newSession();
+
+ setBusy(true);
+ el.input.value = "";
+ autogrow();
+
+ const mine = { id: `local-${Date.now()}`, role: "user", text: body };
+ state.messages.push(mine);
+ append(messageNode(mine));
+ showWorking();
+
+ let replyNode = null;
+ let reply = "";
+ let failed = null;
+
+ try {
+ const res = await fetch(
+ `/ui/api/conversations/${encodeURIComponent(state.conversationId)}/messages`,
+ { method: "POST", headers: { "content-type": "application/json" },
+ body: JSON.stringify({ text: body }) },
+ );
+ if (!res.ok || !res.body) throw new Error(String(res.status));
+
+ for await (const { event, data } of frames(res)) {
+ switch (event) {
+ case "tool_use":
+ hideWorking();
+ append(traceNode(data));
+ if (data.is_memory && /write|edit|create/i.test(data.tool || "")) say("noted");
+ if (!replyNode) showWorking();
+ break;
+
+ case "message":
+ hideWorking();
+ if (!replyNode) {
+ replyNode = append(messageNode({ id: `reply-${Date.now()}`, role: "agent", text: "" }));
+ }
+ reply += data.text || "";
+ replyNode.querySelector(".msg__body").innerHTML = inline(reply);
+ scrollThread();
+ break;
+
+ case "memory.updated":
+ renderMemory(data.memory, data.fresh_ids || []);
+ break;
+
+ case "athlete.updated":
+ renderAthlete(data.athlete);
+ break;
+
+ case "conversation.updated": {
+ const i = state.conversations.findIndex((c) => c.id === data.conversation.id);
+ if (i >= 0) state.conversations[i] = data.conversation;
+ else state.conversations.unshift(data.conversation);
+ renderSessions();
+ renderSessionHeader();
+ break;
+ }
+
+ case "error":
+ failed = data.detail || "the turn did not finish";
+ break;
+
+ case "done":
+ if (data.answer && !reply) {
+ reply = data.answer;
+ if (!replyNode) {
+ replyNode = append(messageNode({ id: "reply", role: "agent", text: "" }));
+ }
+ replyNode.querySelector(".msg__body").innerHTML = inline(reply);
+ }
+ break;
+ }
+ }
+ } catch {
+ failed = failed || "the message did not reach Tirma";
+ }
+
+ hideWorking();
+ setBusy(false);
+
+ if (reply) state.messages.push({ id: "reply", role: "agent", text: reply });
+
+ if (failed && !reply) {
+ // The user's text goes back in the box. Losing what someone typed
+ // because a turn fell over is not acceptable.
+ const node = append(messageNode({
+ id: `err-${Date.now()}`, role: "agent",
+ text: `That turn did not finish — ${failed}. Your message is back in the box, so you can send it again.`,
+ }));
+ node.dataset.error = "true";
+ el.input.value = body;
+ autogrow();
+ } else if (failed) {
+ toast("The turn ended early — part of the reply may be missing", "warning");
+ }
+
+ el.input.focus();
+}
+
+/* ── brand intro ─────────────────────────────────────────────────── */
+
+let introTimer = null;
+
+function openIntro() {
+ el.intro.hidden = false;
+ el.intro.dataset.closing = "false";
+ if (!introStage) {
+ introStage = new AvatarStage(el.introCanvas, { scale: 5, autoScale: false, period: 1500 });
+ introStage.setSurface({ floor: "#181B22", floorEdge: "#2A2F3A", shadow: "#000000" });
+ introStage.setSpec({
+ tier: 3, progress: 0.5, plates: 2, mood: "grin",
+ traits: ["wrist_wrap", "headband", "belt"],
+ });
+ }
+ introStage.start();
+ el.introSkip.focus();
+ clearTimeout(introTimer);
+ introTimer = setTimeout(closeIntro, reduced ? 1500 : 3800);
+}
+
+function closeIntro() {
+ clearTimeout(introTimer);
+ if (el.intro.hidden) return;
+ el.intro.dataset.closing = "true";
+ setTimeout(() => {
+ el.intro.hidden = true;
+ introStage?.stop();
+ el.input.focus({ preventScroll: true });
+ }, reduced ? 0 : 320);
+}
+
+/* ── composer plumbing ───────────────────────────────────────────── */
+
+function autogrow() {
+ el.input.style.height = "auto";
+ el.input.style.height = `${Math.min(200, el.input.scrollHeight)}px`;
+}
+
+/* ── boot ────────────────────────────────────────────────────────── */
+
+function wire() {
+ el.themeToggle.addEventListener("click", () =>
+ applyTheme(document.documentElement.dataset.theme === "dark" ? "light" : "dark"));
+
+ el.replayIntro.innerHTML = icon("restart", { size: 20 });
+ el.replayIntro.addEventListener("click", openIntro);
+
+ el.newAthlete.innerHTML = icon("add", { size: 20 });
+ el.newAthlete.addEventListener("click", addAthlete);
+
+ el.newSession.innerHTML = icon("add", { size: 20 });
+ el.newSession.addEventListener("click", () =>
+ newSession().catch(() => toast("Could not start a session", "warning")));
+
+ el.sendBtn.insertAdjacentHTML("afterbegin", icon("send", { size: 16 }));
+
+ el.picker.addEventListener("change", (e) => switchAthlete(e.target.value));
+
+ el.sessionList.addEventListener("click", (e) => {
+ const btn = e.target.closest(".session");
+ if (btn && btn.dataset.id !== state.conversationId) openSession(btn.dataset.id);
+ });
+
+ el.traits.addEventListener("click", (e) => {
+ const btn = e.target.closest(".trait");
+ if (btn) highlightMemory(btn.dataset.memory);
+ });
+
+ el.memoryList.addEventListener("click", (e) => {
+ const btn = e.target.closest(".mem__more");
+ if (!btn) return;
+ const card = btn.closest(".mem");
+ const open = card.dataset.clamped === "false";
+ card.dataset.clamped = open ? "true" : "false";
+ btn.textContent = open ? "Read the whole entry" : "Show less";
+ });
+
+ el.suggests.addEventListener("click", (e) => {
+ const btn = e.target.closest("[data-say]");
+ if (btn) send(btn.dataset.say);
+ });
+
+ el.form.addEventListener("submit", (e) => { e.preventDefault(); send(el.input.value); });
+
+ el.input.addEventListener("input", autogrow);
+ el.input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
+ e.preventDefault();
+ send(el.input.value);
+ }
+ });
+
+ el.introSkip.addEventListener("click", closeIntro);
+ el.intro.addEventListener("click", (e) => { if (e.target === el.intro) closeIntro(); });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && !el.intro.hidden) closeIntro();
+ });
+
+ window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", (e) => {
+ let saved = null;
+ try { saved = localStorage.getItem("tirma.theme"); } catch {}
+ if (!saved) applyTheme(e.matches ? "light" : "dark");
+ });
+}
+
+function bootAvatar() {
+ stage = new AvatarStage(el.athleteCanvas, { autoScale: true });
+ stage.setSurface(surfaceColours());
+ stage.onRep = (n) => {
+ // An occasional grunt. Sparse on purpose — character, not a notification.
+ if (n % 5 === 0) say(AvatarStage.randomGrunt());
+ };
+ stage.start();
+ document.addEventListener("visibilitychange", () => {
+ if (document.hidden) stage.stop(); else stage.start();
+ });
+}
+
+async function main() {
+ initTheme();
+ wire();
+ bootAvatar();
+
+ if (new URLSearchParams(location.search).get("intro") !== "0") openIntro();
+
+ try {
+ await boot();
+ } catch (err) {
+ setLive("offline", "No server");
+ el.thread.innerHTML = `
+
+
Tirma is not answering.
+
The page loaded but the API behind it did not. Start it from the
+ repository root with uvicorn api.main:app --reload and
+ reload this page.
+
`;
+ }
+
+ // Keep the timestamps honest without a reload.
+ setInterval(() => {
+ if (state.memory.updated_at) {
+ el.contextStamp.textContent = `updated ${ago(state.memory.updated_at)}`;
+ }
+ renderSessions();
+ }, 30000);
+}
+
+main();
diff --git a/ui/static/js/avatar.js b/ui/static/js/avatar.js
new file mode 100644
index 0000000..715d5bb
--- /dev/null
+++ b/ui/static/js/avatar.js
@@ -0,0 +1,593 @@
+/* ─────────────────────────────────────────────────────────────────
+ Tirma · pixel athlete
+ A procedural pixel-art sprite that presses a barbell overhead.
+
+ Two things drive what you see, and nothing else:
+ · build — how many sessions have been logged (0 → 5). Shoulder
+ width, arm and leg thickness, neck, plate count.
+ · traits — accessories derived from the memory store. A wrist
+ injury puts wraps on the wrists; a belt, knee sleeves,
+ shoulder tape, a headband and the floor props all come
+ from the same place.
+
+ Everything is drawn as integer rectangles on a 104×76 logical grid
+ and scaled by a whole number, so the pixels stay square and hard.
+ ───────────────────────────────────────────────────────────────── */
+
+const W = 104; // logical grid width
+const H = 76; // logical grid height
+const GROUND = 66; // y of the floor line
+const CX = 52; // centre of the athlete
+
+const SHOULDER_Y = 28;
+const HIP_Y = 44;
+const HEAD_TOP = 15;
+const HEAD_H = 11;
+const BAR_DOWN_Y = 30; // racked at the clavicle, just under the chin
+const BAR_UP_Y = 10; // locked out — high enough that the plates still fit
+
+export const TIERS = [
+ { label: "Twig", blurb: "Day one. Bar only." },
+ { label: "Sprout", blurb: "Something is happening." },
+ { label: "Solid", blurb: "Shirts fit differently now." },
+ { label: "Buff", blurb: "Doorframes are a consideration." },
+ { label: "Beast", blurb: "The bar bends a little." },
+ { label: "Absolute Unit", blurb: "Gravity files a complaint." },
+];
+
+const GRUNTS = ["hnngh", "one more", "oof", "let's go", "easy", "ngh", "yep"];
+
+/* Sprite palette. Skin, hair and kit are fixed — the athlete is the
+ same character in both themes. Only the floor and shadow follow the
+ surface, so they arrive from the caller. */
+const KIT = {
+ skin: "#F0C396",
+ skinShade: "#CE9C6E",
+ hair: "#3A2A20",
+ hairShade: "#291C15",
+ tank: "#8C7BFF",
+ tankShade: "#6455D6",
+ shorts: "#242833",
+ shortsShade: "#171A22",
+ shoe: "#EDEFF2",
+ shoeSole: "#9AA0AC",
+ metal: "#B4BAC6",
+ metalShade: "#7E858F",
+ plate: "#22262F",
+ plateRim: "#8C7BFF",
+ band: "#F7F8FA",
+ bandShade: "#C9CDD4",
+ sleeve: "#4A5160",
+ belt: "#6B4A2F",
+ beltBuckle: "#D8B25E",
+ tape: "#79A2FF",
+ shades: "#101218",
+ chalk: "#FFFFFF",
+ sweat: "#9FD3F5",
+ mouth: "#7A3B33",
+};
+
+/* Trait catalogue. `on` marks the ones that ride on the body; the rest
+ are props that sit on the floor beside the platform. */
+export const TRAIT_LABELS = {
+ wrist_wrap: "Wrist wraps",
+ wrist_brace: "Wrist brace",
+ knee_sleeve: "Knee sleeves",
+ elbow_sleeve: "Elbow sleeves",
+ belt: "Lifting belt",
+ shoulder_tape: "Shoulder tape",
+ headband: "Headband",
+ cap: "Cap",
+ shades: "Shades",
+ chalk: "Chalk",
+ water_bottle: "Water bottle",
+ coffee: "Coffee",
+ towel: "Towel",
+ notebook: "Training log",
+ banana: "Banana",
+ cat: "Cat",
+};
+
+/* Deterministic jitter — a stable hash, so the wobble is consistent
+ frame to frame instead of flickering. */
+function noise(seed) {
+ let x = Math.sin(seed * 127.1) * 43758.5453;
+ return (x - Math.floor(x)) * 2 - 1;
+}
+
+const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
+const easeOut = (t) => 1 - Math.pow(1 - t, 2.2);
+const easeIn = (t) => Math.pow(t, 1.7);
+
+/* Rep cycle → how far the bar has travelled (0 = racked, 1 = locked out) */
+function liftCurve(p) {
+ if (p < 0.40) return easeOut(p / 0.40);
+ if (p < 0.54) return 1;
+ if (p < 0.90) return 1 - easeIn((p - 0.54) / 0.36);
+ return 0;
+}
+
+export class AvatarStage {
+ /**
+ * @param {HTMLCanvasElement} canvas
+ * @param {{scale?:number, autoScale?:boolean, period?:number}} opts
+ */
+ constructor(canvas, opts = {}) {
+ this.canvas = canvas;
+ this.ctx = canvas.getContext("2d");
+ this.scale = opts.scale || 4;
+ this.autoScale = opts.autoScale !== false;
+ // Height budget in CSS px. The athlete must not push the memory
+ // panel below the fold — the memory is the point of the product.
+ this.maxHeight = opts.maxHeight || 232;
+ this.period = opts.period || 1900; // ms per rep
+ this.spec = {
+ tier: 0, progress: 0, traits: [], mood: "neutral",
+ sessions_logged: 0, plates: 1,
+ };
+ this.surface = { floor: "#181B22", floorEdge: "#2A2F3A", shadow: "#0A0B0E" };
+ this.reps = 0;
+ this.flashUntil = 0;
+ this.onRep = null;
+ this.running = false;
+ this._t0 = 0;
+ this._lastPhase = 0;
+ this._raf = null;
+ this.reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ if (this.autoScale) {
+ this._ro = new ResizeObserver(() => this.fit());
+ this._ro.observe(canvas.parentElement || canvas);
+ }
+ this.fit();
+ }
+
+ fit() {
+ const host = this.canvas.parentElement || this.canvas;
+ const avail = host.clientWidth || W * this.scale;
+ if (this.autoScale) {
+ this.scale = clamp(
+ Math.min(Math.floor(avail / W), Math.floor(this.maxHeight / H)),
+ 2, 8,
+ );
+ }
+ this.canvas.width = W * this.scale;
+ this.canvas.height = H * this.scale;
+ this.canvas.style.width = `${W * this.scale}px`;
+ this.canvas.style.height = `${H * this.scale}px`;
+ this.ctx.imageSmoothingEnabled = false;
+ this.draw(this._lastPhase);
+ }
+
+ setSpec(spec) {
+ this.spec = { ...this.spec, ...spec };
+ this.draw(this._lastPhase);
+ }
+
+ setSurface(surface) {
+ this.surface = { ...this.surface, ...surface };
+ this.draw(this._lastPhase);
+ }
+
+ /** One short flash when the athlete moves up a tier. No confetti. */
+ celebrate() {
+ this.flashUntil = performance.now() + 900;
+ if (!this.running) this.draw(this._lastPhase);
+ }
+
+ start() {
+ if (this.running) return;
+ this.running = true;
+ if (this.reduced) {
+ // Hold the top of the press. Still legible, no motion.
+ this.draw(0.47);
+ return;
+ }
+ this._t0 = performance.now();
+ const tick = (now) => {
+ if (!this.running) return;
+ const p = ((now - this._t0) % this.period) / this.period;
+ if (p < this._lastPhase) {
+ this.reps += 1;
+ if (this.onRep) this.onRep(this.reps);
+ }
+ this._lastPhase = p;
+ this.draw(p);
+ this._raf = requestAnimationFrame(tick);
+ };
+ this._raf = requestAnimationFrame(tick);
+ }
+
+ stop() {
+ this.running = false;
+ if (this._raf) cancelAnimationFrame(this._raf);
+ }
+
+ destroy() {
+ this.stop();
+ if (this._ro) this._ro.disconnect();
+ }
+
+ /* ── drawing primitives ───────────────────────────────────────── */
+
+ px(x, y, w, h, color) {
+ const S = this.scale;
+ this.ctx.fillStyle = color;
+ this.ctx.fillRect(Math.round(x) * S, Math.round(y) * S, Math.round(w) * S, Math.round(h) * S);
+ }
+
+ /** Thick line with a square brush — chunky pixel limbs. */
+ line(x0, y0, x1, y1, thick, color) {
+ x0 = Math.round(x0); y0 = Math.round(y0);
+ x1 = Math.round(x1); y1 = Math.round(y1);
+ const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
+ const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
+ let err = dx - dy;
+ const off = Math.floor(thick / 2);
+ for (;;) {
+ this.px(x0 - off, y0 - off, thick, thick, color);
+ if (x0 === x1 && y0 === y1) break;
+ const e2 = 2 * err;
+ if (e2 > -dy) { err -= dy; x0 += sx; }
+ if (e2 < dx) { err += dx; y0 += sy; }
+ }
+ }
+
+ has(trait) {
+ return (this.spec.traits || []).includes(trait);
+ }
+
+ /* ── the frame ────────────────────────────────────────────────── */
+
+ draw(phase = 0) {
+ const ctx = this.ctx;
+ const S = this.scale;
+ ctx.clearRect(0, 0, W * S, H * S);
+
+ const build = clamp((this.spec.tier || 0) + (this.spec.progress || 0), 0, 5);
+ const L = this.reduced ? 1 : liftCurve(phase);
+
+ // Body geometry grows with build.
+ const g = {
+ shoulderW: 16 + Math.round(build * 3.2),
+ waistW: 12 + Math.round(build * 1.6),
+ neckW: 3 + Math.round(build * 0.8),
+ armW: 3 + Math.round(build * 0.8),
+ legW: 5 + Math.round(build * 1.0),
+ // A plate has to read as a disc, not a sliver. Height is capped so a
+ // fully loaded bar still clears the top of the grid at lockout — a
+ // clipped plate looks like a rendering fault, not a heavy lift.
+ plateW: 3 + Math.round(build * 0.8),
+ plateH: 9 + Math.round(build * 1.8),
+ };
+
+ // Leg drive: the whole body sinks at the bottom of the rep.
+ const dip = Math.round((1 - L) * 2);
+ // The bar wobbles when there is no one strong holding it.
+ const barTilt = Math.round(noise(Math.floor(phase * 6) + this.reps) * (1.6 - build * 0.28));
+ const barY = Math.round(BAR_DOWN_Y + (BAR_UP_Y - BAR_DOWN_Y) * L);
+
+ this.drawFloor();
+ this.drawProps();
+ this.drawShadow(g);
+ this.drawLegs(g, dip);
+ this.drawTorso(g, dip);
+ this.drawHead(g, dip, L);
+ this.drawArms(g, dip, barY, barTilt);
+ this.drawBar(g, barY, barTilt);
+ this.drawFx(g, dip, L, barY, phase);
+
+ if (performance.now() < this.flashUntil) this.drawFlash();
+ }
+
+ drawFloor() {
+ const { floor, floorEdge } = this.surface;
+ this.px(14, GROUND, 76, 5, floor);
+ this.px(14, GROUND, 76, 1, floorEdge);
+ // Rubber-mat dashes, so the platform reads as a surface not a slab.
+ for (let x = 17; x < 88; x += 6) this.px(x, GROUND + 2, 3, 1, floorEdge);
+ this.px(0, GROUND + 4, W, 1, floorEdge);
+ }
+
+ drawShadow(g) {
+ const w = g.shoulderW + 8;
+ this.px(CX - w / 2, GROUND - 1, w, 1, this.surface.shadow);
+ }
+
+ drawLegs(g, dip) {
+ const { legW } = g;
+ const hip = HIP_Y + dip;
+ const kneeY = hip + 11 + dip;
+ const ankleY = GROUND - 3;
+ const stance = Math.round(g.shoulderW * 0.28);
+
+ for (const s of [-1, 1]) {
+ const hx = CX + s * stance;
+ const kx = CX + s * (stance + 1);
+ const ax = CX + s * (stance + 1);
+ const outer = s > 0 ? Math.floor(legW / 2) - 1 : -Math.floor(legW / 2);
+ this.line(hx, hip, kx, kneeY, legW, KIT.shorts);
+ this.px(hx + outer, hip, 1, kneeY - hip, KIT.shortsShade);
+ this.line(kx, kneeY, ax, ankleY, legW - 1, KIT.skin);
+ this.px(kx + outer, kneeY, 1, ankleY - kneeY, KIT.skinShade);
+
+ if (this.has("knee_sleeve")) {
+ this.px(kx - Math.floor(legW / 2), kneeY - 2, legW, 5, KIT.sleeve);
+ }
+ // Shoe
+ this.px(ax - Math.floor(legW / 2) - 1, ankleY, legW + 2, 2, KIT.shoe);
+ this.px(ax - Math.floor(legW / 2) - 1, ankleY + 2, legW + 2, 1, KIT.shoeSole);
+ }
+ }
+
+ drawTorso(g, dip) {
+ const { shoulderW, waistW } = g;
+ const top = SHOULDER_Y + dip;
+ const bottom = HIP_Y + dip;
+ const rows = bottom - top;
+
+ // Taper from shoulders to waist, one row at a time — a pixel V.
+ // The tank runs the full torso; the shorts waistband closes it off, so
+ // there is no accidental crop top.
+ for (let i = 0; i < rows; i++) {
+ const t = i / (rows - 1);
+ const w = Math.round(shoulderW - (shoulderW - waistW) * Math.pow(t, 0.75));
+ const x = CX - Math.floor(w / 2);
+ const waistband = i >= rows - 3;
+ this.px(x, top + i, w, 1, waistband ? KIT.shorts : KIT.tank);
+ this.px(x + w - 1, top + i, 1, 1, waistband ? KIT.shortsShade : KIT.tankShade);
+ // Tank straps leave the deltoids bare at the very top.
+ if (i < 2) {
+ this.px(x, top + i, 3, 1, KIT.skin);
+ this.px(x + w - 3, top + i, 3, 1, KIT.skin);
+ }
+ }
+
+ // A chest split, once there is a chest to split.
+ if (shoulderW > 20) this.px(CX, top + 3, 1, 5, KIT.tankShade);
+ if (shoulderW > 26) {
+ this.px(CX - 4, top + 2, 3, 1, KIT.tankShade);
+ this.px(CX + 2, top + 2, 3, 1, KIT.tankShade);
+ }
+
+ if (this.has("belt")) {
+ const w = waistW + 2;
+ this.px(CX - Math.floor(w / 2), bottom - 4, w, 3, KIT.belt);
+ this.px(CX - 1, bottom - 4, 2, 3, KIT.beltBuckle);
+ }
+ // Neck
+ this.px(CX - Math.floor(g.neckW / 2), top - 2, g.neckW, 2, KIT.skin);
+ }
+
+ drawHead(g, dip, L) {
+ const hx = CX - 6;
+ const hy = HEAD_TOP + dip;
+
+ // Skull
+ this.px(hx, hy, 12, HEAD_H, KIT.skin);
+ this.px(hx + 11, hy + 2, 1, HEAD_H - 3, KIT.skinShade);
+ this.px(hx - 1, hy + 4, 1, 2, KIT.skin); // ears
+ this.px(hx + 12, hy + 4, 1, 2, KIT.skin);
+
+ // Hair
+ if (!this.has("cap")) {
+ this.px(hx, hy - 1, 12, 3, KIT.hair);
+ this.px(hx, hy + 2, 3, 1, KIT.hair);
+ this.px(hx + 9, hy + 2, 3, 1, KIT.hairShade);
+ }
+
+ const straining = L > 0.55;
+ const mood = this.spec.mood || "neutral";
+ const eyeY = hy + 4;
+
+ if (this.has("shades")) {
+ this.px(hx + 1, eyeY - 1, 10, 4, KIT.shades);
+ this.px(hx + 5, eyeY, 2, 1, KIT.metalShade);
+ } else if (straining) {
+ // Squeezed shut, plus angry brows. This is the funny bit.
+ this.px(hx + 2, eyeY + 1, 3, 1, KIT.hairShade);
+ this.px(hx + 7, eyeY + 1, 3, 1, KIT.hairShade);
+ this.px(hx + 2, eyeY - 1, 2, 1, KIT.hair);
+ this.px(hx + 8, eyeY - 1, 2, 1, KIT.hair);
+ } else {
+ const blink = Math.floor(performance.now() / 220) % 22 === 0;
+ const h = blink ? 1 : 2;
+ const y = blink ? eyeY + 1 : eyeY;
+ this.px(hx + 3, y, 2, h, KIT.shades);
+ if (mood === "wink") this.px(hx + 7, eyeY + 1, 2, 1, KIT.shades);
+ else this.px(hx + 7, y, 2, h, KIT.shades);
+ }
+
+ // Mouth
+ const my = hy + 8;
+ if (straining) {
+ this.px(hx + 4, my - 1, 4, 3, KIT.mouth); // open, mid-grunt
+ this.px(hx + 5, my, 2, 1, KIT.chalk); // teeth
+ } else if (mood === "grin") {
+ this.px(hx + 4, my, 4, 1, KIT.mouth);
+ this.px(hx + 3, my - 1, 1, 1, KIT.mouth);
+ this.px(hx + 8, my - 1, 1, 1, KIT.mouth);
+ } else {
+ this.px(hx + 5, my, 3, 1, KIT.mouth);
+ }
+
+ if (this.has("headband")) {
+ this.px(hx - 1, hy + 1, 14, 2, KIT.tank);
+ this.px(hx - 1, hy + 2, 14, 1, KIT.tankShade);
+ }
+ if (this.has("cap")) {
+ this.px(hx - 1, hy - 2, 14, 4, KIT.tank);
+ this.px(hx - 1, hy + 1, 14, 1, KIT.tankShade);
+ this.px(hx + 11, hy + 2, 5, 1, KIT.tank); // brim
+ }
+ }
+
+ drawArms(g, dip, barY, barTilt) {
+ const { armW, shoulderW } = g;
+ const grip = Math.floor(shoulderW / 2) + 5;
+
+ for (const s of [-1, 1]) {
+ const sx = CX + s * (Math.floor(shoulderW / 2) - 1);
+ const sy = SHOULDER_Y + dip + 2;
+ const handX = CX + s * grip;
+ const handY = barY + 1 + s * barTilt;
+
+ // Elbow flares out and down when racked, tucks under when locked out.
+ const midX = (sx + handX) / 2;
+ const midY = (sy + handY) / 2;
+ const flare = clamp((barY - BAR_UP_Y) / (BAR_DOWN_Y - BAR_UP_Y), 0, 1);
+ const ex = Math.round(midX + s * (2 + flare * 5));
+ const ey = Math.round(midY + flare * 5);
+
+ this.line(sx, sy, ex, ey, armW, KIT.skin);
+ this.line(ex, ey, handX, handY, armW - 1, KIT.skin);
+ this.px(ex, ey, 1, 2, KIT.skinShade);
+
+ // Bicep swells at the top of the press once there is a bicep.
+ if (g.shoulderW > 22) {
+ this.px(Math.round((sx + ex) / 2) - 1, Math.round((sy + ey) / 2) - 1, 2, 2, KIT.skinShade);
+ }
+ if (this.has("elbow_sleeve")) {
+ this.px(ex - Math.floor(armW / 2), ey - 1, armW, 4, KIT.sleeve);
+ }
+ if (this.has("wrist_brace")) {
+ this.line(handX, handY, Math.round((ex + handX) / 2), Math.round((ey + handY) / 2), armW, KIT.band);
+ } else if (this.has("wrist_wrap")) {
+ const wx = Math.round(handX - s * 1);
+ this.px(wx - Math.floor(armW / 2), handY + 1, armW + 1, 3, KIT.band);
+ this.px(wx - Math.floor(armW / 2), handY + 3, armW + 1, 1, KIT.bandShade);
+ }
+ // Fist over the bar
+ this.px(handX - 1, handY - 1, 3, 3, KIT.skin);
+
+ // Tape goes on last, over the deltoid — drawn before the arm it
+ // would be hidden by it.
+ if (s > 0 && this.has("shoulder_tape")) {
+ for (let i = 0; i < 5; i++) this.px(sx - 3 + i, sy - 2 + i, 2, 1, KIT.tape);
+ }
+ }
+ }
+
+ drawBar(g, barY, barTilt) {
+ const grip = Math.floor(g.shoulderW / 2) + 5;
+ const plates = clamp(this.spec.plates || 1, 1, 3);
+ const inner = grip + 3; // first plate starts here
+ const half = inner + plates * (g.plateW + 1); // sleeve runs through them
+
+ // Shaft. Drawn as one tilted line so both ends stay on the bar.
+ this.line(CX - half, barY - barTilt, CX + half, barY + barTilt, 2, KIT.metal);
+
+ for (const s of [-1, 1]) {
+ const tilt = s * barTilt;
+ // Collar, just inside the plates.
+ this.px(CX + s * (inner - 2) - (s < 0 ? 1 : 0), barY - 1 + tilt, 2, 4, KIT.metalShade);
+
+ for (let i = 0; i < plates; i++) {
+ const from = inner + i * (g.plateW + 1);
+ const x = s > 0 ? CX + from : CX - from - g.plateW;
+ const h = g.plateH - i * 3; // outer plates are smaller
+ // Centred on the shaft, which sits at barY-1..barY.
+ const y = barY + tilt - Math.floor(h / 2);
+ this.px(x, y, g.plateW, h, KIT.plate);
+ this.px(x, y, g.plateW, 1, KIT.plateRim); // rims read as the lip
+ this.px(x, y + h - 1, g.plateW, 1, KIT.plateRim);
+ this.px(x + g.plateW - 1, y + 1, 1, h - 2, KIT.metalShade); // outer shading
+ }
+ }
+ }
+
+ drawProps() {
+ const y = GROUND;
+ if (this.has("water_bottle")) {
+ this.px(19, y - 9, 4, 9, KIT.tape);
+ this.px(20, y - 11, 2, 2, KIT.metalShade);
+ this.px(19, y - 6, 4, 1, KIT.chalk);
+ }
+ if (this.has("coffee")) {
+ this.px(26, y - 5, 5, 5, KIT.chalk);
+ this.px(26, y - 6, 5, 1, KIT.metalShade);
+ this.px(31, y - 4, 1, 2, KIT.metalShade);
+ this.px(28, y - 9, 1, 2, KIT.metalShade); // steam
+ this.px(29, y - 11, 1, 1, KIT.metalShade);
+ }
+ if (this.has("banana")) {
+ this.px(34, y - 2, 5, 2, KIT.beltBuckle);
+ this.px(33, y - 3, 1, 1, KIT.beltBuckle);
+ this.px(39, y - 3, 1, 1, KIT.hairShade);
+ }
+ if (this.has("towel")) {
+ this.px(80, y - 3, 8, 3, KIT.band);
+ this.px(80, y - 1, 8, 1, KIT.bandShade);
+ }
+ if (this.has("notebook")) {
+ this.px(70, y - 4, 8, 4, KIT.chalk);
+ this.px(71, y - 3, 6, 1, KIT.metalShade);
+ this.px(71, y - 2, 4, 1, KIT.metalShade);
+ }
+ if (this.has("chalk")) {
+ this.px(90, y - 3, 5, 3, KIT.chalk);
+ this.px(90, y - 1, 5, 1, KIT.bandShade);
+ }
+ if (this.has("cat")) {
+ const cx = 92, cy = y - 6;
+ this.px(cx, cy + 2, 7, 4, KIT.hairShade); // body
+ this.px(cx + 4, cy, 4, 4, KIT.hairShade); // head
+ this.px(cx + 4, cy - 1, 1, 1, KIT.hairShade); // ears
+ this.px(cx + 7, cy - 1, 1, 1, KIT.hairShade);
+ this.px(cx + 5, cy + 1, 1, 1, KIT.sweat); // eyes
+ this.px(cx + 7, cy + 1, 1, 1, KIT.sweat);
+ this.px(cx - 1, cy + 1, 1, 3, KIT.hairShade); // tail
+ }
+ }
+
+ drawFx(g, dip, L, barY, phase) {
+ // Effort marks either side of the head at the hard part of the rep.
+ if (L > 0.45 && L < 0.95) {
+ const hy = HEAD_TOP + dip + 2;
+ for (const s of [-1, 1]) {
+ const x = CX + s * 11;
+ this.px(x, hy, 1, 2, KIT.metalShade);
+ this.px(x + s, hy + 3, 1, 2, KIT.metalShade);
+ }
+ }
+ // Sweat flies off at lockout.
+ if (L > 0.9) {
+ const j = Math.floor(phase * 40);
+ this.px(CX - 12 + Math.round(noise(j) * 2), HEAD_TOP + dip + Math.round(noise(j + 7) * 3), 1, 2, KIT.sweat);
+ this.px(CX + 12 + Math.round(noise(j + 3) * 2), HEAD_TOP + dip + 4 + Math.round(noise(j + 9) * 3), 1, 2, KIT.sweat);
+ }
+ // Chalk puff off the hands as the bar leaves the shoulders.
+ if (this.has("chalk") && L > 0.05 && L < 0.3) {
+ const grip = Math.floor(g.shoulderW / 2) + 5;
+ for (const s of [-1, 1]) {
+ this.px(CX + s * (grip + 1), barY + 4, 1, 1, KIT.chalk);
+ this.px(CX + s * (grip + 3), barY + 6, 1, 1, KIT.chalk);
+ }
+ }
+ // Dust at the feet on the drive out of the bottom.
+ if (L > 0.02 && L < 0.22) {
+ this.px(CX - g.shoulderW / 2 - 4, GROUND - 2, 2, 1, this.surface.floorEdge);
+ this.px(CX + g.shoulderW / 2 + 3, GROUND - 2, 2, 1, this.surface.floorEdge);
+ }
+ }
+
+ /** Level-up: an expanding pixel ring. One beat, then gone. */
+ drawFlash() {
+ const t = 1 - (this.flashUntil - performance.now()) / 900;
+ const r = Math.round(8 + t * 30);
+ const cy = SHOULDER_Y + 4;
+ const step = 6;
+ for (let a = 0; a < 360; a += step) {
+ const rad = (a * Math.PI) / 180;
+ const x = Math.round(CX + Math.cos(rad) * r);
+ const y = Math.round(cy + Math.sin(rad) * r * 0.72);
+ if (x < 0 || x >= W || y < 0 || y >= H) continue;
+ this.px(x, y, 1, 1, KIT.plateRim);
+ }
+ }
+
+ static randomGrunt() {
+ return GRUNTS[Math.floor(Math.random() * GRUNTS.length)];
+ }
+}
+
+export { W as AVATAR_W, H as AVATAR_H };
diff --git a/ui/static/js/icons.js b/ui/static/js/icons.js
new file mode 100644
index 0000000..8d40888
--- /dev/null
+++ b/ui/static/js/icons.js
@@ -0,0 +1,71 @@
+/* ─────────────────────────────────────────────────────────────────
+ Icons · IBM Carbon Icons (Apache 2.0), 32×32 outline set.
+ Source: @carbon/icons v11.84.0 — svg/32/.svg, verbatim paths.
+ Carbon is the inherited TAM icon set; nothing here is hand-drawn.
+ ───────────────────────────────────────────────────────────────── */
+
+const PATHS = {
+ add: '',
+
+ send: '',
+
+ arrowRight: '',
+
+ arrowUp: '',
+
+ book: '',
+
+ notebook: '',
+
+ list: '',
+
+ time: '',
+
+ user: '',
+
+ warning: '',
+
+ checkmark: '',
+
+ information: '',
+
+ close: '',
+
+ chevronRight: '',
+
+ chevronDown: '',
+
+ renew: '',
+
+ trophy: '',
+
+ flash: '',
+
+ activity: '',
+
+ healthCross: '',
+
+ circleDash: '',
+
+ light: '',
+
+ asleep: '',
+
+ restart: '',
+};
+
+/**
+ * Return an inline Carbon SVG string.
+ * Icons are decorative here — labels always carry the meaning — so they
+ * are hidden from assistive tech unless a `label` is passed explicitly.
+ */
+export function icon(name, { size = 16, label = null, cls = "" } = {}) {
+ const body = PATHS[name];
+ if (!body) throw new Error(`Unknown icon: ${name}`);
+ const a11y = label
+ ? `role="img" aria-label="${label}"`
+ : 'aria-hidden="true" focusable="false"';
+ return ``;
+}
+
+export const ICON_NAMES = Object.keys(PATHS);
diff --git a/ui/tam-decision.yml b/ui/tam-decision.yml
new file mode 100644
index 0000000..1b61ea4
--- /dev/null
+++ b/ui/tam-decision.yml
@@ -0,0 +1,123 @@
+schema_version: 1
+system_version: 0.2.6
+generated_at: 2026-07-28T00:00:00Z
+generator_mode: create
+tier: 50
+type: proto
+source_repo: theam/brand-system
+entrypoint: ui/index.html
+mode_applied: auto # dark by default, light honoured and toggleable
+surface_scope: desktop_first # 1440 reference; panes stack below 900
+display_variant_used: sans_default
+signature_variant: product_logo_plus_tam_text
+stack: vanilla
+
+product:
+ name: Tirma
+ team: Tirma
+ what: Personal trainer agent whose memory of the athlete persists across sessions.
+ tam_relationship: >
+ Built and run by The Agile Monkeys. The athlete deals with Tirma; the
+ client buying it deals with TAM. That is the TAM-50 case.
+
+components_used:
+ - app_header # wordmark, athlete picker, agent/store metadata, live state
+ - rail_list # session history
+ - thread # chat log with inline tool/memory traces
+ - composer # square writing surface + primary button
+ - context_panel # sticky athlete + live memory store
+ - pixel_stage # canvas sprite, product-owned imagery
+ - meter # thin progress segment
+ - pill_tag # trait chips, memory kind labels
+ - toast
+ - brand_intro # dismissible first-load brand moment
+
+inherited_from_tam_100:
+ typography: >
+ IBM Plex only, by proto role. Mono for chrome, labels, buttons, badges,
+ metadata and technical strings. Sans for human text — chat messages,
+ memory summaries, input content. Display default is Sans Medium.
+ Neue Galano and Montserrat are absent, as proto requires.
+ scale: h1 40/45 · h2 30/40 · body_1 20/30 · body_2 14/20
+ spacing: 4 8 12 16 24 32 48 64 96 128
+ radii: r0 for writing surfaces and separators · r4 for contained surfaces · pill for pill_tag only
+ buttons: 40px, single line, squared, never a pill
+ icons: IBM Carbon Icons 11.84.0, outline, 32-grid paths used verbatim
+ shadows: none
+ breakpoints: 640 / 1024 / 1440
+ focus: reinforcement of the component's own stroke, never an added outer ring
+
+product_freedom_used:
+ palette:
+ role_primary: Tirma violet — #8C7BFF on dark, #5334F0 on light
+ role_surface: basalt ramp on dark, TAM white/gray_light on light
+ role_accent: the same violet, repeated wherever it means "this is Tirma's"
+ role_focus: derived from the product palette, per TAM-50 color.roles
+ role_line_soft: attenuated, never hard black
+ rationale: >
+ The accent hue is deliberately off every hue in the TAM secondary
+ palette, so product accent and semantic status can never be confused.
+ imagery: >
+ The pixel athlete is product-owned imagery, drawn procedurally to a
+ canvas. No photography, so the TAM-100 threshold treatment does not
+ apply (and is not inherited at this tier).
+ wordmark: >
+ Tirma is a pixel lockup — a dumbbell glyph plus a 5x7 pixel-grid
+ wordmark, distributed as SVG. It is product identity, not sub-brand
+ naming; the TAM bullet and Neue Galano are deliberately absent.
+
+semantic_accent_used:
+ - critical_for_active_injury_constraints
+ - warning_for_equipment_and_schedule_constraints
+ - info_for_goals_and_programme
+ - success_for_logged_progress_and_cleared_constraints
+ - muted_for_profile_and_preferences
+ - success_for_live_stream_state
+ - critical_for_lost_stream_state
+
+open_decisions:
+ - >
+ A --fs-micro step (12/16) below body_2 was added for timestamps, memory
+ kind labels and trait chips. The TAM scale stops at body_2 (14/20),
+ which is too large for a three-pane operational surface at this
+ density. Used only for chrome, never for content.
+ - >
+ The TAM kerning table is expressed in unitless figures. Read here as
+ hundredths of an em (h1 -3 → -0.03em). Buttons stay at 0 per the
+ button component spec, which overrides the table.
+ - >
+ Memory entry kinds carry semantic colour on a 2px left edge. Read as
+ status and risk — an active injury is a different order of thing from a
+ stated preference — rather than as editorial category colouring, which
+ the system forbids. Confined to a hairline and a 12px label; no surface
+ is ever tinted by kind.
+ - >
+ Levels and tiers ("Twig" through "Absolute Unit") are gamification.
+ foundations/UX.md permits it where the domain justifies it and names
+ fitness explicitly. The progression is the product, and it is driven by
+ sessions the coach logged in memory, not by messages sent.
+ - >
+ generators/proto.md v0.2 says TAM-50 proto takes the
+ `with_wordmark_image` signature, while tiers/TAM-50.design.md v0.4
+ fixes `product_logo_plus_tam_text` for the whole tier and makes it a
+ membership condition. The tier doc is followed, since proto.md states
+ it is not the SSOT. Worth reconciling in the system.
+
+warnings:
+ - >
+ Neue Galano is a licensed font and is not vendored here. It is not
+ needed: proto does not use it. Only the four IBM Plex woff2 files are
+ bundled, under the OFL included beside them.
+ - >
+ Reviewed at 1440x900 and 1280x720, light and dark, with
+ prefers-reduced-motion both on and off. Not yet reviewed on a real
+ mobile device — surface_scope is desktop_first and the sub-900
+ breakpoint is untested on hardware.
+ - >
+ Contrast was reasoned from the token values rather than measured with a
+ checker. The greys are TAM's own AA-validated values, but the violet on
+ basalt pairing should be measured before this is called done.
+ - >
+ A live coaching turn takes 30-90 seconds. The working row counts up and
+ the tool traces stream so the wait reads as work rather than as a hang.
+ No artificial progress bar is shown, since the real duration is unknown.