Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dotty-pi-ext/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
"@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:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play && npm run test:adminauth",
"test": "npm run test:memory && npm run test:memgate && npm run test:recall && npm run test:remember && npm run test:rememberperson && npm run test:turnlog && npm run test:think && npm run test:play && npm run test:adminauth",
"test:memory": "node --experimental-strip-types tests/memory_lookup.test.ts",
"test:memgate": "node --experimental-strip-types tests/memory_gate.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",
Expand Down
6 changes: 6 additions & 0 deletions dotty-pi-ext/src/lib/brain_db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,17 @@ export function searchMemories(

try {
const db = openReadOnly(path);
// Kid-safety gate (#53): never surface unreviewed `person_pending:<id>`
// facts about a minor through the free-text search — fetchPersonMemories
// is namespace-scoped to approved `person:<id>` for the same reason, but a
// bare FTS MATCH would otherwise leak a pending fact whenever a query
// phrase-matched it.
const stmt = db.prepare(`
SELECT m.key, m.content, m.category, m.namespace, m.created_at
FROM memories_fts
JOIN memories m ON m.rowid = memories_fts.rowid
WHERE memories_fts MATCH ?
AND m.namespace NOT LIKE 'person_pending:%'
ORDER BY rank
LIMIT ?
`);
Expand Down
63 changes: 63 additions & 0 deletions dotty-pi-ext/tests/memory_gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Regression: memory_lookup's FTS search must NOT surface unreviewed
// `person_pending:<id>` facts about a minor (#53 kid-safety gate). Builds a
// throwaway brain.db (real schema) with one approved and one pending row that
// both phrase-match the query, and asserts only the approved row comes back.
//
// Hermetic — no DOTTY_BRAIN_DB_SNAPSHOT needed (unlike memory_lookup.test.ts).

import Database from "better-sqlite3";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { searchMemories, _resetForTests } from "../src/lib/brain_db.ts";

const dir = mkdtempSync(join(tmpdir(), "dotty-memgate-"));
const dbPath = join(dir, "brain.db");

const db = new Database(dbPath);
db.exec(`
CREATE TABLE memories (
id INTEGER PRIMARY KEY, key TEXT, content TEXT, category TEXT,
embedding BLOB, created_at TEXT, updated_at TEXT, session_id TEXT,
namespace TEXT, importance INTEGER, superseded_by TEXT
);
CREATE VIRTUAL TABLE memories_fts
USING fts5(key, content, content='memories', content_rowid='id');
`);
const ins = db.prepare(
"INSERT INTO memories (key, content, category, namespace, created_at) VALUES (?,?,?,?,?)",
);
ins.run("k1", "alice likes peanuts", "fact", "person:alice", "2026-01-01");
ins.run("k2", "kiddo is allergic to peanuts", "fact", "person_pending:bob", "2026-01-01");
db.exec("INSERT INTO memories_fts(rowid, key, content) SELECT id, key, content FROM memories;");
db.close();

let failures = 0;
function check(label: string, cond: boolean): void {
if (cond) {
process.stdout.write(`ok - ${label}\n`);
} else {
process.stderr.write(`FAIL - ${label}\n`);
failures++;
}
}

try {
_resetForTests();
const rows = searchMemories("peanuts", { dbPath, limit: 5 });
const namespaces = rows.map((r) => r.namespace);

check("approved person: row is returned", namespaces.includes("person:alice"));
check(
"pending minor fact is excluded",
!namespaces.some((n) => n.startsWith("person_pending:")),
);
check("only the approved row remains", rows.length === 1);
} finally {
_resetForTests();
rmSync(dir, { recursive: true, force: true });
}

if (failures > 0) process.exit(1);
process.stdout.write("memory_gate.test.ts passed\n");
10 changes: 8 additions & 2 deletions dotty-pi-ext/tests/oracle.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@
from pathlib import Path


# Copied verbatim from bridge.py:_voice_memory_search_blocking (lines
# ~3909-3942). Do NOT refactor — this is the spec.
# Ported from the (now-removed, #111) bridge.py:_voice_memory_search_blocking
# and kept as the spec the TS port must match. The ONE intentional change vs
# the original is the `namespace NOT LIKE 'person_pending:%'` kid-safety gate
# (#53) — added in both this oracle and brain_db.ts together. Don't otherwise
# refactor; if the TS port diverges, fix the TS, not this.
def _voice_memory_search_blocking(db: Path, query: str, limit: int = 5) -> list[dict]:
if not db.exists():
return []
Expand All @@ -37,11 +40,14 @@ def _voice_memory_search_blocking(db: Path, query: str, limit: int = 5) -> list[
try:
conn.row_factory = sqlite3.Row
cur = conn.execute(
# Kid-safety gate (#53): exclude unreviewed person_pending:<id>
# facts about a minor from free-text search (mirrors brain_db.ts).
"""
SELECT m.key, m.content, m.category, m.namespace, m.created_at
FROM memories_fts
JOIN memories m ON m.rowid = memories_fts.rowid
WHERE memories_fts MATCH ?
AND m.namespace NOT LIKE 'person_pending:%'
ORDER BY rank
LIMIT ?
""",
Expand Down
Loading