#53: per-person memory — durable per-person record layer#86
Merged
Conversation
Add a durable per-person memory layer keyed on the household registry's person_id, stored as `memories` rows in a `person:<id>` namespace of the existing brain.db (FTS-free direct fetch — no schema change). Read path: _voice_memory_person_fetch_blocking + _build_person_memory_block render a [Person memory] block, injected by _voice_preparer after [Speaking with] and before [Current perception] on both /api/message and /api/message/stream. Fetched off-loop via asyncio.to_thread. Write path: POST /api/voice/remember_person with a conservative kid-safety gate — facts auto-commit to readable person:<id> memory only when the speaker is affirmatively an adult in the registry; known minors, unknown people, and unclassifiable entries route to a person_pending:<id> review queue that the read path never reads. The write trigger (a tier1_slim tool calling the endpoint) and the dotty-pi-ext parallel land in follow-ups. Refs #53. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pi runtime exposes no turn-prep / system-prompt injection seam (the
extension only registers tools and listens to agent_end), so per-person
memory is surfaced on the pi-runtime path as a tool — recall_person —
rather than the [Person memory] prompt-block injection bridge.py uses.
- brain_db.ts: fetchPersonMemories() — namespace-scoped SELECT against
person:<id>, mirror of bridge.py:_voice_memory_person_fetch_blocking.
Reads only the approved namespace; person_pending:<id> is never read.
- tools/recall_person.ts: recall_person(name) tool — facts pipe-joined,
matching the memory_lookup result shape.
- tests/recall_person{_oracle.py,.test.ts}: equivalence test — seeded
person:<id> rows, TS SELECT vs the bridge SELECT asserted byte-equal,
plus ordering / namespace-isolation / case-insensitivity cases.
The remember_person write tool lands next. Refs #53.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sifier The pi-runtime write path for declared per-person facts. The kid-safety gate decision needs the household registry, so it stays single-source in Python rather than being duplicated in TypeScript: - dotty-behaviour: GET /api/voice/person_review_status — classifies a person_id against the household registry (adult -> auto-commit; minor, unknown, or unclassifiable -> review). Ports bridge.py's gate logic. - dotty-pi-ext: remember_person tool — asks the classifier, then writes the fact direct to brain.db under person:<id> or person_pending:<id> via storeMemory (consistent with the `remember` tool). The HTTP client fails safe: an unreachable classifier routes to review. Tests: 8 classifier cases in dotty-behaviour (adult/minor by age & relation, unknown, sparse, none-household, endpoint); namespace-router + edge cases in dotty-pi-ext. Refs #53. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The human end of the kid-safety review queue, completing the #53 implementation. - bridge.py: _voice_memory_person_records_blocking (list approved + pending rows), _voice_memory_approve_blocking (person_pending:<id> -> person:<id>), _voice_memory_delete_blocking (redact). Exposed to the dashboard via configure(). - dashboard.py: GET /ui/memory (rows grouped by person, the pending review queue surfaced first), POST /ui/actions/memory/{approve,redact}. - templates: memory_list.html (card body — pending + stored sections, per-row Approve/Redact), memory_result.html. New card in dashboard.html between the perception card and the activity feed. Refs #53. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Implements #53 by adding a durable per-person memory layer (stored in brain.db under person:<id> / person_pending:<id>), including write/read paths, a kid-safety review gate, and a bridge dashboard UI for approving/redacting pending items.
Changes:
- Add per-person memory read/write tooling on the pi-extension side (
remember_person,recall_person) and direct DB access helpers. - Add a single-source kid-safety “needs review” classifier endpoint in
dotty-behaviour. - Add bridge prompt injection of
[Person memory]plus a new/ui/memorydashboard card for review/management.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| dotty-pi-ext/tests/remember_person.test.ts | Adds unit tests for remember_person pure logic edge cases. |
| dotty-pi-ext/tests/recall_person.test.ts | Adds equivalence + formatter tests for TS person recall vs bridge SELECT oracle. |
| dotty-pi-ext/tests/recall_person_oracle.py | Implements a Python oracle mirroring bridge per-person SELECT for test equivalence. |
| dotty-pi-ext/src/tools/remember_person.ts | Adds the pi tool for per-person memory writes with review gating. |
| dotty-pi-ext/src/tools/recall_person.ts | Adds the pi tool for per-person memory reads (approved namespace only). |
| dotty-pi-ext/src/lib/dotty_behaviour.ts | Adds client helper for the /api/voice/person_review_status classifier. |
| dotty-pi-ext/src/lib/brain_db.ts | Adds fetchPersonMemories + constants/types for per-person DB reads. |
| dotty-pi-ext/src/index.ts | Registers the new per-person tools with the pi extension runtime. |
| dotty-pi-ext/package.json | Wires new recall/remember_person tests into the test script. |
| dotty-behaviour/tests/test_routes_voice.py | Adds unit tests for the kid-safety classifier logic and endpoint. |
| dotty-behaviour/routes/voice.py | Adds /api/voice/person_review_status endpoint and classifier implementation. |
| bridge/templates/memory_result.html | Adds HTMX action result partial for approve/redact actions. |
| bridge/templates/memory_list.html | Adds the per-person memory dashboard list UI (pending + stored). |
| bridge/templates/dashboard.html | Adds a new dashboard card that polls /ui/memory. |
| bridge/dashboard.py | Adds /ui/memory partial + approve/redact action routes and wiring hooks. |
| bridge.py | Adds per-person memory fetch/injection into prompts, write endpoint, and UI backing operations. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+53
to
+80
| export async function runRememberPerson( | ||
| name: string, | ||
| fact: string, | ||
| opts: RememberPersonOptions = {}, | ||
| ): Promise<string> { | ||
| 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, | ||
| }); |
Comment on lines
+55
to
+65
| 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:<id>` namespace convention bridge.py writes against. | ||
| // fetchPersonMemories applies the lowercasing. | ||
| const rows = fetchPersonMemories(n, { | ||
| limit: PERSON_MEMORY_MAX_FACTS, | ||
| dbPath, | ||
| }); | ||
| return formatPersonRecall(n, rows); |
Comment on lines
+109
to
+124
| @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:<id>` review queue (minor / unknown / | ||
| unclassifiable) rather than committed straight to readable | ||
| `person:<id>` memory.""" | ||
| pid = (person_id or "").strip().lower() | ||
| return { | ||
| "person_id": pid, | ||
| "needs_review": person_needs_review(household, pid), | ||
| } |
Comment on lines
+130
to
+150
| 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} |
The bridge-side per-person write path landed unreachable: nothing in the voice stack called POST /api/voice/remember_person. Only the pi-runtime path (dotty-pi-ext) had a fully wired remember_person write. Wire it into the tier1slim path: - tier1_slim.py — add remember_person to TOOLS (name + fact) so the 4B can emit the call; description steers it away from general facts (those still use the [REMEMBER: ...] marker). Silent filler. - bridge.py — add the _voice_tool_remember_person escalate handler and register it in _VOICE_TOOLS, so POST /api/voice/escalate dispatches it. Extract the gate+store core into a shared _person_memory_store() so the escalate handler and the /api/voice/remember_person endpoint apply one identical kid-safety gate decision. - The handler returns the same confirmation strings as the pi-runtime remember_person tool, so the spoken reply is consistent across both voice runtimes. The #53 kid-safety gate is unchanged — a tier1slim remember_person call for a minor / unknown person still routes to the person_pending: queue. Refs #53. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mory # Conflicts: # bridge/dashboard.py # bridge/templates/dashboard.html
Review follow-up. The per-person-memory kid-safety gate (`_ADULT_RELATIONS` + the needs-review function) is implemented twice — bridge.py and dotty-behaviour/routes/voice.py — because the two run as separate Docker images on separate hosts and cannot share an import. Rather than a network round-trip on the voice write path (a new failure mode) or a shared module that neither deployment tree includes, mark the dotty-behaviour copy CANONICAL and the bridge.py copy a TRANSITIONAL MIRROR. The bridge.py copy is retired entirely when the #36 rehoming removes bridge.py + bridge/*. Turns a silent-divergence trap into a documented one with a clear source of truth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 23, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#53 — per-person memory
A durable, queryable per-person memory layer for Dotty — facts about named
household members (preferences, relationships, recent context), distinct
from the static hand-authored household registry.
What it does
(
remember_persontool on the pi-runtime path;/api/voice/remember_personon the bridge path).
[Person memory]prompt block(bridge path, injected by
_voice_preparer) or via therecall_persontool (pi-runtime path — that runtime exposes no prompt-injection seam, so
read is a tool).
person is routed to a
person_pending:<id>review queue that the readpaths never touch. Only an affirmed adult (registry
age >= 18, or anadult
relation) auto-commits. The gate decision is single-source inPython (bridge
_person_memory_needs_review; dotty-behaviourGET /api/voice/person_review_status) — never duplicated in TypeScript./ui/memorydashboard card lists the pending queuewith Approve / Redact, and stored records with Redact.
Storage is the existing FTS5
brain.db, namespace-partitioned(
person:<id>/person_pending:<id>) — no schema change.Commits
1e4d01cbridge.py —[Person memory]read injection +/api/voice/remember_person+ gatecfc2b8cdotty-pi-ext —recall_personread tool +fetchPersonMemories+ oracle testdb284b3dotty-behaviour classifier endpoint + dotty-pi-extremember_personwrite tool1eeea0fbridge/ui/memorydashboard review surfaceTests
New: 8 classifier cases (dotty-behaviour
test_routes_voice.py), arecall_personoracle equivalence test,remember_personunit tests.Verified locally with
py_compile/node --check/ Jinja tag balance;the full
pytestand node test runs need the deploy hosts (fastapi,pytest,better-sqlite3not installed in the dev env).Not done — leave #53 open until
brain.db).Refs #53.
🤖 Generated with Claude Code