feat: drawer witness flag inline-edit (T72.3)

Memories grow per-flag witness checkboxes (you / host / guest) that
auto-submit on change via HTMX. The new POST route emits a manual_edit
event with target_kind=memory_witness and a {flag, value} payload;
prior_value mirrors the same shape so an inverse edit restores the
flag. The drawer's recent-memories query now selects the three
witness columns alongside the existing fields so the template can
render checkbox state without a second query per row.
This commit is contained in:
Joseph Doherty
2026-04-26 17:28:25 -04:00
parent c265e4ce0f
commit 607d0971c4
4 changed files with 185 additions and 5 deletions
+18
View File
@@ -24,6 +24,12 @@ T72.1 (Phase 2.5) adds one list-shaped edit:
by string equality so the route handler doesn't have to track fact by string equality so the route handler doesn't have to track fact
indices across re-renders. indices across re-renders.
T72.3 adds a per-flag witness toggle:
- ``memory_witness`` — flip one of ``witness_you`` / ``witness_host`` /
``witness_guest`` on a memory row. Payload's ``new_value`` is a dict
``{"flag": "you"|"host"|"guest", "value": 0|1}`` and ``prior_value``
mirrors the same shape so an inverse edit can restore the flag.
Pin toggles intentionally use the existing ``memory_pin_changed`` event Pin toggles intentionally use the existing ``memory_pin_changed`` event
(registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so (registered in :mod:`chat.state.memory`) rather than ``manual_edit`` so
the projection writes both ``pinned`` and ``auto_pinned`` atomically. the projection writes both ``pinned`` and ``auto_pinned`` atomically.
@@ -37,6 +43,8 @@ from sqlite3 import Connection
from chat.eventlog.log import Event from chat.eventlog.log import Event
from chat.eventlog.projector import on from chat.eventlog.projector import on
_VALID_WITNESS_FLAGS = {"you", "host", "guest"}
def _clamp(value: int, lo: int, hi: int) -> int: def _clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value)) return max(lo, min(hi, value))
@@ -120,5 +128,15 @@ def _apply_manual_edit(conn: Connection, e: Event) -> None:
target_id["target_id"], target_id["target_id"],
), ),
) )
elif kind == "memory_witness":
# T72.3: toggle one of the three witness flags on a memory row.
# ``new_value`` is the dict ``{"flag", "value"}``; ``prior_value``
# mirrors the same shape so an inverse edit restores the flag.
flag = new_value["flag"]
if flag in _VALID_WITNESS_FLAGS:
conn.execute(
f"UPDATE memories SET witness_{flag} = ? WHERE id = ?",
(1 if int(new_value["value"]) else 0, int(target_id)),
)
# Unknown target_kind: silently no-op for v1. Future kinds (activity # Unknown target_kind: silently no-op for v1. Future kinds (activity
# fields, etc.) extend the dispatch above. # fields, etc.) extend the dispatch above.
+17
View File
@@ -347,6 +347,23 @@
<input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}"> <input type="hidden" name="pinned" value="{{ 0 if m.pinned else 1 }}">
<button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button> <button type="submit">{{ 'Unpin' if m.pinned else 'Pin' }}</button>
</form> </form>
<div class="witness-row">
{% for flag in ['you', 'host', 'guest'] %}
{% set witnessed = m['witness_' ~ flag] %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/memory/witness"
hx-target="#drawer" hx-swap="innerHTML">
<input type="hidden" name="memory_id" value="{{ m.id }}">
<input type="hidden" name="flag" value="{{ flag }}">
<input type="hidden" name="new_value" value="{{ 0 if witnessed else 1 }}">
<label>
<input type="checkbox" {% if witnessed %}checked{% endif %}
onchange="this.form.requestSubmit()">
{{ flag }}
</label>
</form>
{% endfor %}
</div>
<details> <details>
<summary>Edit POV summary</summary> <summary>Edit POV summary</summary>
<form class="inline-edit" <form class="inline-edit"
+71 -2
View File
@@ -125,10 +125,13 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
# Recent memories from host's POV (witness_host = 1), most recent first. # Recent memories from host's POV (witness_host = 1), most recent first.
# Raw query keeps this read self-contained — no projector helper exposes # Raw query keeps this read self-contained — no projector helper exposes
# "latest N for an owner" yet and the drawer is the only consumer. # "latest N for an owner" yet and the drawer is the only consumer. The
# three witness flags ride along so T72.3's per-row checkboxes can
# render the current state without a second query per memory.
recent_rows = conn.execute( recent_rows = conn.execute(
""" """
SELECT id, pov_summary, significance, pinned, created_at SELECT id, pov_summary, significance, pinned, created_at,
witness_you, witness_host, witness_guest
FROM memories FROM memories
WHERE owner_id = ? AND witness_host = 1 WHERE owner_id = ? AND witness_host = 1
ORDER BY id DESC ORDER BY id DESC
@@ -143,6 +146,9 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)):
"significance": r[2], "significance": r[2],
"pinned": r[3], "pinned": r[3],
"created_at": r[4], "created_at": r[4],
"witness_you": r[5],
"witness_host": r[6],
"witness_guest": r[7],
} }
for r in recent_rows for r in recent_rows
] ]
@@ -572,6 +578,69 @@ async def edit_edge_knowledge_facts(
return await drawer(chat_id, request, conn) return await drawer(chat_id, request, conn)
# --- T72.3 witness flag inline-edit --------------------------------------
#
# Witness flags decide which entities can recall a memory (§7 retrieval).
# Editing them is rare but high-impact — flipping ``witness_guest`` from 0
# to 1 makes the memory available to the guest's prompt context. The route
# follows the T25 / T72.1 pattern: snapshot prior, append + apply
# ``manual_edit`` with a ``{flag, value}`` payload, refresh the partial.
_VALID_WITNESS_FLAGS = ("you", "host", "guest")
@router.post(
"/chats/{chat_id}/drawer/memory/witness",
response_class=HTMLResponse,
)
async def edit_memory_witness(
chat_id: str,
request: Request,
memory_id: int = Form(...),
flag: str = Form(...),
new_value: int = Form(...),
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}")
if flag not in _VALID_WITNESS_FLAGS:
raise HTTPException(
status_code=400,
detail=(
f"flag must be one of {list(_VALID_WITNESS_FLAGS)}, "
f"got {flag!r}"
),
)
row = conn.execute(
f"SELECT witness_{flag} FROM memories "
"WHERE id = ? AND chat_id = ?",
(int(memory_id), chat_id),
).fetchone()
if row is None:
raise HTTPException(
status_code=404,
detail=f"memory not found in chat: {memory_id}",
)
prior_int = int(row[0])
new_int = 1 if int(new_value) else 0
append_and_apply(
conn,
kind="manual_edit",
payload={
"target_kind": "memory_witness",
"target_id": int(memory_id),
"prior_value": {"flag": flag, "value": prior_int},
"new_value": {"flag": flag, "value": new_int},
},
)
return await drawer(chat_id, request, conn)
# --- T42 guest add/remove ------------------------------------------------- # --- T42 guest add/remove -------------------------------------------------
# #
# Adding a guest fans out into up to four events: a ``guest_added`` to flip # Adding a guest fans out into up to four events: a ``guest_added`` to flip
+79 -3
View File
@@ -1,4 +1,4 @@
"""T72.1: deferred v1 drawer edits. """T72: deferred v1 drawer edits + witness flag inline-edit.
T25 shipped affinity / significance / pin. T72.1 fills in the rest of the T25 shipped affinity / significance / pin. T72.1 fills in the rest of the
§6.4 editable surface whose ``manual_edit`` projector dispatch was already §6.4 editable surface whose ``manual_edit`` projector dispatch was already
@@ -9,11 +9,14 @@ in place (or, for ``edge_knowledge_fact``, added alongside the route):
* ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped. * ``POST /chats/{chat_id}/drawer/memory/pov-summary`` — textarea, capped.
* ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact. * ``POST /chats/{chat_id}/drawer/edge/knowledge-facts`` — add/remove fact.
T72.3 layers a witness-flag toggle on top:
* ``POST /chats/{chat_id}/drawer/memory/witness`` — ``manual_edit`` with
``target_kind`` = ``memory_witness`` and a ``{flag, value}`` payload.
Each test asserts (a) the ``manual_edit`` event lands in the log, Each test asserts (a) the ``manual_edit`` event lands in the log,
(b) the projected table reflects the new value, and (c) the response is (b) the projected table reflects the new value, and (c) the response is
the refreshed drawer partial. the refreshed drawer partial.
T72.3's witness-flag tests extend this file with the inline-edit pair.
""" """
from __future__ import annotations from __future__ import annotations
@@ -325,3 +328,76 @@ def test_edit_edge_knowledge_facts_400_on_bad_action(client, tmp_path):
assert response.status_code == 400 assert response.status_code == 400
# --- T72.3 tests (witness flag inline-edit) -------------------------------
def test_witness_flag_toggle_updates_memory_row(client, tmp_path):
"""Memory seeded with witness [you=1, host=1, guest=0]; toggling
``guest`` to 1 lands as ``witness_guest = 1`` after projection.
"""
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "guest",
"new_value": "1",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
row = conn.execute(
"SELECT witness_you, witness_host, witness_guest "
"FROM memories WHERE id = ?",
(memory_id,),
).fetchone()
assert row == (1, 1, 1)
def test_witness_flag_toggle_emits_manual_edit_event(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "guest",
"new_value": "1",
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'manual_edit'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["target_kind"] == "memory_witness"
assert payload["target_id"] == memory_id
assert payload["prior_value"] == {"flag": "guest", "value": 0}
assert payload["new_value"] == {"flag": "guest", "value": 1}
def test_witness_flag_toggle_400_on_bad_flag(client, tmp_path):
_seed(tmp_path / "test.db")
with open_db(tmp_path / "test.db") as conn:
memory_id = conn.execute("SELECT id FROM memories LIMIT 1").fetchone()[0]
response = client.post(
"/chats/chat_bot_a/drawer/memory/witness",
data={
"memory_id": str(memory_id),
"flag": "narrator",
"new_value": "1",
},
)
assert response.status_code == 400