Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://127.0.0.1:8000/>. 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)

Expand All @@ -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)
```
148 changes: 148 additions & 0 deletions api/conversations.py
Original file line number Diff line number Diff line change
@@ -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)
41 changes: 37 additions & 4 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
Loading