feat: read-only drawer with scene, activity, edges, memories

This commit is contained in:
Joseph Doherty
2026-04-26 13:35:47 -04:00
parent 3995a8671b
commit 5fc5b8ac23
6 changed files with 427 additions and 2 deletions
+2
View File
@@ -17,6 +17,7 @@ import chat.state.world # noqa: F401
from chat.web.bots import router as bots_router
from chat.web.chat import router as chat_router
from chat.web.drawer import router as drawer_router
from chat.web.kickoff import router as kickoff_router
from chat.web.nav import router as nav_router
from chat.web.settings import router as settings_router
@@ -62,6 +63,7 @@ app.include_router(kickoff_router)
app.include_router(settings_router)
app.include_router(nav_router)
app.include_router(chat_router)
app.include_router(drawer_router)
app.include_router(sse_router)
app.include_router(turns_router)
+10
View File
@@ -79,3 +79,13 @@ code { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.turn-input textarea { padding: 8px; font: inherit; border: 1px solid #ccc; border-radius: 3px; resize: vertical; }
.drawer { position: fixed; top: 0; right: 0; width: 360px; height: 100vh; background: #fff; border-left: 1px solid #e5e5e5; padding: 16px; overflow-y: auto; z-index: 10; }
.drawer[hidden] { display: none; }
.drawer-content { display: flex; flex-direction: column; gap: 16px; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 8px; border-bottom: 1px solid #e5e5e5; }
.drawer-close { border: none; background: transparent; color: #1c1c1c; font-size: 24px; padding: 0 4px; cursor: pointer; }
.drawer-section h3 { margin: 0 0 8px; font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; }
.activity-row, .edge-row { margin-bottom: 12px; }
.activity-row strong, .edge-row strong { display: block; }
.memory-list { list-style: none; padding: 0; margin: 0; }
.memory-list li { padding: 4px 0; font-size: 13px; }
.sig { display: inline-block; min-width: 16px; }
.sig-3 { color: #d4af37; }
+94
View File
@@ -0,0 +1,94 @@
<div class="drawer-content">
<header class="drawer-header">
<h2>{{ host_bot.name }}</h2>
<button class="drawer-close" type="button"
onclick="document.getElementById('drawer').setAttribute('hidden','')">&times;</button>
</header>
<section class="drawer-section">
<h3>Scene</h3>
{% if scene %}
<p>Started: {{ scene.started_at }}</p>
{% endif %}
{% if container %}
<p>Container: {{ container.name }} ({{ container.type }})</p>
{% else %}
<p class="muted">No active container.</p>
{% endif %}
<p>Time: {{ chat.time }}</p>
</section>
<section class="drawer-section">
<h3>Activity</h3>
{% for label, act in [("you", you_activity), (host_bot.name, bot_activity)] %}
<div class="activity-row">
<strong>{{ label }}</strong>
{% if act %}
<p>{{ act.posture or "—" }} / {{ (act.action or {}).verb or "—" }}</p>
{% if act.attention %}<p class="muted">attention: {{ act.attention }}</p>{% endif %}
{% if act.holding %}<p class="muted">holding: {{ act.holding|join(", ") }}</p>{% endif %}
{% else %}
<p class="muted">No activity recorded.</p>
{% endif %}
</div>
{% endfor %}
</section>
<section class="drawer-section">
<h3>Edges</h3>
{% if edge_b2y %}
<div class="edge-row">
<strong>{{ host_bot.name }} &rarr; you</strong>
<p>Affinity: {{ edge_b2y.affinity }}/100 &middot; Trust: {{ edge_b2y.trust }}/100</p>
{% if edge_b2y.summary %}<p class="muted">{{ edge_b2y.summary }}</p>{% endif %}
{% if edge_b2y.knowledge %}
<details><summary>Knowledge ({{ edge_b2y.knowledge|length }})</summary>
<ul>{% for fact in edge_b2y.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
</details>
{% endif %}
</div>
{% endif %}
{% if edge_y2b %}
<div class="edge-row">
<strong>you &rarr; {{ host_bot.name }}</strong>
<p>Affinity: {{ edge_y2b.affinity }}/100 &middot; Trust: {{ edge_y2b.trust }}/100</p>
{% if edge_y2b.summary %}<p class="muted">{{ edge_y2b.summary }}</p>{% endif %}
</div>
{% endif %}
{% if not edge_b2y and not edge_y2b %}
<p class="muted">No edges yet.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Pinned memories ({{ pinned|length }} / {{ pin_cap }})</h3>
{% if pinned %}
<ul class="memory-list">
{% for m in pinned %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary }}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No pinned memories.</p>
{% endif %}
</section>
<section class="drawer-section">
<h3>Recent memories</h3>
{% if recent_memories %}
<ul class="memory-list">
{% for m in recent_memories %}
<li>
<span class="sig sig-{{ m.significance }}">{{ ['·','•','★','★★'][m.significance|default(0)] }}</span>
{{ m.pov_summary[:200] }}{% if m.pov_summary|length > 200 %}…{% endif %}
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No memories yet.</p>
{% endif %}
</section>
</div>
+5 -2
View File
@@ -30,8 +30,11 @@
<button type="submit">Send</button>
</form>
<aside class="drawer" id="drawer" hidden>
<p class="muted">Drawer (read-only). T24 fills this in.</p>
<aside class="drawer" id="drawer" hidden
hx-get="/chats/{{ chat.id }}/drawer"
hx-trigger="revealed"
hx-swap="innerHTML">
<p class="muted">Loading drawer&hellip;</p>
</aside>
</div>
<script>
+106
View File
@@ -0,0 +1,106 @@
"""Read-only chat drawer (T24).
Returns an HTML partial rendered into the chat shell's `<aside id="drawer">`
on first reveal. Shows the current scene + container, per-entity activity,
host <-> you edges, pinned memories with an `n / cap` counter, and recent
witnessed memories from the host's POV with significance markers.
Edit affordances are added in T25; this endpoint is intentionally read-only.
"""
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you
from chat.state.memory import get_pinned
from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn
TEMPLATES = Jinja2Templates(
directory=str(Path(__file__).resolve().parent.parent / "templates")
)
router = APIRouter()
# Soft cap on pinned memories per owner (§8.5). Surfaced in the drawer header
# as `pinned|length / pin_cap`; eviction logic itself lives in T22.
PIN_CAP = 8
# Recent-memories list is bounded to keep the drawer cheap to render.
RECENT_LIMIT = 10
@router.get("/chats/{chat_id}/drawer", response_class=HTMLResponse)
async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
host_bot = get_bot(conn, chat["host_bot_id"])
if host_bot is None:
raise HTTPException(
status_code=404, detail=f"host bot not found: {chat['host_bot_id']}"
)
you_entity = get_you(conn) or {"name": "you", "pronouns": "", "persona": ""}
scene = active_scene(conn, chat_id)
container = None
if scene and scene.get("container_id") is not None:
container = get_container(conn, scene["container_id"])
you_activity = get_activity(conn, "you")
bot_activity = get_activity(conn, chat["host_bot_id"])
edge_b2y = get_edge(conn, chat["host_bot_id"], "you")
edge_y2b = get_edge(conn, "you", chat["host_bot_id"])
# Recent memories from host's POV (witness_host = 1), most recent first.
# Raw query keeps this read self-contained — no projector helper exposes
# "latest N for an owner" yet and the drawer is the only consumer.
recent_rows = conn.execute(
"""
SELECT id, pov_summary, significance, pinned, created_at
FROM memories
WHERE owner_id = ? AND witness_host = 1
ORDER BY id DESC
LIMIT ?
""",
(chat["host_bot_id"], RECENT_LIMIT),
).fetchall()
recent_memories = [
{
"id": r[0],
"pov_summary": r[1],
"significance": r[2],
"pinned": r[3],
"created_at": r[4],
}
for r in recent_rows
]
pinned = get_pinned(conn, chat["host_bot_id"])
return TEMPLATES.TemplateResponse(
request,
"_drawer.html",
{
"chat": chat,
"host_bot": host_bot,
"you_entity": you_entity,
"scene": scene,
"container": container,
"you_activity": you_activity,
"bot_activity": bot_activity,
"edge_b2y": edge_b2y,
"edge_y2b": edge_y2b,
"recent_memories": recent_memories,
"pinned": pinned,
"pin_cap": PIN_CAP,
},
)
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from chat.app import app
from chat.db.connection import open_db
from chat.eventlog.log import append_event
from chat.eventlog.projector import project
@pytest.fixture
def client(tmp_path, monkeypatch):
cfg = tmp_path / "config.toml"
cfg.write_text('featherless_api_key = "test"\n')
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
db = tmp_path / "test.db"
monkeypatch.setenv("CHAT_DB_PATH", str(db))
with TestClient(app) as c:
# Disable background worker (we won't drive turns).
if hasattr(app.state, "background_worker"):
app.state.background_worker.enabled = False
yield c
def _seed(db: Path) -> None:
with open_db(db) as conn:
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": ["shy"],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="you_authored",
payload={
"name": "Me",
"pronouns": "they/them",
"persona": "engineer",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
# Activity for both you and host bot.
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "you",
"posture": "sitting",
"action": {"verb": "thinking"},
"attention": "the screen",
},
)
append_event(
conn,
kind="activity_change",
payload={
"entity_id": "bot_a",
"posture": "standing",
"action": {"verb": "looking out the window"},
},
)
# Edge host -> you.
append_event(
conn,
kind="edge_update",
payload={
"source_id": "bot_a",
"target_id": "you",
"chat_id": "chat_bot_a",
"affinity_delta": 5,
"trust_delta": 2,
"knowledge_facts": ["Me likes coffee"],
},
)
# A regular memory.
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "Talked about her sister",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 2,
},
)
# A pinned memory with significance 3 (★★).
append_event(
conn,
kind="memory_written",
payload={
"owner_id": "bot_a",
"chat_id": "chat_bot_a",
"pov_summary": "First kiss",
"witness_you": 1,
"witness_host": 1,
"witness_guest": 0,
"significance": 3,
"pinned": 1,
"auto_pinned": 1,
},
)
project(conn)
def test_drawer_404_when_chat_missing(client):
response = client.get("/chats/no_such/drawer")
assert response.status_code == 404
def test_drawer_renders_scene_and_activity(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
# Scene/time anchor.
assert "2026-04-26" in body
# Activity verbs from both entities.
assert "thinking" in body
assert "looking out the window" in body
# Activity attention.
assert "the screen" in body
def test_drawer_renders_edges(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
assert "BotA" in body
assert "you" in body
# Default affinity 50 + delta 5 = 55.
assert "55" in body
# Knowledge fact appears.
assert "Me likes coffee" in body
def test_drawer_renders_memories_with_significance_markers(client, tmp_path):
_seed(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
assert "Talked about her sister" in body
assert "First kiss" in body
# Pinned counter shows 1 / 8 (or 1/8).
assert "1 / 8" in body or "1/8" in body
# Significance star marker for the pinned, score-3 memory.
assert "" in body
def test_drawer_handles_no_state_gracefully(client, tmp_path):
db = tmp_path / "test.db"
with open_db(db) as conn:
# Just enough state for the chat to exist.
append_event(
conn,
kind="bot_authored",
payload={
"id": "bot_a",
"name": "BotA",
"persona": "...",
"voice_samples": [],
"traits": [],
"backstory": "",
"initial_relationship_to_you": "",
"kickoff_prose": "",
},
)
append_event(
conn,
kind="chat_created",
payload={
"id": "chat_bot_a",
"host_bot_id": "bot_a",
"initial_time": "2026-04-26T20:00:00+00:00",
"narrative_anchor": "Day 1",
"weather": "",
},
)
project(conn)
response = client.get("/chats/chat_bot_a/drawer")
assert response.status_code == 200
body = response.text
# Drawer renders gracefully with empty placeholders.
assert "No active container" in body or "Container:" not in body
assert "No edges yet" in body or "Edges" in body