feat: drawer events / threads / skip controls (T59)

This commit is contained in:
Joseph Doherty
2026-04-26 20:27:47 -04:00
parent 8efbcdf6c3
commit 2d14197553
3 changed files with 962 additions and 1 deletions
+115
View File
@@ -41,6 +41,121 @@
{% endif %}
</div>
{% endfor %}
<details class="skip-controls">
<summary>Elision skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Landing state hint:
<input type="text" name="landing_state_hint"
placeholder="e.g. arriving at the office">
</label>
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-26T20:30:00+00:00">
</label>
<button type="submit">Skip ahead</button>
</form>
</details>
<details class="skip-controls">
<summary>Jump skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
hx-target="#drawer" hx-swap="innerHTML">
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-27T08:00:00+00:00">
</label>
<label>
Anything notable happen? (optional)
<textarea name="notable_prose" rows="3"
placeholder="leave blank to jump without synthesizing memories"></textarea>
</label>
<label>
<input type="checkbox" name="reset_activity" value="1">
Reset activity at landing
</label>
<button type="submit">Jump ahead</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Events</h3>
{% if active_events %}
<ul class="event-list">
{% for ev in active_events %}
<li class="event-row">
<strong>{{ ev.kind }}</strong>
<span class="muted"> ({{ ev.status }})</span>
{% if ev.planned_for %}
<p class="muted">planned for: {{ ev.planned_for }}</p>
{% endif %}
{% if ev.props %}
<p class="muted">{{ ev.props|tojson }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Cancel</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No active events.</p>
{% endif %}
<details>
<summary>Plan event</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Kind:
<input type="text" name="kind" required
placeholder="e.g. dinner_reservation">
</label>
<label>
Planned for (ISO 8601):
<input type="text" name="planned_for" required
placeholder="2026-04-26T19:00:00+00:00">
</label>
<label>
Props (JSON):
<textarea name="props_json" rows="3"
placeholder='{"location": "Bistro X"}'>{}</textarea>
</label>
<button type="submit">Plan event</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Threads</h3>
{% if open_threads %}
<ul class="thread-list">
{% for th in open_threads %}
<li class="thread-row">
<strong>{{ th.title }}</strong>
{% if th.summary %}
<p>{{ th.summary }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Close</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No open threads.</p>
{% endif %}
</section>
{% if guest_bot %}
+395 -1
View File
@@ -27,19 +27,27 @@ one so a later inverse edit can restore state (§6.4 final paragraph).
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from chat.eventlog.log import append_and_apply
from chat.eventlog.log import append_and_apply, append_event
from chat.services.memory_write import record_turn_memory_for_present
from chat.services.relationship_seed import seed_inter_bot_edges
from chat.services.scene_summarize import apply_scene_close_summary
from chat.services.skip_narration import narrate_skip
from chat.services.synthesized_memories import synthesize_memories
from chat.state.edges import get_edge
from chat.state.entities import get_bot, get_you, list_bots
from chat.state.events import list_active_events
from chat.state.group_node import get_group_node
from chat.state.memory import get_pinned
from chat.state.threads import list_open_threads
from chat.state.world import active_scene, get_activity, get_chat, get_container
from chat.web.bots import get_conn
from chat.web.kickoff import get_llm_client
@@ -155,6 +163,10 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
pinned = get_pinned(conn, chat["host_bot_id"])
# T59: active events + open threads for the new drawer sections.
active_events = list_active_events(conn, chat_id)
open_threads = list_open_threads(conn, chat_id)
return TEMPLATES.TemplateResponse(
request,
"_drawer.html",
@@ -180,6 +192,8 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"recent_memories": recent_memories,
"pinned": pinned,
"pin_cap": PIN_CAP,
"active_events": active_events,
"open_threads": open_threads,
},
)
@@ -839,3 +853,383 @@ async def remove_guest(
)
return await drawer(chat_id, request, conn)
# --- T59 events / threads / skip controls --------------------------------
#
# Five drawer-driven endpoints that emit Phase 3 event-log entries:
#
# * ``event_planned`` / ``event_cancelled`` for the events panel — props
# arrive as a JSON-encoded form field so the user can author arbitrary
# structured side-info without a custom HTMX widget per kind.
# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel —
# each emits the projector event AND an ``assistant_turn`` carrying the
# narration prose from :mod:`chat.services.skip_narration`. Jump skips
# ALSO write per-bot synthesized memories from any user-supplied
# ``notable_prose`` via :func:`synthesize_memories` +
# :func:`record_turn_memory_for_present`.
# * ``thread_closed`` for the threads panel.
#
# Skip narration is appended via plain ``append_event`` (assistant_turn
# has no projector handler — it's a transcript-only kind, see
# :func:`chat.web.turns._read_recent_dialogue`). The user will see the
# new turn on the next chat-detail page load; we do NOT broadcast via
# ``publish`` here because the SSE channel is scoped to the chat-detail
# page and the drawer partial is the response body — adding cross-cutting
# SSE here would require dragging the publish import + chat-channel state
# into the drawer module without a meaningful UX gain (the drawer only
# rerenders itself on these submissions).
def _parse_iso_time(value: str) -> datetime | None:
"""Permissive ISO 8601 parser for skip route validation.
``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until 3.11,
so we normalize it to ``+00:00`` first. Returns ``None`` on parse
failure so the caller can return ``400`` with a stable error shape.
"""
if not value:
return None
try:
v = value.strip()
if v.endswith("Z"):
v = v[:-1] + "+00:00"
return datetime.fromisoformat(v)
except (TypeError, ValueError):
return None
def _now_iso() -> str:
"""UTC ISO timestamp used as a fallback when the chat clock is unset."""
return datetime.now(timezone.utc).isoformat()
@router.post(
"/chats/{chat_id}/drawer/event/plan",
response_class=HTMLResponse,
)
async def plan_event(
chat_id: str,
request: Request,
kind: str = Form(...),
planned_for: str = Form(...),
props_json: str = Form("{}"),
conn=Depends(get_conn),
):
"""Append an ``event_planned`` row from the drawer's "Plan event" form.
``props_json`` is parsed into a dict before being attached to the
payload so the projector can treat it as structured data. Bad JSON
yields ``400`` — the form template renders an inline error in that
case so the user can fix-and-resubmit without losing their input.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
try:
props = json.loads(props_json) if props_json.strip() else {}
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=400, detail=f"props_json must be valid JSON: {exc}"
)
if not isinstance(props, dict):
raise HTTPException(
status_code=400, detail="props_json must encode a JSON object"
)
event_id = f"evt_{uuid.uuid4().hex[:12]}"
append_and_apply(
conn,
kind="event_planned",
payload={
"event_id": event_id,
"chat_id": chat_id,
"kind": kind,
"props": props,
"planned_for": planned_for,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/event/cancel/{event_id}",
response_class=HTMLResponse,
)
async def cancel_event(
chat_id: str,
event_id: str,
request: Request,
conn=Depends(get_conn),
):
"""Append an ``event_cancelled`` row for ``event_id``.
``completed_at`` is sourced from the chat clock (so cancellations
timeline-align with the rest of the fiction) with a UTC-now fallback
when the clock isn't set. The projector is idempotent on terminal
statuses so a stale double-submit is harmless.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
completed_at = chat.get("time") or _now_iso()
append_and_apply(
conn,
kind="event_cancelled",
payload={
"event_id": event_id,
"completed_at": completed_at,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/skip/elision",
response_class=HTMLResponse,
)
async def skip_elision(
chat_id: str,
request: Request,
landing_state_hint: str = Form(""),
new_time: str = Form(...),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Elision skip: collapse in-progress activity into its end-state.
Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the
chat clock (a backwards skip would corrupt downstream causality).
Emits ``time_skip_elision`` first (chat clock advances) then an
``assistant_turn`` carrying the narrated transition from
:func:`chat.services.skip_narration.narrate_skip`. The narration call
has its own LLM-failure fallback so this route never blocks on a
flaky model.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise HTTPException(
status_code=400,
detail=f"new_time must be ISO 8601, got {new_time!r}",
)
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise HTTPException(
status_code=400,
detail="new_time must not be earlier than the current chat clock",
)
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
bot_activity = get_activity(conn, chat["host_bot_id"]) or {}
current_activity = (
(bot_activity.get("action") or {}).get("verb") or ""
)
settings = request.app.state.settings
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="elision",
speaker_bot=host_bot,
you_name=you_entity.get("name") or "you",
current_time=chat.get("time") or "",
new_time=new_time,
current_activity=current_activity,
landing_state_hint=landing_state_hint,
timeout_s=settings.classifier_timeout_s,
)
append_and_apply(
conn,
kind="time_skip_elision",
payload={"chat_id": chat_id, "new_time": new_time},
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": host_bot["id"] if "id" in host_bot else chat["host_bot_id"],
"text": narration,
"truncated": False,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/skip/jump",
response_class=HTMLResponse,
)
async def skip_jump(
chat_id: str,
request: Request,
new_time: str = Form(...),
notable_prose: str = Form(""),
reset_activity: str = Form(""),
conn=Depends(get_conn),
client=Depends(get_llm_client),
):
"""Jump skip: bridge a longer fiction-time delta.
Same ISO + non-decreasing validations as the elision route. When
``notable_prose`` is non-empty, runs :func:`synthesize_memories`
once per present bot witness (host always; guest when present),
then writes one ``memory_written`` per synthesized memory via
:func:`record_turn_memory_for_present` (which fans out to host +
guest internally). Each call writes ``source="synthesized"`` so the
retrieval ranker can treat them as lower-reliability than direct
turn memories. Finally emits ``time_skip_jump`` and the narration
``assistant_turn``.
``reset_activity`` is parsed permissively ("1" / "true" / "on" /
"yes" — same shape as the add-guest reseed flag) since HTML
checkboxes typically post the literal "1" or omit the field
entirely.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
new_dt = _parse_iso_time(new_time)
if new_dt is None:
raise HTTPException(
status_code=400,
detail=f"new_time must be ISO 8601, got {new_time!r}",
)
cur_dt = _parse_iso_time(chat.get("time") or "")
if cur_dt is not None and new_dt < cur_dt:
raise HTTPException(
status_code=400,
detail="new_time must not be earlier than the current chat clock",
)
reset_flag = reset_activity.lower() in ("1", "true", "on", "yes")
host_bot = get_bot(conn, chat["host_bot_id"]) or {
"id": chat["host_bot_id"],
"name": "host",
"persona": "",
}
you_entity = get_you(conn) or {"name": "you"}
you_name = you_entity.get("name") or "you"
guest_bot_id = chat.get("guest_bot_id")
guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None
settings = request.app.state.settings
# Emit time_skip_jump up front so the chat clock is at the new time
# before any memory writes — they should record at the post-jump
# clock, mirroring how a regular turn's memory carries the chat clock.
append_and_apply(
conn,
kind="time_skip_jump",
payload={
"chat_id": chat_id,
"new_time": new_time,
"reset_activity": reset_flag,
},
)
# Synthesize memories per present bot witness when prose is non-empty.
# ``synthesize_memories`` short-circuits on whitespace prose so this
# is safe to call unconditionally, but we gate the loop to avoid
# iterating a fixed empty list.
if notable_prose.strip():
present_bots: list[dict] = [host_bot]
if guest_bot is not None:
present_bots.append(guest_bot)
for bot in present_bots:
digest = await synthesize_memories(
client,
classifier_model=settings.classifier_model,
prose=notable_prose,
bot_name=bot.get("name") or "",
bot_persona=bot.get("persona") or "",
you_name=you_name,
timeout_s=settings.classifier_timeout_s,
)
for mem in digest.memories:
# ``record_turn_memory_for_present`` writes one
# ``memory_written`` per present bot per call. Calling it
# once per synthesized memory means N memories x M bots
# = N*M events; the loop above already iterates by bot
# so we pass guest_bot_id=None here to avoid double-
# writing the guest's row when bot==guest.
record_turn_memory_for_present(
conn,
chat_id=chat_id,
host_bot_id=bot["id"],
guest_bot_id=None,
narrative_text=mem.text,
chat_clock_at=new_time,
source="synthesized",
significance=mem.significance,
)
narration = await narrate_skip(
client,
narrative_model=settings.narrative_model,
skip_kind="jump",
speaker_bot=host_bot,
you_name=you_name,
current_time=chat.get("time") or "",
new_time=new_time,
current_activity="",
landing_state_hint=notable_prose,
timeout_s=settings.classifier_timeout_s,
)
append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": chat_id,
"speaker_id": host_bot.get("id") or chat["host_bot_id"],
"text": narration,
"truncated": False,
},
)
return await drawer(chat_id, request, conn)
@router.post(
"/chats/{chat_id}/drawer/thread/close/{thread_id}",
response_class=HTMLResponse,
)
async def close_thread(
chat_id: str,
thread_id: str,
request: Request,
conn=Depends(get_conn),
):
"""Append a ``thread_closed`` row for ``thread_id``.
Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector
handles idempotency. The drawer's open-threads list is sourced from
``list_open_threads`` which filters by ``status='open'`` so a stale
double-submit is a no-op visually.
"""
chat = get_chat(conn, chat_id)
if chat is None:
raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}")
closed_at = chat.get("time") or _now_iso()
append_and_apply(
conn,
kind="thread_closed",
payload={
"thread_id": thread_id,
"closed_at": closed_at,
},
)
return await drawer(chat_id, request, conn)