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 @@ + + + + + +Tirma · Personal Trainer Agent + + + + + + + + + + + + +
+
+ Tirma + + Personal Trainer Agent +
+ +
+ +
+ + +
+ +
+ +
+
Agent
+
Memory store
+
+ +
+ + + Connecting + + + +
+
+ +
+ + + + + +
+
+
+

Session

+

+
+

+
+ +
+ +
+
    +
    + + + +
    +

    Enter sends · Shift + Enter for a new line

    +
    +
    + + + + +
    + + + + +
    + + + + diff --git a/ui/sprite-lab.html b/ui/sprite-lab.html new file mode 100644 index 0000000..4ffce3c --- /dev/null +++ b/ui/sprite-lab.html @@ -0,0 +1,94 @@ + + + + +Tirma · sprite lab + + + + +

    Sprite lab

    +

    Not part of the product. A bench for checking every tier and pose at a +readable size — open /sprite-lab.html while the server is running.

    +
    + + + + diff --git a/ui/static/brand/favicon.svg b/ui/static/brand/favicon.svg new file mode 100644 index 0000000..68ad9d7 --- /dev/null +++ b/ui/static/brand/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/ui/static/brand/tirma-mark.svg b/ui/static/brand/tirma-mark.svg new file mode 100644 index 0000000..4022786 --- /dev/null +++ b/ui/static/brand/tirma-mark.svg @@ -0,0 +1,8 @@ + + Tirma + + + + + + diff --git a/ui/static/brand/tirma-wordmark.svg b/ui/static/brand/tirma-wordmark.svg new file mode 100644 index 0000000..cc3e08c --- /dev/null +++ b/ui/static/brand/tirma-wordmark.svg @@ -0,0 +1,61 @@ + + Tirma + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/static/css/app.css b/ui/static/css/app.css new file mode 100644 index 0000000..f74ba7f --- /dev/null +++ b/ui/static/css/app.css @@ -0,0 +1,690 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · app layer + TAM-50 · proto. Mono is the voice of the system, Sans is the human + voice. r4 for contained surfaces, r0 for writing surfaces and lines. + Soft structural separators. No shadows anywhere. + ───────────────────────────────────────────────────────────────── */ + +*, *::before, *::after { box-sizing: border-box; } + +html, body { height: 100%; } + +body { + margin: 0; + display: grid; + grid-template-rows: 64px minmax(0, 1fr) auto; + background: var(--surface); + color: var(--text); + font-family: var(--f-sans); + font-size: var(--fs-b2); + line-height: var(--lh-b2); + -webkit-font-smoothing: antialiased; +} + +/* ── Shared type roles ───────────────────────────────────────────── */ + +.eyebrow { + margin: 0; + font-family: var(--f-mono); + font-size: var(--fs-micro); + line-height: var(--lh-micro); + letter-spacing: var(--tr-caps); + text-transform: uppercase; + color: var(--text-2); + font-weight: 400; +} + +.sr-only { + position: absolute; width: 1px; height: 1px; + padding: 0; margin: -1px; overflow: hidden; + clip: rect(0 0 0 0); white-space: nowrap; border: 0; +} + +.skip-link { + position: absolute; top: var(--s2); left: var(--s2); + z-index: 60; + transform: translateY(-200%); + padding: var(--s2) var(--s4); + background: var(--accent); color: var(--on-accent); + font-family: var(--f-mono); font-size: var(--fs-b2); + text-decoration: none; border-radius: var(--r4); +} +.skip-link:focus { transform: none; } + +/* ── Focus · reinforcement of the component's own stroke ─────────── */ + +:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 2px; +} +.btn--primary:focus-visible { outline-offset: 0; } + +/* ── Buttons · single line, 40px, r4. Never a pill. ──────────────── */ + +.btn { + height: 40px; + min-width: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--s2); + padding: 0 var(--s5); + border: 1px solid transparent; + border-radius: var(--r4); + background: transparent; + color: var(--text); + font-family: var(--f-mono); + font-size: var(--fs-btn); + letter-spacing: var(--tr-btn); + white-space: nowrap; + cursor: pointer; + transition: background-color 120ms linear, border-color 120ms linear; +} +.btn:active { transform: scale(0.98); } + +.btn--primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); } +.btn--primary:hover { background: color-mix(in srgb, var(--accent) 84%, var(--surface)); } +.btn--primary:disabled { background: var(--surface-3); border-color: var(--surface-3); color: var(--text-2); cursor: not-allowed; transform: none; } + +.btn--outline { border-color: var(--line-soft); } +.btn--outline:hover { background: var(--surface-3); } +.btn--outline:focus-visible { outline: none; border: 2px solid var(--focus); } + +.btn--icon { padding: 0; width: 40px; border-color: transparent; color: var(--text-2); } +.btn--icon:hover { background: var(--surface-3); color: var(--text); } + +.btn--textual { + height: auto; min-width: 0; padding: 0; + color: var(--text-2); border: none; +} +.btn--textual:hover { color: var(--text); text-decoration: underline; } +.btn--textual:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +/* ── App bar ─────────────────────────────────────────────────────── */ + +.appbar { + display: flex; + align-items: center; + gap: var(--s6); + padding: 0 var(--s5); + border-bottom: 1px solid var(--line-soft); + background: var(--surface-2); +} + +.appbar__brand { display: flex; align-items: center; gap: var(--s3); min-width: 0; } +.appbar__wordmark { display: block; height: 22px; width: auto; } +:root[data-theme="dark"] .appbar__wordmark, +:root[data-theme="dark"] .intro__wordmark { filter: invert(1) brightness(1.9); } +.appbar__divider { width: 1px; height: 20px; background: var(--line-soft); } +.appbar__kind { + font-family: var(--f-mono); font-size: var(--fs-b2); + letter-spacing: var(--tr-mono-sm); color: var(--text-2); +} + +/* Athlete picker. Per-client memory is the architecture, so switching + athlete switches the memory store and the figure drawn from it. The + native select skin never ships — it is rebuilt in the proto grammar. */ +.picker { display: flex; align-items: center; gap: var(--s2); margin-left: var(--s5); } +.picker__label { + font-family: var(--f-mono); font-size: var(--fs-micro); + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.picker__control { position: relative; display: inline-flex; align-items: center; } +.picker__select { + appearance: none; + height: 32px; + padding: 0 var(--s6) 0 var(--s3); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-3); + color: var(--text); + font-family: var(--f-mono); font-size: var(--fs-b2); + cursor: pointer; + max-width: 22ch; + text-overflow: ellipsis; +} +.picker__select:hover { border-color: var(--line-strong); } +.picker__select:focus-visible { outline: none; border: 2px solid var(--focus); } +.picker__chevron { + position: absolute; right: var(--s3); top: 50%; + width: 7px; height: 7px; margin-top: -5px; + border-right: 1.5px solid var(--text-2); + border-bottom: 1.5px solid var(--text-2); + transform: rotate(45deg); + pointer-events: none; +} + +.appbar__meta { display: flex; gap: var(--s5); margin: 0; margin-left: auto; } +.appbar__meta > div { display: flex; flex-direction: column; gap: 2px; } +.appbar__meta dt { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.appbar__meta dd { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1.2; + color: var(--text); max-width: 20ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.appbar__actions { display: flex; align-items: center; gap: var(--s2); } + +/* Live indicator. Green when the stream is open, muted when it isn't — + status, not decoration. */ +.live { display: inline-flex; align-items: center; gap: var(--s2); padding-right: var(--s2); } +.live__dot { width: 8px; height: 8px; border-radius: var(--r-pill); background: var(--sem-muted); } +.live__label { + font-family: var(--f-mono); font-size: var(--fs-micro); + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.live[data-state="live"] .live__dot { background: var(--sem-success); } +.live[data-state="live"] .live__label { color: var(--text); } +.live[data-state="offline"] .live__dot { background: var(--sem-critical); } + +/* ── Three-pane shell. Shared metrics across the panes. ──────────── */ + +.shell { + display: grid; + grid-template-columns: 268px minmax(0, 1fr) 420px; + min-height: 0; +} + +.pane { display: flex; flex-direction: column; min-height: 0; min-width: 0; } +.pane--rail { border-right: 1px solid var(--line-soft); background: var(--surface-2); } +.pane--context { border-left: 1px solid var(--line-soft); background: var(--surface-2); } + +.pane__head { + height: 48px; + flex: none; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--s3); + padding: 0 var(--s4); + border-bottom: 1px solid var(--line-soft); +} +.pane__head--split { height: auto; padding: var(--s3) var(--s5); align-items: flex-start; } +.pane__headText { min-width: 0; } +.pane__title { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.pane__sub, .pane__stat { + margin: 2px 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.pane__stat { text-align: right; white-space: nowrap; padding-top: var(--s1); } +.pane__stamp { + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); +} + +/* ── Session list ────────────────────────────────────────────────── */ + +.sessions { list-style: none; margin: 0; padding: var(--s2); overflow-y: auto; flex: 1 1 auto; } + +.session { + width: 100%; + display: block; + text-align: left; + padding: var(--s3); + border: 1px solid transparent; + border-radius: var(--r4); + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; +} +.session:hover { background: var(--surface-3); } +.session[aria-current="true"] { background: var(--surface-3); border-color: var(--accent); } +.session:focus-visible { outline: none; border: 2px solid var(--focus); padding: calc(var(--s3) - 1px); } + +.session__title { + margin: 0; + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.session__meta { + margin: var(--s1) 0 0; + display: flex; align-items: center; gap: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.session__writes { display: inline-flex; align-items: center; gap: 4px; color: var(--accent); } +.session[aria-current="true"] .session__title { font-weight: 500; } + +.rail__empty { + margin: 0; padding: var(--s3) var(--s4); + font-family: var(--f-mono); font-size: var(--fs-micro); color: var(--text-2); +} + +.rail__note { + flex: none; + margin: 0; + padding: var(--s3) var(--s4) var(--s4); + border-top: 1px solid var(--line-soft); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* ── Thread ──────────────────────────────────────────────────────── */ + +.thread { + flex: 1 1 auto; + overflow-y: auto; + padding: var(--s6) var(--s7); + display: flex; + flex-direction: column; + gap: var(--s5); +} + +.msg { display: flex; flex-direction: column; gap: var(--s2); max-width: 68ch; } +.msg--user { align-self: flex-end; align-items: flex-end; } + +.msg__who { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; color: var(--text-2); +} +.msg__body { + padding: var(--s3) var(--s4); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-2); + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: 22px; + white-space: pre-wrap; +} +.msg--user .msg__body { background: var(--surface-3); border-color: var(--line-soft); } +.msg--agent .msg__body { background: var(--surface-2); } +:root[data-theme="light"] .msg--agent .msg__body { background: var(--paper); } +.msg__body strong { font-weight: 500; } +.msg__body em { font-style: italic; } + +/* Memory writes, surfaced inline. This is the demo: you watch the agent + decide what to keep while the right panel changes. */ +.trace { + align-self: flex-start; + display: flex; align-items: center; gap: var(--s2); + max-width: 68ch; + padding: var(--s2) var(--s3); + border-left: 2px solid var(--accent); + background: var(--accent-quiet); + border-radius: var(--r0); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text); +} +.trace--read { border-left-color: var(--line-strong); background: transparent; color: var(--text-2); } +.trace__path { color: var(--text-2); } + +.typing { display: inline-flex; gap: 3px; align-items: center; height: 20px; } +.typing i { width: 4px; height: 4px; background: var(--text-2); animation: blip 1s infinite steps(1); } +.typing i:nth-child(2) { animation-delay: .16s; } +.typing i:nth-child(3) { animation-delay: .32s; } +@keyframes blip { 0%, 40% { opacity: 1; } 41%, 100% { opacity: .25; } } + +.working__clock { + margin-left: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); +} + +.msg[data-error="true"] .msg__body { border-left: 2px solid var(--sem-critical); } + +.msg__body code { + font-family: var(--f-mono); font-size: 13px; + padding: 1px 4px; + background: var(--surface-4); + border-radius: var(--r4); +} + +.thread__empty { + margin: auto; max-width: 46ch; text-align: center; color: var(--text-2); +} +.thread__empty code { + font-family: var(--f-mono); font-size: 13px; + padding: 1px 4px; background: var(--surface-3); border-radius: var(--r4); +} +.thread__empty h2 { + margin: 0 0 var(--s3); + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); color: var(--text); +} +.thread__empty p { margin: 0; font-size: var(--fs-b2); line-height: 22px; } + +/* ── Composer. Writing surface stays square (r0). ────────────────── */ + +.composer { + flex: none; + padding: var(--s4) var(--s7) var(--s5); + border-top: 1px solid var(--line-soft); +} +.suggests { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s2); margin: 0 0 var(--s3); padding: 0; } +.suggests .btn { height: 32px; padding: 0 var(--s3); font-size: var(--fs-micro); } + +.composer__form { display: flex; align-items: flex-end; gap: var(--s3); } +.composer__input { + flex: 1 1 auto; + min-height: 40px; max-height: 200px; + padding: 10px var(--s3); + border: 1px solid var(--line-strong); + border-radius: var(--r0); + background: var(--surface); + color: var(--text); + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: var(--lh-b2); + resize: none; +} +.composer__input::placeholder { color: var(--text-2); } +.composer__input:focus-visible { outline: none; border: 2px solid var(--focus); padding: 9px 11px; } +.composer__hint { + margin: var(--s2) 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* ── Right pane · the athlete ────────────────────────────────────── */ + +.context__scroll { flex: 1 1 auto; overflow-y: auto; min-height: 0; } + +/* The athlete stays put while the memory list scrolls under it. When a + memory changes, both halves of the change have to be visible at once. */ +.athlete { + position: sticky; + top: 0; + z-index: 2; + padding: 0 0 var(--s5); + background: var(--surface-2); + border-bottom: 1px solid var(--line-soft); +} + +.athlete__stage { + position: relative; + display: flex; justify-content: center; + padding: var(--s4) 0 var(--s2); + background: var(--surface-3); + border-bottom: 1px solid var(--line-soft); +} +.athlete__stage canvas { display: block; image-rendering: pixelated; } + +.athlete__bubble { + position: absolute; top: var(--s4); right: var(--s4); + margin: 0; + padding: var(--s1) var(--s2); + background: var(--text); color: var(--surface); + border-radius: var(--r4); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + opacity: 0; + transition: opacity 160ms linear; +} +.athlete__bubble[data-show="true"] { opacity: 1; } + +.athlete__id { + display: flex; align-items: baseline; justify-content: space-between; gap: var(--s3); + padding: var(--s4) var(--s4) 0; +} +.athlete__name { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b1); line-height: var(--lh-b1); +} +.athlete__tier { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-b2); + letter-spacing: var(--tr-caps); text-transform: uppercase; + color: var(--accent); + white-space: nowrap; +} +.athlete__blurb { + margin: var(--s1) 0 0; + padding: 0 var(--s4); + font-family: var(--f-sans); font-style: italic; + font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text-2); +} + +/* Thin progress segment — an allowed use of a bar, not decoration. */ +.meter { padding: var(--s4) var(--s4) 0; } +.meter__track { height: 4px; background: var(--line-soft); border-radius: var(--r0); overflow: hidden; } +.meter__fill { height: 100%; width: 0; background: var(--accent); transition: width 500ms ease-out; } +.meter__legend { + margin: var(--s2) 0 0; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} + +/* Trait chips. Each one names the memory that put it on the athlete — + click it and that memory highlights. */ +.traits { list-style: none; display: flex; flex-wrap: wrap; gap: var(--s2); margin: var(--s4) 0 0; padding: 0 var(--s4); } +.trait { + display: inline-flex; align-items: center; gap: 6px; + min-height: 24px; + padding: 3px var(--s2); + border: 1px solid var(--line-soft); + border-radius: var(--r4); + background: var(--surface-3); + color: var(--text); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + cursor: pointer; +} +.trait:hover { border-color: var(--accent); } +.trait:focus-visible { outline: none; border: 2px solid var(--focus); padding: 2px 7px; } +.trait__dot { width: 6px; height: 6px; border-radius: var(--r-pill); background: var(--sem-muted); flex: none; } + +/* ── Right pane · memory store ───────────────────────────────────── */ + +.memory { padding: var(--s5) 0 var(--s6); } +.memory__head { + display: flex; align-items: baseline; justify-content: space-between; + padding: 0 var(--s4) var(--s3); +} +.memory__count { font-family: var(--f-mono); font-size: var(--fs-micro); color: var(--text-2); } + +.memory__list { list-style: none; margin: 0; padding: 0 var(--s4); display: flex; flex-direction: column; gap: var(--s2); } + +.mem { + border: 1px solid var(--line-soft); + border-left: 2px solid var(--sem-muted); + border-radius: var(--r4); + background: var(--surface-3); + padding: var(--s3); + transition: background-color 400ms linear, border-color 400ms linear; +} +.mem[data-kind="injury"] { border-left-color: var(--sem-critical); } +.mem[data-kind="constraint"] { border-left-color: var(--sem-warning); } +.mem[data-kind="goal"] { border-left-color: var(--sem-info); } +.mem[data-kind="progress"] { border-left-color: var(--sem-success); } +.mem[data-kind="preference"] { border-left-color: var(--sem-muted); } +.mem[data-kind="profile"] { border-left-color: var(--sem-muted); } + +.mem__top { display: flex; align-items: center; gap: var(--s2); } +.mem__path { + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.mem__kind { + margin-left: auto; flex: none; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: 1; + letter-spacing: var(--tr-caps); text-transform: uppercase; + color: var(--text-2); +} +.mem__title { + margin: var(--s2) 0 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-b2); line-height: var(--lh-b2); +} +.mem__body { + margin: var(--s1) 0 0; + font-family: var(--f-sans); font-size: var(--fs-b2); line-height: 20px; + color: var(--text-2); +} +.mem[data-clamped="true"] .mem__body { + display: -webkit-box; + -webkit-line-clamp: 5; + -webkit-box-orient: vertical; + overflow: hidden; +} +.mem__more { + margin-top: var(--s2); + padding: 0; border: none; background: none; + color: var(--accent); + font-family: var(--f-mono); font-size: var(--fs-micro); + cursor: pointer; +} +.mem__more:hover { text-decoration: underline; } +.mem__more:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +.mem__cleared { + margin: var(--s2) 0 0; + display: flex; align-items: center; gap: 6px; + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--sem-success); +} + +.mem__foot { + margin: var(--s2) 0 0; + display: flex; align-items: center; gap: var(--s2); + font-family: var(--f-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); + color: var(--text-2); +} +.mem__prev { + margin: var(--s2) 0 0; + border-top: 1px solid var(--line-soft); + padding-top: var(--s2); +} +.mem__prev summary { + font-family: var(--f-mono); font-size: var(--fs-micro); + color: var(--text-2); cursor: pointer; list-style: none; +} +.mem__prev summary::marker { content: ""; } +.mem__prev summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +.mem__prev p { + margin: var(--s2) 0 0; + font-family: var(--f-sans); font-size: var(--fs-micro); line-height: 18px; + color: var(--text-2); + text-decoration: line-through; + text-decoration-color: var(--sem-critical); +} + +.mem[data-fresh="true"] { background: var(--accent-quiet); border-color: var(--accent); } +.mem[data-highlight="true"] { border-color: var(--accent); } +.mem__new { + display: inline-flex; align-items: center; gap: 4px; + color: var(--accent); +} +.mem[data-superseded="true"] { opacity: .55; } +.mem[data-superseded="true"] .mem__path { text-decoration: line-through; } + +/* ── Footer signature ────────────────────────────────────────────── */ + +.signature { + display: flex; align-items: center; gap: var(--s2); + padding: var(--s4) var(--s5); + border-top: 1px solid var(--line-soft); + background: var(--surface); +} +.signature__mark { display: block; height: 14px; width: auto; color: var(--text); opacity: .9; } +:root[data-theme="dark"] .signature__mark { filter: invert(1) brightness(1.9); } +.signature__text { + font-family: var(--f-mono); font-size: var(--fs-b2); line-height: var(--lh-b2); + color: var(--text-2); + text-decoration: none; +} +.signature__text:hover { color: var(--text); } +@media (hover: none) { .signature__text { text-decoration: underline; } } +.signature__text:focus-visible { + outline: none; + text-decoration: underline 2px solid var(--focus); + text-underline-offset: 4px; +} + +/* ── Toast ───────────────────────────────────────────────────────── */ + +.toast { + position: fixed; bottom: var(--s7); left: 50%; + transform: translate(-50%, 200%); + z-index: 50; + display: flex; align-items: center; gap: var(--s2); + padding: var(--s3) var(--s4); + background: var(--text); color: var(--surface); + border-radius: var(--r4); + font-family: var(--f-mono); font-size: var(--fs-b2); + visibility: hidden; + transition: transform 240ms ease-out, visibility 240ms linear; +} +.toast[data-show="true"] { transform: translate(-50%, 0); visibility: visible; } + +/* ── Brand intro ─────────────────────────────────────────────────── */ + +.intro { + position: fixed; inset: 0; z-index: 100; + display: grid; place-items: center; + background: var(--basalt-900); + color: var(--ash-100); +} +.intro[hidden] { display: none; } +.intro__inner { display: grid; justify-items: center; gap: var(--s4); padding: var(--s6); text-align: center; } +.intro__eyebrow { color: var(--ash-300); } +.intro__wordmark { + width: min(500px, 62vw); height: auto; + animation: introRise 620ms ease-out both; +} +.intro__claim { + margin: 0; + font-family: var(--f-sans); font-weight: 500; + font-size: var(--fs-h2); line-height: var(--lh-h2); + animation: introRise 620ms 160ms ease-out both; +} +.intro__stage { animation: introRise 620ms 280ms ease-out both; } +.intro__stage canvas { display: block; image-rendering: pixelated; } +.intro__sig { + margin: 0; + font-family: var(--f-mono); font-size: var(--fs-b2); + color: var(--ash-300); + animation: introRise 620ms 400ms ease-out both; +} +.intro__skip { color: var(--ash-300); margin-top: var(--s2); } +.intro__skip:hover { color: var(--ash-100); } + +@keyframes introRise { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: none; } +} +.intro[data-closing="true"] { opacity: 0; transition: opacity 320ms linear; } + +/* ── Responsive. Desktop is the reference; the panes stack below. ── */ + +@media (max-width: 1180px) { + .shell { grid-template-columns: 220px minmax(0, 1fr) 360px; } + .thread, .composer { padding-left: var(--s5); padding-right: var(--s5); } + .appbar__meta { display: none; } +} + +@media (max-width: 900px) { + body { grid-template-rows: 64px minmax(0, 1fr) auto; } + .shell { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) auto; } + .pane--rail { border-right: none; border-bottom: 1px solid var(--line-soft); max-height: 168px; } + .pane--context { border-left: none; border-top: 1px solid var(--line-soft); max-height: 52vh; } + .appbar { gap: var(--s3); } + .appbar__kind, .appbar__divider { display: none; } + .intro__claim { font-size: var(--fs-b1); line-height: var(--lh-b1); } +} + +/* ── Reduced motion ──────────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.001ms !important; + animation-delay: 0ms !important; + transition-duration: 0.001ms !important; + } +} diff --git a/ui/static/css/tokens.css b/ui/static/css/tokens.css new file mode 100644 index 0000000..318851a --- /dev/null +++ b/ui/static/css/tokens.css @@ -0,0 +1,172 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · design tokens + Tier: TAM-50 · type: proto + + Two layers, kept visually separate in this file: + + 1. INHERITED FROM TAM-100 — non-negotiable. Typography families and + scale, spacing scale, radii, breakpoints, no-shadow rule, the + restricted semantic palette. Do not add values here. + 2. TIRMA PRODUCT PALETTE — product freedom under TAM-50, but with + role discipline: primary, surface, accent, focus, line_soft. + Few colours, each with one job. + ───────────────────────────────────────────────────────────────── */ + +/* ── Fonts · IBM Plex only. proto does NOT use Neue Galano. ──────── */ + +@font-face { + font-family: "IBM Plex Mono"; + src: url("../fonts/IBMPlexMono-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Mono"; + src: url("../fonts/IBMPlexMono-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Regular.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Medium.woff2") format("woff2"); + font-weight: 500; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "IBM Plex Sans"; + src: url("../fonts/IBMPlexSans-Italic.woff2") format("woff2"); + font-weight: 400; + font-style: italic; + font-display: swap; +} + +:root { + /* ═══ INHERITED · typography families ═════════════════════════════ + proto grammar: Mono = the voice of the system (chrome, labels, + buttons, badges, metadata, technical strings). Sans = the neutral + human voice (what the user reads or writes). Display default is + Sans Medium — Serif is not invoked by this project. */ + --f-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + --f-sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + + /* ═══ INHERITED · typographic scale ══════════════════════════════ */ + --fs-h1: 40px; --lh-h1: 45px; + --fs-h2: 30px; --lh-h2: 40px; + --fs-b1: 20px; --lh-b1: 30px; + --fs-b2: 14px; --lh-b2: 20px; + --fs-caps: 14px; + --fs-btn: 14px; + /* Sub-scale step. Not in the TAM scale — declared here because a + dense proto needs one register below body_2 for timestamps and + micro-chips. Logged as an open decision in tam-decision.yml. */ + --fs-micro: 12px; --lh-micro: 16px; + + /* Tracking. The TAM kerning table is expressed in unitless figures; + read here as hundredths of an em. Buttons stay at 0 per the + button component spec (no expanded tracking without CAPS). */ + --tr-mono-lg: -0.03em; + --tr-mono-sm: -0.02em; + --tr-caps: 0.05em; + --tr-btn: 0; + + /* ═══ INHERITED · spacing scale · 4pt base ═══════════════════════ */ + --s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; + --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px; --s10: 128px; + + /* ═══ INHERITED · radii ══════════════════════════════════════════ + r0 = separators, lines and writing surfaces (the composer stays + square — functionality is not softened). + r4 = any contained surface that reads as a UI object. + pill = pill_tag only. A button never takes a pill shape. */ + --r0: 0; + --r4: 4px; + --r-pill: 999px; + + /* ═══ INHERITED · restricted semantic palette ════════════════════ + Small footprints only: status dots, chips, thin bars. Never a + surface, never a decorative band, never a category colour. */ + --sem-success: #2FBF71; + --sem-warning: #FFB238; + --sem-critical: #FF6B60; + --sem-info: #79A2FF; + --sem-muted: #A1ACB8; + + /* ═══ TIRMA PRODUCT PALETTE · primitives ═════════════════════════ + Tirma is named after the massif on Gran Canaria. The palette is + basalt (surfaces) plus one lift-violet accent — deliberately off + the semantic hues so product accent and status never read alike. */ + --basalt-900: #0C0D11; + --basalt-800: #12141A; + --basalt-700: #181B22; + --basalt-600: #1F232C; + --basalt-500: #2A2F3A; + --basalt-400: #3A404D; + --ash-300: #999999; + --ash-200: #C9CDD4; + --ash-100: #F2F3F5; + --paper: #FFFFFF; + --paper-2: #F5F5F5; + --paper-3: #EBECEE; + --graphite: #000000; + --graphite-soft: #707070; + --line-light: #DFE1E5; + + --lift-violet: #8C7BFF; /* accent · dark surfaces */ + --lift-violet-deep: #5334F0; /* accent · light surfaces */ + + /* ═══ TIRMA PRODUCT PALETTE · roles ══════════════════════════════ + Components reference roles, never primitives. Mode swaps the + role→primitive mapping; it does not rename hexes. */ + color-scheme: dark; + --surface: var(--basalt-900); + --surface-2: var(--basalt-800); + --surface-3: var(--basalt-700); + --surface-4: var(--basalt-600); + --text: var(--ash-100); + --text-2: var(--ash-300); + --line-soft: var(--basalt-500); + --line-strong: var(--basalt-400); + --accent: var(--lift-violet); + --accent-quiet: color-mix(in srgb, var(--lift-violet) 16%, transparent); + --focus: var(--lift-violet); + --on-accent: var(--basalt-900); +} + +:root[data-theme="light"] { + color-scheme: light; + --surface: var(--paper); + --surface-2: var(--paper-2); + --surface-3: var(--paper-3); + --surface-4: var(--paper-2); + --text: var(--graphite); + --text-2: var(--graphite-soft); + --line-soft: var(--line-light); + --line-strong: var(--graphite); + --accent: var(--lift-violet-deep); + --accent-quiet: color-mix(in srgb, var(--lift-violet-deep) 10%, transparent); + --focus: var(--lift-violet-deep); + --on-accent: var(--paper); + + /* Semantic palette · light pairs. Explicit pairs, not a heuristic + inversion of the dark values. */ + --sem-success: #1E9E57; + --sem-warning: #E18800; + --sem-critical: #D83A34; + --sem-info: #3F6DD8; + --sem-muted: #7C8592; +} + +/* ═══ INHERITED · breakpoints (documentation) ═══════════════════════ + sm 640 · md 1024 · lg 1440 (reference viewport) + Declared in media queries in app.css. Mobile-first is the CSS + convention; the render at 1440 is the visual benchmark. */ diff --git a/ui/static/fonts/IBMPlexMono-Medium.woff2 b/ui/static/fonts/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000..090f82f Binary files /dev/null and b/ui/static/fonts/IBMPlexMono-Medium.woff2 differ diff --git a/ui/static/fonts/IBMPlexMono-Regular.woff2 b/ui/static/fonts/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..0804aaf Binary files /dev/null and b/ui/static/fonts/IBMPlexMono-Regular.woff2 differ diff --git a/ui/static/fonts/IBMPlexSans-Italic.woff2 b/ui/static/fonts/IBMPlexSans-Italic.woff2 new file mode 100644 index 0000000..cf026fe Binary files /dev/null and b/ui/static/fonts/IBMPlexSans-Italic.woff2 differ diff --git a/ui/static/fonts/IBMPlexSans-Medium.woff2 b/ui/static/fonts/IBMPlexSans-Medium.woff2 new file mode 100644 index 0000000..6d5527e Binary files /dev/null and b/ui/static/fonts/IBMPlexSans-Medium.woff2 differ diff --git a/ui/static/fonts/IBMPlexSans-Regular.woff2 b/ui/static/fonts/IBMPlexSans-Regular.woff2 new file mode 100644 index 0000000..f0ee65d Binary files /dev/null and b/ui/static/fonts/IBMPlexSans-Regular.woff2 differ diff --git a/ui/static/fonts/LICENSE-OFL.txt b/ui/static/fonts/LICENSE-OFL.txt new file mode 100644 index 0000000..2ae8ef3 --- /dev/null +++ b/ui/static/fonts/LICENSE-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 IBM Corp. All rights reserved. IBMPlexSans-Italic[wdth,wght].ttf: Copyright 2019 IBM Corp. All rights reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/ui/static/js/app.js b/ui/static/js/app.js new file mode 100644 index 0000000..0184375 --- /dev/null +++ b/ui/static/js/app.js @@ -0,0 +1,862 @@ +/* ───────────────────────────────────────────────────────────────── + Tirma · app + Renders the three panes and keeps them in step with the coaching API. + + The server is the only source of truth. Nothing here invents state: + the athlete's build and accessories arrive already derived from the + client's memory store (api/ui_state.py), so the pixel figure and the + agent can never disagree about what is remembered. + + A coaching turn is one POST that streams Server-Sent Events back: + the coach's own status / tool_use / message / done frames, then the + two this interface adds once the store has been re-read — + memory.updated and athlete.updated. + ───────────────────────────────────────────────────────────────── */ + +import { icon } from "./icons.js"; +import { AvatarStage, TIERS, TRAIT_LABELS } from "./avatar.js"; + +const $ = (id) => document.getElementById(id); + +const el = { + metaAgent: $("metaAgent"), + metaStore: $("metaStore"), + live: $("liveState"), + themeToggle: $("themeToggle"), + replayIntro: $("replayIntro"), + picker: $("athletePicker"), + newAthlete: $("newAthlete"), + newSession: $("newSession"), + sessionList: $("sessionList"), + railEmpty: $("railEmpty"), + railNote: $("railNote"), + sessionTitle: $("sessionTitle"), + sessionSub: $("sessionSub"), + sessionStat: $("sessionStat"), + thread: $("thread"), + suggests: $("suggests"), + form: $("composerForm"), + input: $("composerInput"), + sendBtn: $("sendBtn"), + contextStamp: $("contextStamp"), + athleteCanvas: $("athleteCanvas"), + athleteBubble: $("athleteBubble"), + athleteName: $("athleteName"), + athleteTier: $("athleteTier"), + athleteBlurb: $("athleteBlurb"), + meterFill: $("meterFill"), + meterLegend: $("meterLegend"), + traits: $("traits"), + memoryList: $("memoryList"), + memoryCount: $("memoryCount"), + toast: $("toast"), + intro: $("intro"), + introCanvas: $("introCanvas"), + introSkip: $("introSkip"), +}; + +const state = { + demo: false, + agent: null, + athletes: [], + active: null, // { id, name, store_id } + athlete: null, // derived figure + memory: { entries: [], store_id: null, updated_at: null }, + conversations: [], + conversationId: null, + messages: [], + suggests: [], + busy: false, + streamText: "", + streamBody: null, +}; + +let stage = null; +let introStage = null; +const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +/* ── helpers ─────────────────────────────────────────────────────── */ + +const esc = (s) => + String(s ?? "").replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +/** Just enough inline markup for the coach's replies. Escaped first. */ +function inline(text) { + return esc(text) + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1$2") + .replace(/`([^`]+)`/g, "$1"); +} + +function ago(iso) { + if (!iso) return ""; + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ""; + const s = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (s < 45) return "just now"; + if (s < 3600) return `${Math.round(s / 60)} min ago`; + if (s < 86400) return `${Math.round(s / 3600)} h ago`; + const d = Math.round(s / 86400); + if (d === 1) return "yesterday"; + if (d < 30) return `${d} days ago`; + return new Date(iso).toLocaleDateString(undefined, { day: "numeric", month: "short" }); +} + +const shortId = (id) => + !id ? "—" : id.length > 22 ? `${id.slice(0, 10)}…${id.slice(-8)}` : id; + +let toastTimer = null; +function toast(message, iconName = "checkmark") { + el.toast.innerHTML = `${icon(iconName, { size: 16 })}${esc(message)}`; + el.toast.dataset.show = "true"; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { el.toast.dataset.show = "false"; }, 4000); +} + +/* ── theme ───────────────────────────────────────────────────────── */ + +function surfaceColours() { + const cs = getComputedStyle(document.documentElement); + const v = (n) => cs.getPropertyValue(n).trim(); + return { + floor: v("--surface-4") || "#1F232C", + floorEdge: v("--line-soft") || "#2A2F3A", + shadow: v("--line-strong") || "#3A404D", + }; +} + +function applyTheme(theme) { + document.documentElement.dataset.theme = theme; + const toLight = theme === "dark"; + el.themeToggle.innerHTML = icon(toLight ? "light" : "asleep", { size: 20 }); + el.themeToggle.setAttribute("aria-label", `Switch to ${toLight ? "light" : "dark"} mode`); + try { localStorage.setItem("tirma.theme", theme); } catch {} + stage?.setSurface(surfaceColours()); +} + +function initTheme() { + let saved = null; + try { saved = localStorage.getItem("tirma.theme"); } catch {} + applyTheme(saved || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")); +} + +/* ── the athlete ─────────────────────────────────────────────────── */ + +const SEVERITY_COLOUR = { + critical: "var(--sem-critical)", + warning: "var(--sem-warning)", + info: "var(--sem-info)", + success: "var(--sem-success)", + muted: "var(--sem-muted)", +}; + +function renderAthlete(athlete) { + if (!athlete) return; + const previous = state.athlete; + state.athlete = athlete; + + const tier = TIERS[Math.min(athlete.tier ?? 0, TIERS.length - 1)]; + el.athleteName.textContent = athlete.name || "Athlete"; + el.athleteTier.textContent = athlete.tier_label || tier.label; + el.athleteBlurb.textContent = athlete.blurb || tier.blurb; + + const pct = Math.round((athlete.progress ?? 0) * 100); + el.meterFill.style.width = `${Math.min(100, pct)}%`; + const atTop = (athlete.tier ?? 0) >= TIERS.length - 1; + const next = TIERS[Math.min((athlete.tier ?? 0) + 1, TIERS.length - 1)]; + const n = athlete.sessions_logged ?? 0; + el.meterLegend.textContent = atTop + ? `${n} sessions on record · top tier` + : `${n} ${n === 1 ? "session" : "sessions"} on record · ${pct}% to ${next.label}`; + + const traits = athlete.traits || []; + el.traits.innerHTML = traits.length + ? traits.map((t) => ` +
  • `).join("") + : `
  • Nothing on the athlete yet — the memory store is empty.
  • `; + + if (!stage) return; + stage.setSpec({ + tier: athlete.tier ?? 0, + progress: athlete.progress ?? 0, + traits: traits.map((t) => t.id), + mood: athlete.mood || "neutral", + sessions_logged: n, + plates: athlete.plates ?? 1, + }); + + if (previous && previous.name === athlete.name) { + if ((athlete.tier ?? 0) > (previous.tier ?? 0)) { + stage.celebrate(); + toast(`Level up · ${athlete.tier_label || tier.label}`, "trophy"); + say("LEVEL UP"); + } else { + const before = new Set((previous.traits || []).map((t) => t.id)); + const gained = traits.filter((t) => !before.has(t.id)); + if (gained.length) say(gained[0].label.toLowerCase()); + } + } +} + +let bubbleTimer = null; +function say(text) { + el.athleteBubble.textContent = text; + el.athleteBubble.dataset.show = "true"; + clearTimeout(bubbleTimer); + bubbleTimer = setTimeout(() => { el.athleteBubble.dataset.show = "false"; }, 1600); +} + +/* ── the memory panel ────────────────────────────────────────────── */ + +const KIND_ICON = { + injury: "healthCross", + constraint: "warning", + goal: "flash", + progress: "trophy", + preference: "user", + profile: "notebook", + program: "list", +}; + +function memoryNode(entry) { + const li = document.createElement("li"); + li.className = "mem"; + li.dataset.kind = entry.kind || "profile"; + li.dataset.id = entry.id; + if (entry.superseded_by) li.dataset.superseded = "true"; + + const previous = entry.previous + ? `
    + ${icon("renew", { size: 12 })} What this said before +

    ${esc(entry.previous)}

    +
    ` + : ""; + + // 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 + ? `

    ${icon("checkmark", { size: 12 })} + cleared · ${entry.cleared.map(esc).join(" · ")}

    ` + : ""; + + li.innerHTML = ` +
    + ${icon(KIND_ICON[entry.kind] || "notebook", { size: 14 })} + ${esc(entry.path)} + ${esc(entry.kind || "note")} +
    +

    ${esc(entry.title || "")}

    +

    ${inline(entry.body || "")}

    + ${long ? `` : ""} + ${cleared} +

    + ${icon("time", { size: 12 })}${esc(ago(entry.updated_at) || "on record")} + ${entry.superseded_by ? `· ${esc(entry.superseded_by)}` : ""} +

    + ${previous}`; + return li; +} + +function renderMemory(memory, freshIds = []) { + state.memory = memory || { entries: [] }; + const entries = state.memory.entries || []; + el.memoryCount.textContent = `${entries.length} ${entries.length === 1 ? "memory" : "memories"}`; + el.contextStamp.textContent = state.memory.updated_at + ? `updated ${ago(state.memory.updated_at)}` + : ""; + el.metaStore.textContent = shortId(state.memory.store_id); + el.metaStore.title = state.memory.store_id || ""; + + el.memoryList.replaceChildren(...entries.map(memoryNode)); + + for (const id of freshIds) { + const node = el.memoryList.querySelector(`[data-id="${CSS.escape(id)}"]`); + if (!node) continue; + node.dataset.fresh = "true"; + const foot = node.querySelector(".mem__foot"); + if (foot) { + const tag = document.createElement("span"); + tag.className = "mem__new"; + tag.innerHTML = `${icon("arrowUp", { size: 12 })}just written`; + foot.prepend(tag); + } + } + // Deliberately not scrolled into view: the athlete sits above the list + // and is the other half of the change. Scrolling to the entry would + // hide the thing the entry just altered. + if (freshIds.length && !reduced) { + setTimeout(() => { + el.memoryList.querySelectorAll('[data-fresh="true"]') + .forEach((n) => { n.dataset.fresh = "false"; }); + }, 3600); + } +} + +function highlightMemory(id) { + if (!id) return; + const node = el.memoryList.querySelector(`[data-id="${CSS.escape(id)}"]`); + if (!node) { toast("That memory is not in the store any more", "information"); return; } + el.memoryList.querySelectorAll("[data-highlight]").forEach((n) => delete n.dataset.highlight); + node.dataset.highlight = "true"; + node.scrollIntoView({ block: "nearest", behavior: reduced ? "auto" : "smooth" }); + setTimeout(() => { delete node.dataset.highlight; }, 3200); +} + +/* ── sessions rail ───────────────────────────────────────────────── */ + +function renderSessions() { + el.sessionList.replaceChildren(...state.conversations.map((c) => { + const li = document.createElement("li"); + const active = c.id === state.conversationId; + li.innerHTML = ` + `; + return li; + })); + el.railEmpty.hidden = state.conversations.length > 0; + el.railNote.textContent = state.active + ? `Every session reads and writes ${state.active.name}'s own memory store.` + : ""; +} + +function renderSessionHeader() { + const c = state.conversations.find((x) => x.id === state.conversationId); + el.sessionTitle.textContent = c ? c.title : "New session"; + el.sessionSub.textContent = c + ? `${c.id} · started ${ago(c.started_at)}` + : "Nothing said yet"; + const writes = c?.memory_writes ?? 0; + el.sessionStat.textContent = writes + ? `${writes} memory ${writes === 1 ? "write" : "writes"} this session` + : "no memory writes yet"; +} + +/* ── thread ──────────────────────────────────────────────────────── */ + +function messageNode(msg) { + const div = document.createElement("div"); + div.className = `msg msg--${msg.role === "user" ? "user" : "agent"}`; + div.dataset.id = msg.id || ""; + div.innerHTML = ` +

    ${msg.role === "user" ? "You" : "Tirma"}

    +
    ${inline(msg.text)}
    `; + return div; +} + +function traceNode({ tool, target, is_memory, note }) { + const div = document.createElement("div"); + div.className = `trace ${is_memory ? "" : "trace--read"}`; + const writing = /write|edit|create|str_replace/i.test(tool || ""); + const label = is_memory ? `memory ${writing ? "written" : "read"}` : `tool · ${tool}`; + div.innerHTML = ` + ${icon(is_memory ? (writing ? "notebook" : "book") : "flash", { size: 14 })} + ${esc(label)} + ${target ? `${esc(target)}` : ""} + ${note ? `· ${esc(note)}` : ""}`; + return div; +} + +const nearBottom = () => + el.thread.scrollHeight - el.thread.scrollTop - el.thread.clientHeight < 160; + +function scrollThread(force = false) { + if (force || nearBottom()) el.thread.scrollTop = el.thread.scrollHeight; +} + +function renderThread() { + if (!state.messages.length) { + el.thread.innerHTML = ` +
    +

    Tirma already knows this athlete.

    +

    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 = ` +

    Tirma

    +
    + + reading the training file +
    `; + append(div); + workTimer = setInterval(() => { + const clock = $("workingClock"); + if (clock) clock.textContent = `working · ${Math.round((Date.now() - started) / 1000)}s`; + }, 1000); +} + +function hideWorking() { + clearInterval(workTimer); + workTimer = null; + $("workingRow")?.remove(); +} + +function setBusy(busy) { + state.busy = busy; + el.sendBtn.disabled = busy; + el.input.disabled = busy; + el.sendBtn.querySelector("span").textContent = busy ? "Working…" : "Send message"; + el.suggests.querySelectorAll("button").forEach((b) => { b.disabled = busy; }); +} + +function renderSuggests() { + el.suggests.innerHTML = (state.suggests || []).map((s) => + `
  • ` + ).join(""); +} + +/* ── server ──────────────────────────────────────────────────────── */ + +async function api(path, options = {}) { + const res = await fetch(path, { + headers: { "content-type": "application/json" }, + ...options, + }); + if (!res.ok) { + const detail = await res.text().catch(() => ""); + throw new Error(`${res.status} ${detail.slice(0, 200)}`); + } + return res.status === 204 ? null : res.json(); +} + +function setLive(name, label) { + el.live.dataset.state = name; + el.live.querySelector(".live__label").textContent = label; +} + +/** Parse an SSE body off a fetch response. */ +async function* frames(response) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let cut; + while ((cut = buffer.indexOf("\n\n")) >= 0) { + const raw = buffer.slice(0, cut); + buffer = buffer.slice(cut + 2); + let event = "message"; + let data = ""; + for (const line of raw.split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) data += line.slice(5).trim(); + } + if (data) { + try { yield { event, data: JSON.parse(data) }; } catch { /* keepalive */ } + } + } + } +} + +/* ── loading ─────────────────────────────────────────────────────── */ + +function adopt(payload) { + if (payload.active) { + state.active = payload.active; + el.picker.value = payload.active.id; + } + if (payload.conversations) state.conversations = payload.conversations; + if (payload.active_conversation_id) state.conversationId = payload.active_conversation_id; + if (payload.messages) state.messages = payload.messages; + if (payload.memory) renderMemory(payload.memory); + if (payload.athlete) renderAthlete(payload.athlete); + renderSessions(); + renderSessionHeader(); + renderThread(); +} + +async function boot() { + const data = await api("/ui/api/bootstrap"); + state.demo = !!data.demo; + state.agent = data.agent; + state.athletes = data.athletes || []; + state.suggests = data.suggests || []; + + el.metaAgent.textContent = shortId(data.agent?.id); + el.metaAgent.title = data.agent?.id || ""; + el.picker.innerHTML = state.athletes + .map((a) => ``) + .join(""); + renderSuggests(); + + if (!state.athletes.length) { + el.thread.innerHTML = ` +
    +

    No athlete yet.

    +

    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 `${body}`; +} + +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.