diff --git a/CLAUDE.md b/CLAUDE.md index f398f33..f04d9ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ Two voice-LLM paths coexist (selected via `selected_module.LLM` in `data/.config │ ▼ JSON-RPC 2.0 / ACP over stdio POST /api/voice/escalate → zeroclaw-bridge ▼ │ - ZeroClaw (the brain) ◄─────────────────┘ (think_hard / memory_lookup / take_photo / play_song) + ZeroClaw (the brain) ◄─────────────────┘ (think_hard / memory_lookup / take_photo / play_song / remember_person) ``` Smart-mode flips the backend model: legacy path rewrites `~/.zeroclaw/config.toml` and restarts the bridge daemon; Tier1Slim path hot-swaps the live provider via `/xiaozhi/admin/set-tier1slim-model`. @@ -66,7 +66,7 @@ This repo uses placeholders (``, ``, `` namespace of `brain.db`, pre-rendered by the + caller and passed in as `person_memory_block`. Injected after + `[Speaking with]` and before `[Current perception]`. See #53. `device_id` is curried into the wrapper so `_build_perception_block` can read the latest perception caches at every turn (multi-turn @@ -1120,6 +1125,8 @@ def _voice_preparer(channel: str | None, resolution=None, speaker_block = _build_speaker_block(resolution) if speaker_block: block_parts.append(speaker_block) + if person_memory_block: + block_parts.append(person_memory_block) if room_description: cleaned = room_description.strip() # Defensive: cap length so a runaway VLM response can't blow @@ -3899,6 +3906,10 @@ async def audio_explain( _VOICE_THINKER_MODEL = os.environ.get("VOICE_THINKER_MODEL", "qwen3.6:27b-think") _VOICE_THINKER_TIMEOUT = float(os.environ.get("VOICE_THINKER_TIMEOUT", "30")) +# #53 per-person memory — how many durable facts to load into a turn's +# `[Person memory]` block. Direct `person:` namespace fetch, not FTS. +_PERSON_MEMORY_MAX_FACTS = int(os.environ.get("PERSON_MEMORY_MAX_FACTS", "8")) + def _voice_memory_search_blocking(query: str, limit: int = 5) -> list[dict]: """FTS5 search across `memories`. Read-only, WAL-friendly. Returns @@ -3973,6 +3984,170 @@ def _voice_memory_store_blocking( return False +def _voice_memory_person_fetch_blocking( + person_id: str, limit: int = _PERSON_MEMORY_MAX_FACTS, +) -> list[dict]: + """Fetch durable per-person memory rows for `person_id` — a direct + `namespace='person:'` lookup, *not* an FTS search. Ordered by + importance then recency. Read-only, WAL-friendly. Empty list on any + error, unknown person, or missing db. + + Only the approved `person:` namespace is read. The kid-safety + pending namespace (`person_pending:`, written for minors) is + deliberately excluded so unreviewed facts never reach a prompt — see + the #53 review-before-write gate.""" + import sqlite3 + if not _VOICE_MEMORY_DB.exists(): + log.warning("voice memory: db not found at %s", _VOICE_MEMORY_DB) + return [] + pid = (person_id or "").strip().lower() + if not pid: + return [] + try: + conn = sqlite3.connect( + f"file:{_VOICE_MEMORY_DB}?mode=ro", uri=True, timeout=2, + ) + try: + conn.row_factory = sqlite3.Row + cur = conn.execute( + """ + SELECT key, content, category, importance, + created_at, updated_at + FROM memories + WHERE namespace = ? + ORDER BY importance DESC, updated_at DESC + LIMIT ? + """, + (f"person:{pid}", limit), + ) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + except Exception: + log.exception( + "voice memory person fetch failed for person_id=%r", pid, + ) + return [] + + +def _build_person_memory_block(person_id: str | None) -> str: + """Render durable per-person memory for `person_id` as a + `[Person memory]` block for the talk-turn system prompt. + + Performs a blocking SQLite read — **must** be called via + `asyncio.to_thread`, never directly on the event loop. Returns "" + when nothing is stored or `person_id` is None, so turns for unknown + / first-time speakers don't waste prompt tokens on an empty marker. + `_voice_preparer` injects the result after `[Speaking with]` and + before `[Current perception]` (#53).""" + if not person_id: + return "" + rows = _voice_memory_person_fetch_blocking(person_id) + if not rows: + return "" + facts: list[str] = [] + for r in rows: + content = (r.get("content") or "").strip() + if not content: + continue + if len(content) > 160: + content = content[:159].rstrip() + "…" + facts.append(f"- {content}") + if not facts: + return "" + return "[Person memory]\n" + "\n".join(facts) + "\n" + + +def _voice_memory_person_records_blocking() -> list[dict]: + """List every per-person memory row — approved (`person:`) and + pending review (`person_pending:`). Powers the /ui/memory + dashboard (#53). Read-only. Empty list on error / missing db.""" + import sqlite3 + if not _VOICE_MEMORY_DB.exists(): + return [] + try: + conn = sqlite3.connect( + f"file:{_VOICE_MEMORY_DB}?mode=ro", uri=True, timeout=2, + ) + try: + conn.row_factory = sqlite3.Row + cur = conn.execute( + """ + SELECT id, content, namespace, importance, + created_at, updated_at + FROM memories + WHERE substr(namespace, 1, 7) = 'person:' + OR substr(namespace, 1, 15) = 'person_pending:' + ORDER BY namespace, importance DESC, updated_at DESC + """ + ) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + except Exception: + log.exception("voice memory person records list failed") + return [] + + +def _voice_memory_approve_blocking(mem_id: str) -> bool: + """Promote a pending per-person memory row to approved — + `person_pending:` → `person:` (the #53 kid-safety review + action). Returns False if the row is missing or not in a pending + namespace, so a double-approve is a safe no-op.""" + import sqlite3 + if not _VOICE_MEMORY_DB.exists() or not mem_id: + return False + prefix = "person_pending:" + now = datetime.now(ZoneInfo("UTC")).isoformat() + try: + conn = sqlite3.connect(str(_VOICE_MEMORY_DB), timeout=5) + try: + cur = conn.execute( + "SELECT namespace FROM memories WHERE id = ?", (mem_id,), + ) + row = cur.fetchone() + if row is None: + return False + namespace = row[0] or "" + if not namespace.startswith(prefix): + return False + approved = "person:" + namespace[len(prefix):] + conn.execute( + "UPDATE memories SET namespace = ?, updated_at = ? " + "WHERE id = ?", + (approved, now, mem_id), + ) + conn.commit() + return True + finally: + conn.close() + except Exception: + log.exception("voice memory approve failed (id=%s)", mem_id) + return False + + +def _voice_memory_delete_blocking(mem_id: str) -> bool: + """Delete a memory row by id — the /ui/memory redact action. The + FTS5 triggers drop the matching index row. Returns False if nothing + matched.""" + import sqlite3 + if not _VOICE_MEMORY_DB.exists() or not mem_id: + return False + try: + conn = sqlite3.connect(str(_VOICE_MEMORY_DB), timeout=5) + try: + cur = conn.execute( + "DELETE FROM memories WHERE id = ?", (mem_id,), + ) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + except Exception: + log.exception("voice memory delete failed (id=%s)", mem_id) + return False + + async def _voice_tool_memory_lookup(args: dict, session_id: str) -> str: query = (args.get("query") or "").strip() if not query: @@ -4141,11 +4316,35 @@ def _post() -> tuple[bool, str]: return f"(couldn't play {match}: {err})" +async def _voice_tool_remember_person(args: dict, session_id: str) -> str: + """#53 escalate handler — store a durable fact about a named household + member. `args` carries the 4B's `name` + `fact`. The household-registry + id is the person's name lowercased (the `person:` namespace + convention), so `name` is used directly as the person_id. Returns the + same confirmation strings as the pi-runtime `remember_person` tool so + the second model call can phrase the spoken reply consistently.""" + name = (args.get("name") or args.get("person_id") or "").strip() + if not name: + return "(no person specified)" + if not (args.get("fact") or "").strip(): + return "(empty fact)" + valid, stored, needs_review = await _person_memory_store( + name, args.get("fact") or "", session_id or None, + ) + if not valid or not stored: + return "(remember failed)" + return ( + "(saved — a grown-up will check that)" if needs_review + else f"(remembered about {name})" + ) + + _VOICE_TOOLS = { "memory_lookup": _voice_tool_memory_lookup, "think_hard": _voice_tool_think_hard, "take_photo": _voice_tool_take_photo, "play_song": _voice_tool_play_song, + "remember_person": _voice_tool_remember_person, } @@ -4166,6 +4365,12 @@ class VoiceRememberIn(BaseModel): session_id: str | None = None +class VoiceRememberPersonIn(BaseModel): + person_id: str + fact: str + session_id: str | None = None + + @app.post("/api/voice/escalate") async def voice_escalate(payload: VoiceEscalateIn): """Synchronous Tier 2 tool dispatcher. Called by tier1_slim when the 4B @@ -4219,6 +4424,105 @@ async def voice_remember(payload: VoiceRememberIn): ) +# #53 kid-safety gate — TRANSITIONAL MIRROR. +# +# The canonical implementation lives in dotty-behaviour/routes/voice.py +# (`_ADULT_RELATIONS` + `person_needs_review`). This copy exists only so +# the legacy bridge.py write paths can gate in-process against the +# in-process `_household_registry` — the two services are separate +# Docker images on separate hosts and cannot share an import. It MUST +# stay byte-identical to the dotty-behaviour version: edit there first, +# then mirror here. Both copies disappear when the #36 rehoming retires +# bridge.py + bridge/*. +# +# Relations that affirmatively mark a household member as an adult — +# lets a registry entry with no `age:` still auto-commit. Everything +# *not* in this set (a known minor, an ambiguous relation, an unknown +# person) is routed to review by the kid-safety gate below. +_ADULT_RELATIONS = frozenset({ + "self", "owner", "parent", "mother", "father", "mum", "mom", "dad", + "partner", "spouse", "husband", "wife", "grandparent", "grandmother", + "grandfather", "aunt", "uncle", "sibling", "brother", "sister", +}) + + +def _person_memory_needs_review(person_id: str) -> bool: + """#53 kid-safety gate: decide whether a declared fact about + `person_id` must go to the `person_pending:` review queue + rather than straight into readable `person:` memory. + + Conservative by design — a fact auto-commits **only** when the + speaker is affirmatively an adult per the hand-authored household + registry (`age >= 18`, or an adult `relation`). A known minor, an + unknown person, or a registry entry too sparse to classify all + route to review. The safe failure mode is "a human looks first", + never "written about a minor unreviewed".""" + if _household_registry is None: + return True + try: + person = _household_registry.get(person_id) + except Exception: + log.debug("person memory gate: registry.get raised", exc_info=True) + return True + if person is None: + return True # unknown person — cannot rule out a minor + if person.age is not None: + return person.age < 18 + return (person.relation or "").strip().lower() not in _ADULT_RELATIONS + + +async def _person_memory_store( + person_id: str, fact: str, session_id: str | None, +) -> tuple[bool, bool, bool]: + """#53 gate + store for a declared per-person fact. Runs the kid-safety + gate (`_person_memory_needs_review`), writes to `person:` or the + `person_pending:` review queue accordingly, and returns + `(valid, stored, needs_review)`. `valid` is False when `person_id` or + `fact` is empty after normalisation. Shared by the + `/api/voice/remember_person` endpoint and the tier1slim escalate tool + handler so both write paths apply an identical gate decision.""" + pid = (person_id or "").strip().lower() + fact = (fact or "").strip()[:300] + if not pid or not fact: + return False, False, False + needs_review = _person_memory_needs_review(pid) + namespace = ( + f"person_pending:{pid}" if needs_review else f"person:{pid}" + ) + stored = await asyncio.to_thread( + _voice_memory_store_blocking, + content=fact, category="core", namespace=namespace, + importance=0.7, session_id=session_id, + ) + if stored: + log.info( + "person memory %s person=%s review=%s", + "queued" if needs_review else "stored", pid, needs_review, + ) + else: + log.warning("person memory store failed person=%s", pid) + return True, stored, needs_review + + +@app.post("/api/voice/remember_person") +async def voice_remember_person(payload: VoiceRememberPersonIn): + """Store a declared fact about a specific household member (#53). + + Unlike `/api/voice/remember` this is *not* fire-and-forget: the + kid-safety gate (`_person_memory_needs_review`) decides up front + whether the fact lands in readable `person:` memory or the + `person_pending:` review queue, and the caller is told which — + so the voice layer can phrase its confirmation ("I'll remember + that" vs "I'll check with a grown-up first"). Facts routed to + review are never loaded into a prompt until approved.""" + valid, stored, needs_review = await _person_memory_store( + payload.person_id, payload.fact, payload.session_id, + ) + if not valid: + return {"ok": False, "pending_review": False, "error": "empty"} + return {"ok": stored, "pending_review": needs_review} + + # --------------------------------------------------------------------------- # Scene synthesis — periodic "what's happening right now" memory writes # --------------------------------------------------------------------------- @@ -4885,6 +5189,7 @@ async def message(payload: MessageIn) -> MessageOut: ) await _refresh_caches() speaker = _resolve_speaker_for_request(payload) + person_memory_block = None if speaker is not None and speaker.person_id: log.info( "speaker channel=%s person=%s addressee=%s conf=%.2f signals=%s", @@ -4892,6 +5197,9 @@ async def message(payload: MessageIn) -> MessageOut: speaker.confidence, ",".join(v.signal for v in speaker.votes) or "-", ) + person_memory_block = await asyncio.to_thread( + _build_person_memory_block, speaker.person_id, + ) t0 = perf_counter() error_msg = None try: @@ -4904,6 +5212,7 @@ async def message(payload: MessageIn) -> MessageOut: room_description=(payload.metadata or {}).get( "room_description"), device_id=(payload.metadata or {}).get("device_id"), + person_memory_block=person_memory_block, ), ), timeout=REQUEST_TIMEOUT_SEC, @@ -5101,6 +5410,18 @@ def _identity_display_name(identity: str) -> str | None: return None return getattr(person, "display_name", None) or None + async def _dashboard_memory_records() -> list[dict]: + """All per-person memory rows (approved + pending) for /ui/memory.""" + return await asyncio.to_thread(_voice_memory_person_records_blocking) + + async def _dashboard_memory_approve(mem_id: str) -> dict: + ok = await asyncio.to_thread(_voice_memory_approve_blocking, mem_id) + return {"ok": ok} + + async def _dashboard_memory_redact(mem_id: str) -> dict: + ok = await asyncio.to_thread(_voice_memory_delete_blocking, mem_id) + return {"ok": ok} + _configure_dashboard( send_message=_dashboard_send_message, vision_cache=_vision_cache, @@ -5118,6 +5439,9 @@ def _identity_display_name(identity: str) -> str | None: unsubscribe_events=_dashboard_unsubscribe_events, perception_state_getter=_dashboard_perception_state_getter, perception_recent_getter=get_recent_perception, + memory_records_getter=_dashboard_memory_records, + memory_approve=_dashboard_memory_approve, + memory_redact=_dashboard_memory_redact, identity_display_name=_identity_display_name, last_user_line_getter=_get_last_user_line, sound_balance_getter=_sound_balance_series, @@ -5585,6 +5909,7 @@ async def message_stream(payload: MessageIn) -> StreamingResponse: ) await _refresh_caches() speaker = _resolve_speaker_for_request(payload) + person_memory_block = None if speaker is not None and speaker.person_id: log.info( "speaker channel=%s person=%s addressee=%s conf=%.2f signals=%s", @@ -5592,6 +5917,9 @@ async def message_stream(payload: MessageIn) -> StreamingResponse: speaker.confidence, ",".join(v.signal for v in speaker.votes) or "-", ) + person_memory_block = await asyncio.to_thread( + _build_person_memory_block, speaker.person_id, + ) # `t_request_start` is captured per-request and read inside on_chunk # so the first-audio histogram observes the elapsed time at the @@ -5661,6 +5989,7 @@ async def run_turn() -> None: room_description=(payload.metadata or {}).get( "room_description"), device_id=(payload.metadata or {}).get("device_id"), + person_memory_block=person_memory_block, ), ), timeout=REQUEST_TIMEOUT_SEC, diff --git a/bridge/dashboard.py b/bridge/dashboard.py index 2f592b2..beb85b3 100644 --- a/bridge/dashboard.py +++ b/bridge/dashboard.py @@ -50,6 +50,9 @@ "perception_recent_getter": None, "identity_display_name": None, "last_user_line_getter": None, + "memory_records_getter": None, + "memory_approve": None, + "memory_redact": None, "sound_balance_getter": None, "vision_failures_getter": None, } @@ -68,6 +71,9 @@ def configure(*, send_message: Any = None, vision_cache: dict | None = None, perception_recent_getter: Any = None, identity_display_name: Any = None, last_user_line_getter: Any = None, + memory_records_getter: Any = None, + memory_approve: Any = None, + memory_redact: Any = None, sound_balance_getter: Any = None, vision_failures_getter: Any = None) -> None: """Register bridge state with the dashboard. Idempotent.""" @@ -107,6 +113,12 @@ def configure(*, send_message: Any = None, vision_cache: dict | None = None, _state["identity_display_name"] = identity_display_name if last_user_line_getter is not None: _state["last_user_line_getter"] = last_user_line_getter + if memory_records_getter is not None: + _state["memory_records_getter"] = memory_records_getter + if memory_approve is not None: + _state["memory_approve"] = memory_approve + if memory_redact is not None: + _state["memory_redact"] = memory_redact if sound_balance_getter is not None: _state["sound_balance_getter"] = sound_balance_getter if vision_failures_getter is not None: @@ -898,6 +910,77 @@ async def state_set(request: Request, state: str = Form(...)) -> Any: ) +@router.get("/memory", response_class=HTMLResponse, include_in_schema=False) +async def memory_partial(request: Request) -> Any: + """#53 per-person memory review surface — approved records plus the + kid-safety pending-review queue, grouped by person.""" + getter = _state.get("memory_records_getter") + rows = (await getter()) if getter else [] + pending: dict[str, list] = {} + approved: dict[str, list] = {} + for r in rows: + ns = r.get("namespace") or "" + if ns.startswith("person_pending:"): + pending.setdefault(ns[len("person_pending:"):], []).append(r) + elif ns.startswith("person:"): + approved.setdefault(ns[len("person:"):], []).append(r) + return templates.TemplateResponse( + request, "memory_list.html", + { + "available": getter is not None, + "pending": pending, + "approved": approved, + "pending_count": sum(len(v) for v in pending.values()), + }, + ) + + +@router.post("/actions/memory/approve", response_class=HTMLResponse, + include_in_schema=False) +async def memory_approve(request: Request, mem_id: str = Form(...)) -> Any: + """Promote a pending per-person fact to readable memory.""" + fn = _state.get("memory_approve") + if fn is None: + raise HTTPException(503, "memory_approve not configured") + try: + result = await fn(mem_id) + ok = bool(result.get("ok") if isinstance(result, dict) else result) + except Exception as exc: + log.exception("memory approve failed") + return templates.TemplateResponse( + request, "memory_result.html", + {"ok": False, "action": "approve", "error": str(exc)}, + ) + return templates.TemplateResponse( + request, "memory_result.html", + {"ok": ok, "action": "approve", + "error": None if ok else "row not found or not pending review"}, + ) + + +@router.post("/actions/memory/redact", response_class=HTMLResponse, + include_in_schema=False) +async def memory_redact(request: Request, mem_id: str = Form(...)) -> Any: + """Delete a per-person memory row (approved or pending).""" + fn = _state.get("memory_redact") + if fn is None: + raise HTTPException(503, "memory_redact not configured") + try: + result = await fn(mem_id) + ok = bool(result.get("ok") if isinstance(result, dict) else result) + except Exception as exc: + log.exception("memory redact failed") + return templates.TemplateResponse( + request, "memory_result.html", + {"ok": False, "action": "redact", "error": str(exc)}, + ) + return templates.TemplateResponse( + request, "memory_result.html", + {"ok": ok, "action": "redact", + "error": None if ok else "row not found"}, + ) + + @router.get("/smart-mode", response_class=HTMLResponse, include_in_schema=False) async def smart_mode_partial(request: Request) -> Any: getter = _state.get("smart_mode_getter") diff --git a/bridge/templates/dashboard.html b/bridge/templates/dashboard.html index 75c6cc9..8eecd7b 100644 --- a/bridge/templates/dashboard.html +++ b/bridge/templates/dashboard.html @@ -234,6 +234,17 @@ +
+
+
+
+
+
+
+
); approved = readable memory (person:). #} +
+
+ Person memory + {% if pending_count %} + {{ pending_count }} awaiting review + {% endif %} +
+ + {% if not available %} +
+ Memory store not available — bridge missing the memory helper. +
+ {% else %} + + {% if pending %} +
+ ⚠ Awaiting review — declared about a child / unidentified person +
+
    + {% for pid, rows in pending.items() %} + {% for r in rows %} +
  • +
    + {{ pid }} + imp {{ "%.1f"|format(r.importance or 0) }} +
    +
    {{ r.content }}
    +
    +
    + + +
    +
    + + +
    +
    +
  • + {% endfor %} + {% endfor %} +
+ {% endif %} + + {% if approved %} +
Stored
+
    + {% for pid, rows in approved.items() %} + {% for r in rows %} +
  • +
    + {{ pid }} + imp {{ "%.1f"|format(r.importance or 0) }} +
    +
    {{ r.content }}
    +
    + + +
    +
  • + {% endfor %} + {% endfor %} +
+ {% endif %} + + {% if not pending and not approved %} +
+ No person memories yet. +
+ {% endif %} + +
+ {% endif %} +
diff --git a/bridge/templates/memory_result.html b/bridge/templates/memory_result.html new file mode 100644 index 0000000..0240c1e --- /dev/null +++ b/bridge/templates/memory_result.html @@ -0,0 +1,12 @@ +{# Result of an /ui/actions/memory/* action. On success, reloads the + memory card after a beat so the list reflects the change. Mirrors + state_result.html. #} +{% if ok %} +
+ {% if action == "approve" %}Approved — moved to readable memory.{% else %}Redacted.{% endif %} +
+{% else %} +
{{ error or "Action failed." }}
+{% endif %} diff --git a/custom-providers/tier1_slim/tier1_slim.py b/custom-providers/tier1_slim/tier1_slim.py index 1e2f373..fa577a8 100644 --- a/custom-providers/tier1_slim/tier1_slim.py +++ b/custom-providers/tier1_slim/tier1_slim.py @@ -110,6 +110,27 @@ "parameters": {"type": "object", "properties": {}}, }, }, + { + "type": "function", + "function": { + "name": "remember_person", + "description": ( + "Save a durable fact about a specific NAMED person — a " + "preference, relationship, or lasting detail. Use when the " + "user tells you something worth keeping about a particular " + "person. For a general fact not tied to one named person, " + "do NOT use this — reply normally with a [REMEMBER: ...] marker." + ), + "parameters": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "The person the fact is about."}, + "fact": {"type": "string", "description": "The fact to remember, as one short sentence."}, + }, + "required": ["name", "fact"], + }, + }, + }, ] # Per-tool filler phrases. Spoken via the TTS pipeline while the tool runs. @@ -120,6 +141,7 @@ "think_hard": None, "take_photo": "😮 Let me have a look.", "play_song": None, + "remember_person": None, } diff --git a/dotty-behaviour/routes/voice.py b/dotty-behaviour/routes/voice.py index a213d3b..52c6bdc 100644 --- a/dotty-behaviour/routes/voice.py +++ b/dotty-behaviour/routes/voice.py @@ -56,3 +56,80 @@ async def voice_take_photo( if best_desc and best_age <= TAKE_PHOTO_FRESHNESS_SEC: return {"description": best_desc[:TAKE_PHOTO_MAX_CHARS]} return {"description": TAKE_PHOTO_FALLBACK} + + +# --- per-person memory: kid-safety review classifier (#53) ----------------- +# +# dotty-pi-ext's remember_person tool writes per-person facts straight to +# brain.db, but a fact about a minor must be human-reviewed before it +# becomes readable context. The gate *decision* needs the household +# registry, so it lives here (single-source, Python) rather than being +# duplicated in the TS tool — remember_person calls this classifier, then +# writes to person: or person_pending: accordingly. + + +def get_household(request: Request): + hh = getattr(request.app.state, "household", None) + if hh is None: + raise RuntimeError("HouseholdRegistry not attached to app.state") + return hh + + +# #53 kid-safety gate — CANONICAL SOURCE. +# +# `_ADULT_RELATIONS` + `person_needs_review` below are the single source +# of truth for the per-person-memory gate. bridge.py carries a +# byte-identical transitional mirror (`_ADULT_RELATIONS` / +# `_person_memory_needs_review`) because its legacy write paths gate +# in-process — the two services are separate Docker images on separate +# hosts, so they cannot share an import. Edit *here* first, then mirror +# into bridge.py; the mirror disappears when the #36 rehoming retires +# bridge.py + bridge/*. +# +# Relations that affirmatively mark a household member as an adult — lets +# a registry entry with no `age:` still auto-commit. Everything *not* in +# this set (a known minor, an ambiguous relation, an unknown person) +# routes to review. +_ADULT_RELATIONS = frozenset({ + "self", "owner", "parent", "mother", "father", "mum", "mom", "dad", + "partner", "spouse", "husband", "wife", "grandparent", "grandmother", + "grandfather", "aunt", "uncle", "sibling", "brother", "sister", +}) + + +def person_needs_review(household, person_id: str) -> bool: + """#53 kid-safety gate. A declared per-person fact may be auto- + committed only when the speaker is affirmatively an adult per the + household registry (`age >= 18`, or an adult `relation`). A known + minor, an unknown person, or a registry entry too sparse to classify + all route to review. Safe failure mode: "a human looks first".""" + if household is None: + return True + try: + person = household.get(person_id) + except Exception: + log.debug("person_needs_review: registry.get raised", exc_info=True) + return True + if person is None: + return True # unknown person — cannot rule out a minor + if person.age is not None: + return person.age < 18 + return (person.relation or "").strip().lower() not in _ADULT_RELATIONS + + +@router.get("/api/voice/person_review_status") +async def voice_person_review_status( + person_id: str, + household=Depends(get_household), +) -> dict: + """Kid-safety classifier for dotty-pi-ext's remember_person tool. + + Returns whether a declared fact about `person_id` must be routed to + the `person_pending:` review queue (minor / unknown / + unclassifiable) rather than committed straight to readable + `person:` memory.""" + pid = (person_id or "").strip().lower() + return { + "person_id": pid, + "needs_review": person_needs_review(household, pid), + } diff --git a/dotty-behaviour/tests/test_routes_voice.py b/dotty-behaviour/tests/test_routes_voice.py index 277f4cf..b378a33 100644 --- a/dotty-behaviour/tests/test_routes_voice.py +++ b/dotty-behaviour/tests/test_routes_voice.py @@ -6,8 +6,9 @@ from fastapi.testclient import TestClient +from household import Person from main import app -from routes.voice import TAKE_PHOTO_FALLBACK +from routes.voice import TAKE_PHOTO_FALLBACK, person_needs_review def test_take_photo_returns_fallback_when_cache_empty() -> None: @@ -72,3 +73,78 @@ def test_take_photo_returns_fallback_when_stale() -> None: } r = client.get("/api/voice/take_photo") assert r.json() == {"description": TAKE_PHOTO_FALLBACK} + + +# --- #53 per-person memory kid-safety classifier --------------------------- + + +class _FakeHousehold: + """Minimal stand-in for HouseholdRegistry — only `.get()` is used by + the classifier. Keyed lowercase, matching the real registry.""" + + def __init__(self, people: dict[str, Person]) -> None: + self._people = {k.lower(): v for k, v in people.items()} + + def get(self, person_id: str) -> Person | None: + return self._people.get((person_id or "").lower()) + + +def test_person_needs_review_adult_by_age() -> None: + hh = _FakeHousehold({"brett": Person(id="brett", display_name="Brett", age=40)}) + assert person_needs_review(hh, "brett") is False + + +def test_person_needs_review_minor_by_age() -> None: + hh = _FakeHousehold({"kid": Person(id="kid", display_name="Kid", age=7)}) + assert person_needs_review(hh, "kid") is True + + +def test_person_needs_review_adult_by_relation() -> None: + hh = _FakeHousehold( + {"mum": Person(id="mum", display_name="Mum", relation="parent")} + ) + assert person_needs_review(hh, "mum") is False + + +def test_person_needs_review_child_relation() -> None: + hh = _FakeHousehold( + {"son": Person(id="son", display_name="Son", relation="son")} + ) + assert person_needs_review(hh, "son") is True + + +def test_person_needs_review_unknown_person() -> None: + assert person_needs_review(_FakeHousehold({}), "ghost") is True + + +def test_person_needs_review_sparse_entry() -> None: + # No age, no relation — cannot confirm adult, so route to review. + hh = _FakeHousehold({"x": Person(id="x", display_name="X")}) + assert person_needs_review(hh, "x") is True + + +def test_person_needs_review_none_household() -> None: + assert person_needs_review(None, "anyone") is True + + +def test_person_review_status_endpoint() -> None: + with TestClient(app) as client: + client.app.state.household = _FakeHousehold({ + "dad": Person(id="dad", display_name="Dad", relation="parent"), + "kiddo": Person(id="kiddo", display_name="Kiddo", age=6), + }) + r = client.get( + "/api/voice/person_review_status", params={"person_id": "Dad"} + ) + assert r.status_code == 200 + assert r.json() == {"person_id": "dad", "needs_review": False} + + r2 = client.get( + "/api/voice/person_review_status", params={"person_id": "kiddo"} + ) + assert r2.json() == {"person_id": "kiddo", "needs_review": True} + + r3 = client.get( + "/api/voice/person_review_status", params={"person_id": "stranger"} + ) + assert r3.json() == {"person_id": "stranger", "needs_review": True} diff --git a/dotty-pi-ext/package.json b/dotty-pi-ext/package.json index a640be9..09ec417 100644 --- a/dotty-pi-ext/package.json +++ b/dotty-pi-ext/package.json @@ -15,9 +15,11 @@ "@earendil-works/pi-coding-agent": "^0.74.0" }, "scripts": { - "test": "npm run test:memory && npm run test:remember && npm run test:turnlog && npm run test:think && npm run test:play", + "test": "npm run test:memory && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play", "test:memory": "node --experimental-strip-types tests/memory_lookup.test.ts", + "test:recall": "node --experimental-strip-types tests/recall_person.test.ts", "test:remember": "node --experimental-strip-types tests/remember.test.ts", + "test:rememberperson": "node --experimental-strip-types tests/remember_person.test.ts", "test:turnlog": "node --experimental-strip-types tests/turn_log.test.ts", "test:think": "node --experimental-strip-types tests/think_hard.test.ts", "test:play": "node --experimental-strip-types tests/play_song.test.ts" diff --git a/dotty-pi-ext/src/index.ts b/dotty-pi-ext/src/index.ts index d628c2d..f75fe1e 100644 --- a/dotty-pi-ext/src/index.ts +++ b/dotty-pi-ext/src/index.ts @@ -11,13 +11,17 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { logTurnEnd } from "./lib/turn_logger.ts"; import { memoryLookupTool } from "./tools/memory_lookup.ts"; import { playSongTool } from "./tools/play_song.ts"; +import { recallPersonTool } from "./tools/recall_person.ts"; import { rememberTool } from "./tools/remember.ts"; +import { rememberPersonTool } from "./tools/remember_person.ts"; import { takePhotoTool } from "./tools/take_photo.ts"; import { thinkHardTool } from "./tools/think_hard.ts"; export default function (pi: ExtensionAPI) { pi.registerTool(memoryLookupTool); + pi.registerTool(recallPersonTool); pi.registerTool(rememberTool); + pi.registerTool(rememberPersonTool); pi.registerTool(thinkHardTool); pi.registerTool(playSongTool); pi.registerTool(takePhotoTool); diff --git a/dotty-pi-ext/src/lib/brain_db.ts b/dotty-pi-ext/src/lib/brain_db.ts index ea4e0d8..43fd65f 100644 --- a/dotty-pi-ext/src/lib/brain_db.ts +++ b/dotty-pi-ext/src/lib/brain_db.ts @@ -96,6 +96,63 @@ export function searchMemories( } } +/** Mirrors bridge.py `_PERSON_MEMORY_MAX_FACTS` — per-person fact budget. */ +export const PERSON_MEMORY_MAX_FACTS = 8; + +export interface PersonMemoryRow { + key: string; + content: string; + category: string; + importance: number; + created_at: string; + updated_at: string; +} + +export interface PersonFetchOptions { + /** Override brain.db path (defaults to DOTTY_BRAIN_DB env / canonical). */ + dbPath?: string; + /** Cap rows returned (defaults to PERSON_MEMORY_MAX_FACTS). */ + limit?: number; +} + +/** + * Direct per-person memory fetch — mirrors + * bridge.py:_voice_memory_person_fetch_blocking (#53). A namespace-scoped + * SELECT against `namespace='person:'`, NOT an FTS search, ordered by + * importance then recency. + * + * Only the approved `person:` namespace is read — the kid-safety + * pending namespace (`person_pending:`) is deliberately never + * returned, so unreviewed facts about minors cannot reach a turn. Empty + * array on missing db, empty id, or any sqlite error. + */ +export function fetchPersonMemories( + personId: string, + opts: PersonFetchOptions = {}, +): PersonMemoryRow[] { + const limit = opts.limit ?? PERSON_MEMORY_MAX_FACTS; + const path = opts.dbPath ?? DEFAULT_PATH; + const pid = (personId ?? "").trim().toLowerCase(); + if (!pid) return []; + + try { + const db = openReadOnly(path); + const stmt = db.prepare(` + SELECT key, content, category, importance, created_at, updated_at + FROM memories + WHERE namespace = ? + ORDER BY importance DESC, updated_at DESC + LIMIT ? + `); + return stmt.all(`person:${pid}`, limit) as PersonMemoryRow[]; + } catch (err) { + process.stderr.write( + `[brain_db] person fetch failed for person_id=${JSON.stringify(pid.slice(0, 60))}: ${err}\n`, + ); + return []; + } +} + export interface StoreOptions { content: string; /** Defaults to "core" (long-retention fact, mirrors bridge.py /remember). */ diff --git a/dotty-pi-ext/src/lib/dotty_behaviour.ts b/dotty-pi-ext/src/lib/dotty_behaviour.ts index 6c47bec..80ba7c0 100644 --- a/dotty-pi-ext/src/lib/dotty_behaviour.ts +++ b/dotty-pi-ext/src/lib/dotty_behaviour.ts @@ -64,3 +64,32 @@ export async function fetchTakePhoto( return fallback; } } + +/** + * GET /api/voice/person_review_status — the #53 kid-safety classifier. + * Returns true when a declared fact about `personId` must be routed to + * the review queue (a minor, an unknown person, or an unclassifiable + * registry entry). + * + * Fail-safe: any failure (network, non-2xx, malformed JSON) returns + * `true`. When the gate is unreachable we route to review rather than + * risk auto-committing an unreviewed fact about a minor. + */ +export async function fetchPersonReviewStatus( + personId: string, + opts: BehaviourOptions = {}, +): Promise { + try { + const resp = await behaviourFetch( + `/api/voice/person_review_status?person_id=${encodeURIComponent(personId)}`, + { method: "GET" }, + opts, + ); + if (!resp.ok) return true; + const data = (await resp.json()) as { needs_review?: unknown }; + // Only an explicit `false` clears the gate — missing/garbage → review. + return data.needs_review !== false; + } catch { + return true; + } +} diff --git a/dotty-pi-ext/src/tools/recall_person.ts b/dotty-pi-ext/src/tools/recall_person.ts new file mode 100644 index 0000000..8b40397 --- /dev/null +++ b/dotty-pi-ext/src/tools/recall_person.ts @@ -0,0 +1,101 @@ +// recall_person voice tool (#53) — per-person memory read. +// +// The pi runtime exposes no turn-prep / system-prompt injection seam +// (index.ts only registers tools and listens to `agent_end`), so unlike +// bridge.py — which injects a [Person memory] block via _voice_preparer +// — the pi-runtime path surfaces per-person memory as a TOOL the agent +// calls. Same brain.db, different retrieval mechanism per runtime. +// +// Reads only the approved `person:` namespace via fetchPersonMemories; +// the kid-safety pending namespace is never read here. +// +// Contract: +// - Empty / whitespace name → "(no person specified)" +// - Name given, nothing stored → "(nothing remembered about )" +// - Otherwise → up to PERSON_MEMORY_MAX_FACTS facts, each truncated to +// 200 chars (197 + "..."), pipe-joined with " | " (matches the +// memory_lookup tool's result shape). + +import { Type } from "typebox"; +import { + fetchPersonMemories, + PERSON_MEMORY_MAX_FACTS, + type PersonMemoryRow, +} from "../lib/brain_db.ts"; + +const FACT_MAX_CHARS = 200; +const FACT_TRUNC_HEAD = 197; // 200 - len("...") + +/** + * Pure formatter — separated from the tool wrapper so the test rig can + * exercise it without going through pi's `execute` callback shape. + */ +export function formatPersonRecall( + name: string, + rows: PersonMemoryRow[], +): string { + const facts: string[] = []; + for (const r of rows) { + const trimmed = (r.content ?? "").trim(); + if (!trimmed) continue; + // Slice by Unicode codepoints, not UTF-16 units — matches Python's + // str[:N] semantics (see memory_lookup.ts for the full rationale). + const cp = Array.from(trimmed); + facts.push( + cp.length > FACT_MAX_CHARS + ? cp.slice(0, FACT_TRUNC_HEAD).join("") + "..." + : trimmed, + ); + } + if (facts.length === 0) return `(nothing remembered about ${name})`; + return facts.join(" | "); +} + +/** Top-level dispatch used by both the pi tool and the test rig. */ +export function runRecallPerson(name: string, dbPath?: string): string { + const n = (name ?? "").trim(); + if (!n) return "(no person specified)"; + // The household-registry id is the person's name lowercased — that is + // the `person:` namespace convention bridge.py writes against. + // fetchPersonMemories applies the lowercasing. + const rows = fetchPersonMemories(n, { + limit: PERSON_MEMORY_MAX_FACTS, + dbPath, + }); + return formatPersonRecall(n, rows); +} + +/** Pi tool descriptor — passed to `pi.registerTool` from index.ts. */ +export const recallPersonTool = { + name: "recall_person", + label: "Recall Person", + description: + "Recall durable facts Dotty has learned about a specific household " + + "member — their preferences, relationships, recent context. Use when " + + "the conversation turns to a named person and you want what Dotty " + + "already knows about them.", + promptSnippet: + "Look up what Dotty remembers about a named household member.", + promptGuidelines: [ + "Call recall_person when a named person comes up and you want their " + + "stored preferences or context. For general past-conversation " + + "recall that isn't about one specific person, use memory_lookup.", + ], + parameters: Type.Object({ + name: Type.String({ + description: + "The person's name — matched case-insensitively against the " + + "household registry id.", + }), + }), + async execute( + _toolCallId: string, + params: { name: string }, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: unknown, + ): Promise<{ content: Array<{ type: "text"; text: string }> }> { + const text = runRecallPerson(params.name); + return { content: [{ type: "text", text }] }; + }, +}; diff --git a/dotty-pi-ext/src/tools/remember_person.ts b/dotty-pi-ext/src/tools/remember_person.ts new file mode 100644 index 0000000..14c302e --- /dev/null +++ b/dotty-pi-ext/src/tools/remember_person.ts @@ -0,0 +1,123 @@ +// remember_person voice tool (#53) — per-person memory write with the +// kid-safety review gate. +// +// Unlike `remember` (a fact in the generic `voice` namespace, no gate), +// remember_person attributes a fact to a named household member and must +// respect the #53 kid-safety gate: a fact about a minor is held in a +// `person_pending:` review queue until a human approves it, and is +// never read into a turn meanwhile. +// +// The gate DECISION needs the household registry, so it is NOT duplicated +// here — the tool asks dotty-behaviour's /api/voice/person_review_status +// classifier (single-source, Python), then writes to the approved +// (`person:`) or pending (`person_pending:`) namespace. The +// brain.db write itself stays local via storeMemory(), consistent with +// the `remember` tool. +// +// Contract: +// - Empty / whitespace name → "(no person specified)" +// - Empty / whitespace fact → "(empty fact)" +// - Stored, adult → "(remembered about )" +// - Stored, needs review → "(saved — a grown-up will check that)" +// - Insert failure → "(remember failed)" + +import { Type } from "typebox"; +import { storeMemory } from "../lib/brain_db.ts"; +import { fetchPersonReviewStatus } from "../lib/dotty_behaviour.ts"; + +// bridge.py /api/voice/remember_person truncates `fact` to 300 chars. +const FACT_MAX_CHARS = 300; + +/** + * `person:` when approved, `person_pending:` when held for + * review. Pure — separated so the test rig can exercise it directly. + */ +export function personNamespace( + personId: string, + needsReview: boolean, +): string { + const pid = (personId ?? "").trim().toLowerCase(); + return `${needsReview ? "person_pending" : "person"}:${pid}`; +} + +export interface RememberPersonOptions { + /** dotty-behaviour base URL override (for the review classifier). */ + baseUrl?: string; + timeoutMs?: number; + /** brain.db path override. */ + dbPath?: string; + sessionId?: string | null; +} + +/** Top-level dispatch used by both the pi tool and the test rig. */ +export async function runRememberPerson( + name: string, + fact: string, + opts: RememberPersonOptions = {}, +): Promise { + const n = (name ?? "").trim(); + if (!n) return "(no person specified)"; + const trimmedFact = (fact ?? "").trim(); + if (!trimmedFact) return "(empty fact)"; + // Codepoint-aware truncation — matches Python str[:N] (see remember.ts). + const cp = Array.from(trimmedFact); + const capped = + cp.length > FACT_MAX_CHARS + ? cp.slice(0, FACT_MAX_CHARS).join("") + : trimmedFact; + + const needsReview = await fetchPersonReviewStatus(n, { + baseUrl: opts.baseUrl, + timeoutMs: opts.timeoutMs, + }); + const ok = storeMemory({ + content: capped, + category: "core", + namespace: personNamespace(n, needsReview), + importance: 0.7, + sessionId: opts.sessionId ?? null, + dbPath: opts.dbPath, + }); + if (!ok) return "(remember failed)"; + return needsReview + ? "(saved — a grown-up will check that)" + : `(remembered about ${n})`; +} + +/** Pi tool descriptor — passed to `pi.registerTool` from index.ts. */ +export const rememberPersonTool = { + name: "remember_person", + label: "Remember Person", + description: + "Store a durable fact about a specific named household member — a " + + "preference, relationship, or recent context. Use when the user " + + "tells you something worth keeping about a particular person. For " + + "general facts not about one named person, use remember instead.", + promptSnippet: + "Persist a fact about a named household member to Dotty's memory.", + promptGuidelines: [ + "Call remember_person when the user shares a stable fact about a " + + "specific named person. Keep it short and self-contained (≤300 chars).", + "Facts about children are automatically held for a grown-up to " + + "review before Dotty uses them — just store the fact; the gate " + + "is handled for you.", + ], + parameters: Type.Object({ + name: Type.String({ + description: "The person the fact is about (their name).", + }), + fact: Type.String({ + description: "The fact to remember about them (≤300 chars).", + }), + }), + async execute( + _toolCallId: string, + params: { name: string; fact: string }, + _signal: AbortSignal | undefined, + _onUpdate: unknown, + _ctx: unknown, + ): Promise<{ content: Array<{ type: "text"; text: string }> }> { + const text = await runRememberPerson(params.name, params.fact); + return { content: [{ type: "text", text }] }; + }, +}; diff --git a/dotty-pi-ext/tests/recall_person.test.ts b/dotty-pi-ext/tests/recall_person.test.ts new file mode 100644 index 0000000..59ed94f --- /dev/null +++ b/dotty-pi-ext/tests/recall_person.test.ts @@ -0,0 +1,167 @@ +// Equivalence test (#53): seed known person: rows, then assert the +// TS fetchPersonMemories() returns rows byte-identical to the bridge's +// _voice_memory_person_fetch_blocking SELECT (via recall_person_oracle.py). +// +// Usage: +// DOTTY_BRAIN_DB_SNAPSHOT=/path/to/brain.db \ +// node --experimental-strip-types tests/recall_person.test.ts +// +// The pure-formatter edge cases run unconditionally; the row-equality +// cases need a brain.db snapshot to seed against and SKIP without one. + +import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + fetchPersonMemories, + storeMemory, + _resetForTests, + type PersonMemoryRow, +} from "../src/lib/brain_db.ts"; +import { + formatPersonRecall, + runRecallPerson, +} from "../src/tools/recall_person.ts"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ORACLE = join(__dirname, "recall_person_oracle.py"); + +let failures = 0; + +function assertEq(label: string, actual: unknown, expected: unknown): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) { + process.stdout.write(` PASS ${label}\n`); + return; + } + process.stderr.write( + ` FAIL ${label}\n expected: ${e}\n actual: ${a}\n`, + ); + failures++; +} + +function callOracle(db: string, personId: string): { rows: PersonMemoryRow[] } { + const out = execFileSync("python3", [ORACLE, db, personId], { + encoding: "utf8", + }); + return JSON.parse(out.trim()) as { rows: PersonMemoryRow[] }; +} + +function main(): void { + // ----- Pure formatter / dispatch edge cases (no db needed) ----- + process.stdout.write("Edge cases (pure return value):\n"); + assertEq("empty name", runRecallPerson(""), "(no person specified)"); + assertEq("whitespace name", runRecallPerson(" "), "(no person specified)"); + assertEq( + "no facts → miss", + formatPersonRecall("ghost", []), + "(nothing remembered about ghost)", + ); + assertEq( + "blank-content rows → miss", + formatPersonRecall("ghost", [ + { key: "k", content: " ", category: "core", importance: 0.5, created_at: "", updated_at: "" }, + ]), + "(nothing remembered about ghost)", + ); + assertEq( + "facts pipe-joined", + formatPersonRecall("kid", [ + { key: "k1", content: "loves dinosaurs", category: "core", importance: 0.7, created_at: "", updated_at: "" }, + { key: "k2", content: "afraid of the dark", category: "core", importance: 0.6, created_at: "", updated_at: "" }, + ]), + "loves dinosaurs | afraid of the dark", + ); + + const snapshot = process.env.DOTTY_BRAIN_DB_SNAPSHOT; + if (!snapshot || !existsSync(snapshot)) { + process.stderr.write( + "SKIP: set DOTTY_BRAIN_DB_SNAPSHOT to a readable brain.db copy " + + "for the row-equality cases.\n", + ); + process.stdout.write( + `\n${failures === 0 ? "OK" : "FAIL"} — ${failures} failure(s)\n`, + ); + process.exit(failures === 0 ? 0 : 1); + } + + // ----- Row-equality: TS SELECT vs bridge SELECT on identical data ----- + process.stdout.write(`\nSnapshot: ${snapshot}\n`); + const tmp = mkdtempSync(join(tmpdir(), "dotty-recall-person-")); + const db = join(tmp, "seeded.db"); + copyFileSync(snapshot, db); + try { + // Seed three facts under person:testkid with distinct importance so + // the importance-DESC ordering is observable, plus one unrelated + // namespace=voice row that must NOT leak into the result. + _resetForTests(); + const seed: Array<[string, number, string]> = [ + ["testkid likes drawing", 0.5, "2026-05-01T00:00:00.000Z"], + ["testkid has a dog named Rex", 0.9, "2026-05-02T00:00:00.000Z"], + ["testkid is learning to read", 0.7, "2026-05-03T00:00:00.000Z"], + ]; + seed.forEach(([content, importance, now], i) => { + storeMemory({ + content, + category: "core", + namespace: "person:testkid", + importance, + sessionId: null, + dbPath: db, + _now: now, + _id: `aaaaaaaa-0000-4000-8000-00000000000${i}`, + }); + }); + storeMemory({ + content: "unrelated voice memory", + category: "core", + namespace: "voice", + importance: 0.99, + sessionId: null, + dbPath: db, + _now: "2026-05-04T00:00:00.000Z", + _id: "bbbbbbbb-0000-4000-8000-000000000000", + }); + _resetForTests(); + + const tsRows = fetchPersonMemories("testkid", { dbPath: db }); + const oracle = callOracle(db, "testkid"); + _resetForTests(); + + assertEq("row equality (TS vs bridge SELECT)", tsRows, oracle.rows); + assertEq( + "importance-DESC ordering", + tsRows.map((r) => r.content), + [ + "testkid has a dog named Rex", + "testkid is learning to read", + "testkid likes drawing", + ], + ); + assertEq("namespace isolation (voice row excluded)", tsRows.length, 3); + assertEq( + "case-insensitive id match", + fetchPersonMemories("TestKid", { dbPath: db }).length, + 3, + ); + assertEq( + "unknown person → empty", + fetchPersonMemories("nobody-here", { dbPath: db }), + [], + ); + _resetForTests(); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + process.stdout.write( + `\n${failures === 0 ? "OK" : "FAIL"} — ${failures} failure(s)\n`, + ); + process.exit(failures === 0 ? 0 : 1); +} + +main(); diff --git a/dotty-pi-ext/tests/recall_person_oracle.py b/dotty-pi-ext/tests/recall_person_oracle.py new file mode 100644 index 0000000..de516b8 --- /dev/null +++ b/dotty-pi-ext/tests/recall_person_oracle.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""recall_person oracle — runs the *exact* Python SELECT path the bridge's +_voice_memory_person_fetch_blocking does, then dumps the rows as JSON. + +Usage: + python3 recall_person_oracle.py [--limit=N] + +Outputs a single JSON object {"rows": [...]} on stdout. The TS test +runner consumes this, runs fetchPersonMemories() against the same db, and +asserts byte-equal. If bridge.py and the TS port disagree on the SELECT +(namespace key, column set, ORDER BY), the test fails loudly. + +Mirrors bridge.py:_voice_memory_person_fetch_blocking (#53) — only the +approved `person:` namespace is read; `person_pending:` is never +returned. +""" + +from __future__ import annotations + +import json +import sqlite3 +import sys +from pathlib import Path + +PERSON_MEMORY_MAX_FACTS = 8 # mirrors bridge.py _PERSON_MEMORY_MAX_FACTS + + +# Copied from bridge.py:_voice_memory_person_fetch_blocking. Do NOT +# refactor — this is the spec. +def _voice_memory_person_fetch_blocking( + db: Path, person_id: str, limit: int, +) -> list[dict]: + pid = (person_id or "").strip().lower() + if not pid or not db.exists(): + return [] + try: + conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True, timeout=2) + try: + conn.row_factory = sqlite3.Row + cur = conn.execute( + """ + SELECT key, content, category, importance, + created_at, updated_at + FROM memories + WHERE namespace = ? + ORDER BY importance DESC, updated_at DESC + LIMIT ? + """, + (f"person:{pid}", limit), + ) + return [dict(r) for r in cur.fetchall()] + finally: + conn.close() + except Exception as e: + print(f"oracle error: {e}", file=sys.stderr) + return [] + + +def main() -> int: + args = sys.argv[1:] + if len(args) < 2: + print( + "usage: recall_person_oracle.py [--limit=N]", + file=sys.stderr, + ) + return 2 + db = Path(args[0]) + person_id = args[1] + limit = PERSON_MEMORY_MAX_FACTS + for flag in args[2:]: + if flag.startswith("--limit="): + limit = int(flag.split("=", 1)[1]) + else: + print(f"bad flag: {flag}", file=sys.stderr) + return 2 + + rows = _voice_memory_person_fetch_blocking(db, person_id, limit) + print(json.dumps({"rows": rows})) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dotty-pi-ext/tests/remember_person.test.ts b/dotty-pi-ext/tests/remember_person.test.ts new file mode 100644 index 0000000..2fc309d --- /dev/null +++ b/dotty-pi-ext/tests/remember_person.test.ts @@ -0,0 +1,68 @@ +// Unit test (#53) for the remember_person tool's pure logic — the +// namespace router and the pre-dispatch edge cases. +// +// The full write path (review-status HTTP → storeMemory) is exercised +// end-to-end on the deployed stack; the kid-safety gate decision itself +// is covered by dotty-behaviour/tests/test_routes_voice.py. Here we only +// pin the bits that don't need a daemon or a brain.db. +// +// Usage: +// node --experimental-strip-types tests/remember_person.test.ts + +import { + personNamespace, + runRememberPerson, +} from "../src/tools/remember_person.ts"; + +let failures = 0; + +function assertEq(label: string, actual: unknown, expected: unknown): void { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a === e) { + process.stdout.write(` PASS ${label}\n`); + return; + } + process.stderr.write( + ` FAIL ${label}\n expected: ${e}\n actual: ${a}\n`, + ); + failures++; +} + +async function main(): Promise { + // personNamespace — pure namespace router. + process.stdout.write("personNamespace:\n"); + assertEq("approved", personNamespace("Brett", false), "person:brett"); + assertEq("pending", personNamespace("Kid", true), "person_pending:kid"); + assertEq( + "trims + lowercases", + personNamespace(" Hudson ", false), + "person:hudson", + ); + + // runRememberPerson edge cases — these return before any HTTP / db + // call, so they need neither the daemon nor brain.db. + process.stdout.write("\nrunRememberPerson edge cases:\n"); + assertEq( + "empty name", + await runRememberPerson("", "loves trains"), + "(no person specified)", + ); + assertEq( + "whitespace name", + await runRememberPerson(" ", "loves trains"), + "(no person specified)", + ); + assertEq( + "empty fact", + await runRememberPerson("brett", " "), + "(empty fact)", + ); + + process.stdout.write( + `\n${failures === 0 ? "OK" : "FAIL"} — ${failures} failure(s)\n`, + ); + process.exit(failures === 0 ? 0 : 1); +} + +main();