From 1e4d01cede93acd44f49b9e1dc50dc04bc11f96e Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 19:04:43 +1000 Subject: [PATCH 1/6] =?UTF-8?q?#53:=20per-person=20memory=20=E2=80=94=20br?= =?UTF-8?q?idge=20read=20+=20write=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a durable per-person memory layer keyed on the household registry's person_id, stored as `memories` rows in a `person:` 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: memory only when the speaker is affirmatively an adult in the registry; known minors, unknown people, and unclassifiable entries route to a person_pending: 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) --- bridge.py | 176 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 174 insertions(+), 2 deletions(-) diff --git a/bridge.py b/bridge.py index ba4ac8b..1948d8c 100644 --- a/bridge.py +++ b/bridge.py @@ -1086,10 +1086,11 @@ def _resolve_speaker_for_request(payload): def _voice_preparer(channel: str | None, resolution=None, room_description: str | None = None, - device_id: str | None = None): + device_id: str | None = None, + person_memory_block: str | None = None): """Build a `prepare` callback for `acp.prompt`. - Three layers of speaker context, additive (any combination may be + Four layers of speaker context, additive (any combination may be present per turn): * **Resolver path** — a `SpeakerResolution` with a registry @@ -1108,6 +1109,10 @@ def _voice_preparer(channel: str | None, resolution=None, * **Legacy face-rec path** — when neither of the above produces anything, consume any pending face-recognized identity marker for this channel and emit the historic `[Speaker: name]` line. + * **Person memory** — durable per-person facts from the + `person:` 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 @@ -3825,6 +3832,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 @@ -3899,6 +3910,80 @@ 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" + + async def _voice_tool_memory_lookup(args: dict, session_id: str) -> str: query = (args.get("query") or "").strip() if not query: @@ -4092,6 +4177,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 @@ -4145,6 +4236,77 @@ async def voice_remember(payload: VoiceRememberIn): ) +# 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 #53 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 + + +@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.""" + person_id = (payload.person_id or "").strip().lower() + fact = (payload.fact or "").strip()[:300] + if not person_id or not fact: + return {"ok": False, "pending_review": False, "error": "empty"} + needs_review = _person_memory_needs_review(person_id) + namespace = ( + f"person_pending:{person_id}" if needs_review + else f"person:{person_id}" + ) + stored = await asyncio.to_thread( + _voice_memory_store_blocking, + content=fact, category="core", namespace=namespace, + importance=0.7, session_id=payload.session_id, + ) + if stored: + log.info( + "person memory %s person=%s review=%s", + "queued" if needs_review else "stored", person_id, needs_review, + ) + else: + log.warning("person memory store failed person=%s", person_id) + return {"ok": stored, "pending_review": needs_review} + + # --------------------------------------------------------------------------- # Scene synthesis — periodic "what's happening right now" memory writes # --------------------------------------------------------------------------- @@ -4811,6 +4973,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", @@ -4818,6 +4981,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: @@ -4830,6 +4996,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, @@ -5509,6 +5676,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", @@ -5516,6 +5684,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 @@ -5585,6 +5756,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, From cfc2b8c13d028722f984765f50d3206a8a678b5b Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 19:15:30 +1000 Subject: [PATCH 2/6] =?UTF-8?q?#53:=20per-person=20memory=20=E2=80=94=20do?= =?UTF-8?q?tty-pi-ext=20read=20tool=20(recall=5Fperson)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:, mirror of bridge.py:_voice_memory_person_fetch_blocking. Reads only the approved namespace; person_pending: 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: 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) --- dotty-pi-ext/package.json | 3 +- dotty-pi-ext/src/index.ts | 2 + dotty-pi-ext/src/lib/brain_db.ts | 57 +++++++ dotty-pi-ext/src/tools/recall_person.ts | 101 +++++++++++++ dotty-pi-ext/tests/recall_person.test.ts | 167 +++++++++++++++++++++ dotty-pi-ext/tests/recall_person_oracle.py | 83 ++++++++++ 6 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 dotty-pi-ext/src/tools/recall_person.ts create mode 100644 dotty-pi-ext/tests/recall_person.test.ts create mode 100644 dotty-pi-ext/tests/recall_person_oracle.py diff --git a/dotty-pi-ext/package.json b/dotty-pi-ext/package.json index a640be9..71956cd 100644 --- a/dotty-pi-ext/package.json +++ b/dotty-pi-ext/package.json @@ -15,8 +15,9 @@ "@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: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:turnlog": "node --experimental-strip-types tests/turn_log.test.ts", "test:think": "node --experimental-strip-types tests/think_hard.test.ts", diff --git a/dotty-pi-ext/src/index.ts b/dotty-pi-ext/src/index.ts index d628c2d..67e94d8 100644 --- a/dotty-pi-ext/src/index.ts +++ b/dotty-pi-ext/src/index.ts @@ -11,12 +11,14 @@ 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 { 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(thinkHardTool); pi.registerTool(playSongTool); 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/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/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()) From db284b3226aba194b082cd3c446eb3204d5f1171 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 19:26:19 +1000 Subject: [PATCH 3/6] =?UTF-8?q?#53:=20per-person=20memory=20=E2=80=94=20re?= =?UTF-8?q?member=5Fperson=20write=20tool=20+=20kid-safety=20classifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: or person_pending: 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) --- dotty-behaviour/routes/voice.py | 66 +++++++++++ dotty-behaviour/tests/test_routes_voice.py | 78 ++++++++++++- dotty-pi-ext/package.json | 3 +- dotty-pi-ext/src/index.ts | 2 + dotty-pi-ext/src/lib/dotty_behaviour.ts | 29 +++++ dotty-pi-ext/src/tools/remember_person.ts | 123 +++++++++++++++++++++ dotty-pi-ext/tests/remember_person.test.ts | 68 ++++++++++++ 7 files changed, 367 insertions(+), 2 deletions(-) create mode 100644 dotty-pi-ext/src/tools/remember_person.ts create mode 100644 dotty-pi-ext/tests/remember_person.test.ts diff --git a/dotty-behaviour/routes/voice.py b/dotty-behaviour/routes/voice.py index a213d3b..9c7b289 100644 --- a/dotty-behaviour/routes/voice.py +++ b/dotty-behaviour/routes/voice.py @@ -56,3 +56,69 @@ 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 + + +# 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 71956cd..09ec417 100644 --- a/dotty-pi-ext/package.json +++ b/dotty-pi-ext/package.json @@ -15,10 +15,11 @@ "@earendil-works/pi-coding-agent": "^0.74.0" }, "scripts": { - "test": "npm run test:memory && npm run test:recall && 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 67e94d8..f75fe1e 100644 --- a/dotty-pi-ext/src/index.ts +++ b/dotty-pi-ext/src/index.ts @@ -13,6 +13,7 @@ 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"; @@ -20,6 +21,7 @@ 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/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/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/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(); From 1eeea0f984528cc7c3c396fae5715a0e8bb677e5 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 19:47:23 +1000 Subject: [PATCH 4/6] =?UTF-8?q?#53:=20per-person=20memory=20=E2=80=94=20/u?= =?UTF-8?q?i/memory=20dashboard=20review=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: -> person:), _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) --- bridge.py | 105 ++++++++++++++++++++++++++++ bridge/dashboard.py | 85 +++++++++++++++++++++- bridge/templates/dashboard.html | 11 +++ bridge/templates/memory_list.html | 80 +++++++++++++++++++++ bridge/templates/memory_result.html | 12 ++++ 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 bridge/templates/memory_list.html create mode 100644 bridge/templates/memory_result.html diff --git a/bridge.py b/bridge.py index 1948d8c..7d03db9 100644 --- a/bridge.py +++ b/bridge.py @@ -3984,6 +3984,96 @@ def _build_person_memory_block(person_id: str | None) -> str: 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: @@ -5194,6 +5284,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, @@ -5211,6 +5313,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, ) diff --git a/bridge/dashboard.py b/bridge/dashboard.py index 3400578..d11c3e4 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, } @@ -65,7 +68,10 @@ def configure(*, send_message: Any = None, vision_cache: dict | None = None, perception_state_getter: Any = None, perception_recent_getter: Any = None, identity_display_name: Any = None, - last_user_line_getter: Any = None) -> None: + last_user_line_getter: Any = None, + memory_records_getter: Any = None, + memory_approve: Any = None, + memory_redact: Any = None) -> None: """Register bridge state with the dashboard. Idempotent.""" if send_message is not None: _state["send_message"] = send_message @@ -103,6 +109,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 TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) @@ -807,6 +819,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 fae4ed7..52827aa 100644 --- a/bridge/templates/dashboard.html +++ b/bridge/templates/dashboard.html @@ -234,6 +234,17 @@ +
+
+
+
+
+
+
+
diff --git a/bridge/templates/memory_list.html b/bridge/templates/memory_list.html new file mode 100644 index 0000000..98af62c --- /dev/null +++ b/bridge/templates/memory_list.html @@ -0,0 +1,80 @@ +{# #53 per-person memory review surface. Rendered into #memory-card-body + (innerHTML swap) — carries no own `.card` chrome; the parent section in + dashboard.html owns the card shell. Pending = the kid-safety review + queue (person_pending:); 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 %} From 90af8cdd64c8f0be41d63368276ce6e9f02ddb99 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 20:38:25 +1000 Subject: [PATCH 5/6] =?UTF-8?q?#53:=20tier1slim=20=E2=80=94=20wire=20remem?= =?UTF-8?q?ber=5Fperson=20escalate=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 4 +- bridge.py | 81 +++++++++++++++++------ custom-providers/tier1_slim/tier1_slim.py | 22 ++++++ 3 files changed, 85 insertions(+), 22 deletions(-) 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 (``, ``, ` 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, } @@ -4362,6 +4386,39 @@ def _person_memory_needs_review(person_id: str) -> bool: 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). @@ -4373,27 +4430,11 @@ async def voice_remember_person(payload: VoiceRememberPersonIn): 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.""" - person_id = (payload.person_id or "").strip().lower() - fact = (payload.fact or "").strip()[:300] - if not person_id or not fact: - return {"ok": False, "pending_review": False, "error": "empty"} - needs_review = _person_memory_needs_review(person_id) - namespace = ( - f"person_pending:{person_id}" if needs_review - else f"person:{person_id}" - ) - stored = await asyncio.to_thread( - _voice_memory_store_blocking, - content=fact, category="core", namespace=namespace, - importance=0.7, session_id=payload.session_id, + valid, stored, needs_review = await _person_memory_store( + payload.person_id, payload.fact, payload.session_id, ) - if stored: - log.info( - "person memory %s person=%s review=%s", - "queued" if needs_review else "stored", person_id, needs_review, - ) - else: - log.warning("person memory store failed person=%s", person_id) + if not valid: + return {"ok": False, "pending_review": False, "error": "empty"} return {"ok": stored, "pending_review": needs_review} 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, } From 0d2a496cb9c180019a38da60d4cdbcffee564a09 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 22 May 2026 20:45:58 +1000 Subject: [PATCH 6/6] #53: mark the kid-safety gate's canonical source vs. mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bridge.py | 13 ++++++++++++- dotty-behaviour/routes/voice.py | 11 +++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/bridge.py b/bridge.py index c4941f0..90de7fa 100644 --- a/bridge.py +++ b/bridge.py @@ -4424,10 +4424,21 @@ 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 #53 kid-safety gate below. +# 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", diff --git a/dotty-behaviour/routes/voice.py b/dotty-behaviour/routes/voice.py index 9c7b289..52c6bdc 100644 --- a/dotty-behaviour/routes/voice.py +++ b/dotty-behaviour/routes/voice.py @@ -75,6 +75,17 @@ def get_household(request: Request): 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)