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
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,18 @@ The six kinds have confidence ceilings clamped on write and read: `observation`

Writes are strict; reads are lenient. Invalid or legacy data remains readable as `unknown`, without promoting a coarse `source` string into a claim type. Corrections mark supersession without deleting history. Relevance retrieval returns only topical matches (never recent padding); explicit `mode="recent"` remains available. A populated store with no relevant hit does not fall back to raw notes.

### Person Memory (`src/pxh/people.py`)

Facts a person literally stated about themselves, extracted **deterministically**
(regex, no model in the write path) into `state/people-{persona}.jsonl` as
provenance kind `report`. **Reflection must never read this store** — its output
reaches `/api/v1/public/thoughts`, the feed, the blog and Bluesky, so the
separate file *is* the privacy firewall, enforced by the filesystem rather than a
prompt. Pinned by `tests/test_people_invariants.py`; `people.py` and that test
are blacklisted from px-evolve because `mind.py` is a whitelisted target. Design
rationale, TTL policy and the bias-to-rejection matcher are in the module
docstring; the false-positive corpus in `tests/test_people.py` is the spec.

### Autonomous Racing (px-race)

```bash
Expand Down
117 changes: 117 additions & 0 deletions docs/specs/2026-08-25-person-memory-minimal-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Person Memory — Minimal Design (2026-08-25)

The smallest thing that lets SPARK remember what the people it talks to have
literally told it about themselves, without any possibility of that memory
reaching the public pipeline, the performance personas, or a model's
imagination. Implementation: `src/pxh/people.py`. Structural invariants:
`tests/test_people_invariants.py`. False-positive corpus (the extraction
spec, in executable form): `tests/test_people.py`.

## Scope and non-goals

Three fact kinds, and only three: **stable preferences**, **stated
relationships**, **explicit first-person commitments**. Deliberately out of
scope, now and until a new design supersedes this one: embeddings, vector
stores, episodic memory, spatial memory, LLM-based extraction, additional
fact classes. A missed fact costs one turn of continuity; a fabricated or
leaked one is a robot telling a child something they never said, or telling
the internet something a child said in private. Every trade in this design
is made in that direction.

## Store

`state/people-{persona}.jsonl`, one JSON record per line, append-only,
trimmed to the last 2000 lines. Record shape is compatible with
`memory.py`'s records (`ts`, `subject`, `text`, `tags`, `importance`,
`source`, provenance block) so the existing relevance scorer can read it
unchanged when retrieval lands. Extra fields: `fact_kind`, `topic`,
`polarity`, `expires_ts`.

**The separate file is the privacy firewall.** Reflection reads
`memories-{persona}.jsonl`, and reflection's output flows to
`thoughts-spark.jsonl` → `/api/v1/public/thoughts` → the site feed, the blog
and Bluesky. Person facts live in a file that `mind.py` never opens — the
same allowlist-by-construction discipline as `_REFLECTION_AWARENESS_KEYS`,
enforced by the filesystem and pinned by source-scan tests, not by prompt
prose.

## Writer

Deterministic regex extraction over clauses. **No model anywhere in the
write path** — a fact exists only because a human sentence asserted it.
Provenance kind is the hardcoded literal `report` (confidence ceiling 0.9);
no caller or model can choose a kind. The matcher is biased to rejection:
questions, hedges, conditionals, reported speech, second/third person,
hyperbole, deictic objects and negated intents are refused outright.
`tests/test_people.py`'s rejection corpus is the authority; patterns are
widened only against it, never to raise recall in the abstract.

### Evidence minimisation

Evidence stored with a fact is the **exact matched clause** plus the source
message reference — never the whole utterance. "I'm sad about school today,
but I really like dinosaurs" stores the dinosaur clause and nothing else;
the full message remains only in its source log (`obi_chat.jsonl`, the
conversation buffer), recoverable by id. A store whose stated purpose is
narrowly-scoped person facts must not accumulate unrelated private context
as a side effect of faithful provenance.

### Identity threading

A channel that has real event identity must thread it: obi-chat entries
carry an `id` and that id is the evidence reference (`obi_chat:<id>`).
Voice conversation turns carry no id, so their reference is a content hash
of the utterance (`voice:turn:<sha1-12>`) — an explicit fallback for
id-less channels, not an accepted normal path.

### Who writes

Exactly two call sites: `voice_loop.record_conversation_turn` (the user's
words only, SPARK persona only) and `api._append_obi_chat_api` (role `obi`
only, after the message is durably stored so evidence ids always name an
existing line). The persona gate lives inside `record_person_facts`, not at
the call sites: GREMLIN and VIXEN are refused at the writer, and a
per-persona filename alone would have been two stores, not a firewall.
SPARK's own replies are never facts about Obi. The writer never raises into
its caller.

### Commitments expire; nothing is deleted

Commitments get a days-not-weeks TTL (default 3, ceiling 10, tightened by a
named day, computed in Hobart time). Expiry filters at read time; records
stay on disk. Corrections use `provenance.supersedes` on the same
`(subject, fact_kind, topic)` — both records kept, one surfaced.

## Operator seeding (stage 2)

A small CLI writes operator-known facts through the same canonical
`append_person_facts` writer — never by editing the JSONL directly, never
via an LLM. Seed records are structurally distinguishable from
conversational extraction: `source`/`source_channel` is `operator_seed`,
the evidence names the operator as the asserting actor, and kind remains
`report`. **An operator-seeded fact must never render as "Obi told me"** —
attribution follows the record, and the record says who actually asserted
it. Optional expiry and supersession work exactly as above. Only benign,
stable facts are seeded; nothing sensitive (health, family conflict,
school support, private messages, location).

## Retrieval (stage 5 — not in the writer PR)

Injection into exactly two prompts: the SPARK voice prompt (persona ==
spark) and the obi-chat prompt. Never GREMLIN/VIXEN; never reflection,
public chat, blog, or social. Retrieval returns zero when nothing is
relevant — no recent-padding, ever. Injected lines are compact, preserve
attribution ("Adrian told you that…" vs "Obi told you 2 days ago that…"),
and every injected statement is mechanically traceable to a stored record.

## Enforcement

- `tests/test_people_invariants.py`: source scans pin that `mind.py` has no
route to the store, no module reads it before retrieval lands, only the
two named call sites write, and the writer contains no model call.
- `src/pxh/people.py` and `tests/test_people_invariants.py` are blacklisted
from px-evolve: `mind.py` and `voice_loop.py` are whitelisted evolution
targets, so the module deciding whether a bridge exists must not be one
SPARK can propose editing.
- `tests/conftest.py` isolates the store autouse, so test utterances never
land fabricated facts in the live robot's `state/`.
12 changes: 11 additions & 1 deletion src/pxh/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from pydantic import BaseModel, Field, ValidationError, field_validator
from starlette.middleware.base import BaseHTTPMiddleware

from . import people
from .runtime_paths import resolve_heartbeat_read_path, resolve_sonar_live_read_path
from .state import atomic_write, clear_quiet_mode, load_session, load_session_readonly, set_quiet_mode, update_session, tail_lines
from .time import utc_timestamp
Expand Down Expand Up @@ -1350,7 +1351,16 @@ def _append_obi_chat_api(entry: dict) -> None:
lines = lines[-100:]
atomic_write(path, "\n".join(lines) + "\n")
except _FileLockTimeout:
pass # best-effort; the message will still be returned in the response
return # best-effort; the message will still be returned in the response
# Person-memory writer. Runs only after the message is durably stored, so a
# fact's evidence msg id always names a line that exists; the `obi` role
# gate is inside record_person_facts (SPARK's own replies are not facts
# about Obi), as is the guarantee that this never raises into the request.
people.record_person_facts(role=str(entry.get("role") or ""),
text=str(entry.get("text") or ""),
msg_id=str(entry.get("id") or "") or None,
ts=str(entry.get("ts") or "") or None,
channel="obi_chat")


_PUBLIC_CHAT_EXECUTOR = ThreadPoolExecutor(max_workers=2)
Expand Down
8 changes: 8 additions & 0 deletions src/pxh/claude_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,14 @@ def run_claude_session(
"tools/check_investigator_agent.py",
"tests/test_agent_authority_invariant.py",
".claude/agents/spark-investigator.md",
# Person memory. The firewall keeping what a child said in private out of
# reflection — and therefore out of public thoughts, the blog and Bluesky —
# is that `mind.py` never opens `people-*.jsonl`. `mind.py` is a whitelisted
# evolution target, so the module that decides whether that bridge exists
# must not be one SPARK can propose editing, and neither must the test that
# checks it. Same reasoning as the policy and resident-only pairs above.
"src/pxh/people.py",
"tests/test_people_invariants.py",
}

BLACKLIST_PATTERNS = [
Expand Down
Loading
Loading