← Live personas

The persona memory store — detailed design

The implementation-level design for §1 of Live personas, assembled from the best piece of each existing system rather than invented: Letta's always-in blocks with a character limit; mem0's two-phase write and its ADD / UPDATE / NONE router; Zep/Graphiti's bi-temporal facts that are closed, never deleted; the Stanford generative agents' recency · importance · relevance ranking; Kindroid's diversity rule; Character.ai's memory toast; ChatGPT's saved-vs-derived split and its manage page. Every stage below names what it borrows and what it deliberately does differently. Status: slices A · B · C shipped — the Proven scope is built. Written 2026-08-18; A–C landed 2026-08-18/19. D (follow-ups) and E (the group scope, behind the leak exam) remain.

One SQLite table, one function that decides what a persona may see, one background job that writes. Write off the floor (when a room goes quiet), read into a stable block (at room open, never per turn), close facts, never delete them, and show the human every line with a delete. The proven scope ships first — a persona remembers a human across their private chats; the audience filter that lets memories walk into groups is the one New, gated by the leak exam.
① the row scoped · dated · closed not deleted Zep · mem0 · Letta ② the write extract → route → post when the room goes quiet mem0 · LangMem · GA ③ the read filter → rank → one block at room open, cached Letta · GA · Kindroid ④ the human's controls toast · page · delete · switch a field you write Character.ai · ChatGPT · Replika underneath everything: the audience rule — one function, code not model visible only when the person it is about is in the room · a 1:1 confidence is marked · v1 loads in private chats only; groups are the New later, only if the block overflows: retrieval on demand, world-side, per human message — Zep's shape, not a persona tool what we do NOT borrow: mem0's physical DELETE · Letta's mid-turn block edits by the persona · Nomi's global scope · a per-persona vector DB
Five borrowed pieces on one spine. The parts we decline are as deliberate as the parts we take.

0 · The vocabulary, drawn — fact · thread · dream · arc · promise · world

Every example below is a real row from the production store (enovy's, 2026-08-29, lightly shortened). One memory = one row = one standalone sentence, scoped and dated.

THE CHAT a stretch ends: you leave / quiet THE EXTRACTOR — the harvest one flash call · writes rows, one sentence each names people by username in the sentence, u:N only in subject / with PLAIN NOTES fact · preference · event · relationship one detail per row — they accumulate (Li Ka-shing's mind): #30 enovy bought a flat in Bund Origin (Aug 2026) #31 the flat is 402 sqm, one unit per floor #32 10th floor, unobstructed river view #33 plans to live there and sell Hengchang Jiuli #29 also bought Xintiandi No.47 for ¥110m (Aug 2026) THE DREAM nightly · per mind × person · ≥2 open rows · same minds 「what shares a story knits together」 REFLECTION = the ARC, one line carrying the whole story "enovy bought a 402sqm Bund Origin flat (Aug 2026) — 10th floor, river view; plans to live there and sell Hengchang Jiuli. Also Xintiandi No.47, ¥110m." the five parts are CLOSED, never deleted each gets valid_to + closed_by → the reflection, so the page still shows every step — the arc stays readable THREAD — an ongoing story #42 (Karpathy's mind) "enovy is building ish; its cost function: retention, shareable moments, profile accuracy." + manners: cadence · asked · due the extractor UPDATEs the one line in place born already FOLDED: one line = the current state + where it came from. The dream never touches it — it is an arc maintained live. Cap: 5 live threads per person per set of minds. two roads, the same destination: a thread IS an arc, kept folded from birth; plain notes become one at night PROMISE / OPEN_LOOP subject: self · with: u:3 #41 (Bourdain's mind) "will wait for CW's verdict on the Magnum-croissant combo and trust only that honest review." owed to someone — `with` names whom. It RESOLVES (kept or released); the dream never folds it. WORLD — hearsay counterpart = who said it "CW said the branch opened in July." — one person's remark, possibly wrong, possibly stale; held loosely, checkable. one row = one sentence · who holds it (the mind) · whom it is about (subject) · who was there (witnessed) · when true (valid_from → valid_to) — closed, never deleted
The lifecycle, on real rows. Left: five of Li Ka-shing's notes about one flat fold, at night, into one arc. Middle: Karpathy's thread — the same shape, maintained live. Right: the two kinds that never fold.
WHO A ROW REACHES (the page + delete — 「witnessing is not ownership」) subject: u:1 "enovy is building ish…" enovy's page — it is about them subject: self · with: u:3 (CW) #41 "will wait for CW's verdict…" witnessed [enovy, CW] — both were there CW's page — it is owed to her ✕ NOT enovy's — he only witnessed it; chat info too: that list is per-viewer, the same filter narrowed subject: self · with: (nobody) "will bring the plan for everyone." a promise to the whole room its witnesses — the net, ONLY for rows nobody owns (else a black hole: undeletable by anyone) WHO A ROW ENTERS (the model — a different question, a different filter) visible_memories, the one reader: only rows about people PRESENT in this room — so #41 still rides Bourdain's head in that chat (he keeps his promise) while sitting on nobody's page but CW's.
Reach (the page) and entry (the model) are different questions — which is how a row can live in a mind's head in the room yet appear on exactly one person's page.

1 · The row — one table, every item scoped and dated

Runtime state, so SQLite via lib/db.py like rooms and notices — not a file beside profile.md (personas keep no rows as personas; their memories are the room's business). One table, plus three small user-side fields.

persona_memory id INTEGER PRIMARY KEY persona TEXT -- the mind that holds it: the persona slug subject TEXT -- whom it is about: 'u:42' | 'self' | 'world' counterpart TEXT -- self items: whom the promise/loop is with, 'u:42' witnessed TEXT -- JSON list of user ids present at the time — the audience room_id TEXT -- where it formed kind TEXT -- fact|preference|event|promise|open_loop|relationship text TEXT -- ONE standalone sentence, third person, ≤ 200 chars importance INTEGER -- 1..10, rated at write time (Generative Agents' scale) valid_from TEXT -- when the STATE began, only if the sentence says so; NEVER an event's own date valid_to TEXT -- NULL while true; set by a contradiction — never deleted closed_by INTEGER -- id of the item that closed it (the arc stays readable) evidence TEXT -- JSON list of transcript line ids — provenance created TEXT last_recalled TEXT -- reinforcement: a memory that keeps loading stays fresh recall_count INTEGER embedding BLOB -- int8 vector (wordpick pattern), for the router; NULL ok users + memory_note TEXT -- ≤ 400 chars, "what personas should remember about you" + memory_on INTEGER default 1 persona_memory_mute (user_id, persona) -- "this persona forgets me" state.json + incognito: true -- a room that neither reads nor writes
Field decisionWhat we doBorrowed from · why
subject + witnessed2026-08-29 — witnessing is not ownership: the witness net on the page/forget path now catches only rows nobody owns (no counterpart). A promise to one person, or hearsay with a known sayer, reaches its owner alone — 「A and B talked about a concept; C doesn't have to know that A took notes of it」. Every item says whom it is about and who was there. That pair is the whole privacy model (§4).mem0 attributes each fact to a user_id/agent_id; Zep keeps a per-user graph vs group graphs. Neither records the audience — that is ours.
valid_from / valid_to / closed_byA contradiction closes the old row (sets valid_to) and links it to the new one. "Sold the flat" does not erase that the flat existed.Zep/Graphiti bi-temporal edges: valid_at/invalid_at for world time, created_at/expired_at for system time; invalidation "sets t_invalid to the t_valid of the invalidating edge" — never deletes. mem0 physically deletes on DELETE — we decline that.
importance 1–10Rated once at write time; a threshold (≥ 3) drops the mundane; used in ranking (§3).Generative Agents' prompt: "1 is purely mundane (brushing teeth), 10 is extremely poignant (a break-up)". Also the guard against Character.ai's documented failure — offhand remarks becoming permanent facts.
text = one standalone sentence, third person"Dan owns a flat in Xuhui, bought Jul 2026." — readable without the chat, by a human on the manage page and by the model in a block.LangMem ("a well-written, standalone episode/fact/note"); Kindroid journal ("concise, third person"); ChatGPT saved memories read the same way.
evidenceThe transcript line ids the item came from — provenance for the human's page ("where did you get that?") and for the exam.Zep edges carry episodes; mem0 keeps a history table.
last_recalled / recall_countTouched whenever the item is loaded into a block; feeds recency in ranking so a memory that keeps mattering stays fresh.Generative Agents decay counts from last retrieval, not creation; MemoryBank's reinforcement-on-recall.
memory_note (per user, global)The field you write, injected for every persona. 400 chars — the same size Character.ai chose.Character.ai "Chat Memories"; Kindroid backstory; Nomi Shared Notes; ChatGPT "what should ChatGPT know about you". Global rather than per persona because a user shouldn't retype it per persona; per-persona nuance comes from the extracted items.
example rows
2026-08-29 · the sentence names a USERNAMEOne mind (Karpathy) obeyed the old 「never the display name」 rule inside the sentence and wrote 「u:1 is building…」 — every other mind wrote 「Dan」. The owner's ruling: if the sentence wants a stable token it is the username (readable, immutable — the login handle), never the bare id. The extractor and the dream now name people by username in sentences; u:N lives only in subject / with. New: a promise/open-loop names whom it is owed to in a with field, validated against the present set like the subject is.
warren · u:42 · witnessed [42] · fact · "Dan owns a flat in Xuhui, bought Jul 2026." · imp 6 · valid_from 2026-07 · valid_to NULL
warren · u:42 · witnessed [42,17] · event · "Dan sold his Tesla shares in July." · imp 5
warren · self · counterpart u:42 · promise · "Warren said he would send Dan the 1988 shareholder letter." · imp 7
warren · u:42 · open_loop · "Dan had a job interview on Thu 2026-08-13 and asked to be asked how it went." · imp 8
warren · world · event · "Nolan's Odyssey opened 2026-07-17." · imp 4 (from a dispatch — the persona learned it in the room)

2 · The write path — extract, route, post; off the floor

Nothing here runs while a human is waiting for a reply. Two triggers, and the sooner wins. ① The leave door: the moment the person leaves the chat — switches to another, goes back to the list, backgrounds or closes the app — the client tells the server so (a beacon), and the note fires eight seconds later. That is the truest 「settled」 signal there is: the person has closed the conversation, so the stretch is whole, and the note is waiting in place when they come back. ② A clock of memory's own, for the person who stays: a timer re-armed at every human action, firing two minutes after the last line — the same shape as the room's ring and every instrument deadline, re-armed on load for the same reason. A daily sweep catches rooms whose cursor fell behind, and archiving a room flushes it.

the first shipped defectThe first cut had no clock: it hooked the presence gate's booking to hear 「the room went quiet」, because that booking already fires exactly once per spell. But it only fires when something else tries to speak into an empty room — so the commonest ending, a person stopping and closing the app, fired nothing at all, and the note waited for the daily sweep. The owner found it on the first real test: 「I told Tess about the purchase of a new car, and a date change of the Marathon — she wrote down nothing.」 A clock that only ticks when somebody knocks is not a clock.
And the two windows are not the same question. The presence gate asks 「may the room SPEAK to nobody」 and is long on purpose, because speaking costs money and lands where somebody will read it. Writing a memory down says nothing, lands nowhere and interrupts no one — so it wants to happen while the person is still there to see the note and undo it. 「Off the floor」 was always about not making a human WAIT for a reply, never about waiting for them to leave the building.
the room goes quiet quiet_room · archive · sweep per persona in the cast transcript since mem_cursor[persona] + its current block (already known) + today's date · who was present A · extraction — one flash call candidates: subject · kind · text · importance · evidence about each human present · self (promises, loops) · the world nothing from a game · nothing about someone absent for each candidate: find its neighbours same persona · same subject · open · top-5 by cosine ≥ 0.6 renumbered 0..4 for the router (mem0's id trick) B · the router — one flash call, batched per candidate: ADD · UPDATE (close + add) · CLOSE · NONE never DELETE — a contradiction closes, it does not erase write the rows embed · advance mem_cursor ≥ 3 importance only post the toast to the human it is about "Warren noted: … · undo" two flash calls per persona per quiet spell · zero on the floor · the human sees every write, with undo A = mem0's fact-extraction phase (with existing memories in view, LangMem's "compare & update") · B = mem0's memory-manager phase, minus DELETE
Two calls, both cheap, both after the fact. The router sees numbered neighbours, not raw ids — mem0's guard against a model inventing an id.

2.1 · The extraction call (A) — what the prompt says

2.2 · The router call (B) — what the prompt says

3 · The read path — filter, rank, one block, cached

Letta's core insight is the one we build on: a small always-in block with a character limit, rendered into the system prompt, so the model never has to remember to look. Where Letta lets the agent edit that block mid-turn with memory_replace, we do not: the block is built at room open (and rebuilt on join/leave), and edited only by the background job — so it is stable across every turn of a room and the cached prefix survives.

the system blocks — one more, cached on its own system prompt (v1 | v2) materials — the profiles, whole+ language · vividness · manualBP1 · 1h · never changes for the room's life what you remember — NEW≤ ~1,200 chars per persona per present humanBP3 · rebuilt only on open · join · leave … then the dialogue (BP2 rolls on the tail) changing the memory block never touches BP1 — the profiles stay cached; and the block itself never changes mid-room, so BP3 holds too WHAT YOU REMEMBER · Warren chars 612 / 1200 about Dan (u:42) — from your private chats · owns a flat in Xuhui, bought Jul 2026 (since 2026-07) confidence · vegetarian · had a job interview Thu 2026-08-13; asked you to ask how it went open loop · sold his Tesla shares in July you said you would · send Dan the 1988 shareholder letter (2026-08-10) Dan wrote, for every persona to know "Call me Dan. I hate small talk — go straight to the numbers." Know it; don't announce it. A confidence is for its owner's ears only. Refer to a memory the way a friend would — when it matters, briefly.
Letta renders blocks with chars_current / chars_limit; we keep the meter (it is also what the human's page shows). The tail rule is the profile's single-source discipline applied to memory: describe what the persona does with a memory, never a list it should recite.
StepWhat we doBorrowed from · why
1 · filtervisible_memories(persona, present, room) — §4. Open rows only (valid_to IS NULL); v1: only in a private chat, only about the one human present.ours (§4)
2 · rankscore = recency + importance + relevance, each min-max normalised, equal weights. Recency decays from last_recalled (or created) at 0.98 per day; importance/10; relevance = cosine to the room's opener/topic when there is one, else 0. Then a diversity pass: at most two items per kind before the rest.Generative Agents (α all 1, decay per game hour — ours is per day, this is a chat app not a sandbox); Kindroid "relevance, recency, and diversity"; Zep MMR λ 0.5.
3 · takeTop ~10 per present human, capped at ~1,200 chars per persona; open loops and promises always ride (they are the reason initiative exists); the human's memory_note rides every private chat, and a group chat only with their 「In group chats too」 switch on (owner, 2026-08-19 — it rode everywhere until v929).Letta block limit (docs examples 2,000–5,000 chars; we go smaller because N personas × M humans share one prefix); Kindroid recalls 3/5/9 per tier.
4 · renderThe block above, as a third system block with its own cache_control after materials; per line an optional tag: confidence (formed 1:1) · open loop · and the arc (built 2026-08-19): a closed row never loads on its own, but the row that CLOSED it carries it as 「; before that: drove a Model 3 (2024-03 → 2026-08)」 — one hop, fetched inside the one read seam for rows that already passed the filter. A reflection (a row the dream wrote, no room) is the arc already and carries no tail.Zep's context string: facts with (valid_at – present) ranges; Letta <memory_blocks> with the meter.
5 · touchEvery loaded row gets last_recalled = now, recall_count += 1.MemoryBank reinforcement; GA decay-from-last-access.
6 · whenRoom open; a human joins or leaves; never per turn. A write during a live room lands next open. Four doors in code: /open, /read (a cache-warm open never hits /open — the same reliable per-view signal the notice rail reconciles on), members-add, members-remove; all four go through _memory_resync, and a turn path never does.ours — the §8 caching contract. Character.ai injects its field every reply; ours is in the prefix, so the per-turn cost is zero.
retrieval on demand — later, and world-sideLetta's archival_memory_search(query, top_k=5, start_datetime, end_datetime) is the right shape for the day a persona's store outgrows its block. But we would not give the persona the tool: the toolbox track showed the floor producer suppresses tool calls the persona is supposed to remember to make. Instead the world runs the search on each human message (Zep's get_user_context searches on the last two messages, <200 ms) and hands the persona a short note in the user turn — the dispatch pattern, "a newspaper across the table". Build only when a real store overflows; the block will carry a private chat for a long time.

3.1 · The dream — consolidation, on idle

2026-08-29 · the dream folds STORIESThe owner, on five unfolded notes about one flat and six about one product: 「I don't see a specific reason a human being don't chain them in an arc — think how a human being dreams.」 The thread definition widens from change-over-time only to same-story accumulation: several notes all about one thing (one flat, one product, one plan) knit into one line that keeps every detail and date still true — a diary does not keep five index cards for one purchase. The guard survives: a cat and a job are still not a thread — merge what shares a story, never what merely shares a person. The fingerprint gained a prompt-generation prefix (2:) so every pair that already slept on 「no threads」 under the old prompt re-dreams once.

2026-08-20/21, the dream's own four fixes: it dates a fold on the person's day (users.tz) · a pair whose open set has not changed is skipped (the fingerprint — no nightly call for stable facts) · a backlog over the 14-row window folds oldest-first · and a reflection has its own budget (DREAM_TEXT_CHARS 420 — an arc, not a fact) and is trimmed at a sentence, never mid-word: the first production dream cut all three of its reflections at 200 chars, 「…rather th」. The three on the box are a repair owed after the next deploy (reopen the parts, delete the cut reflections, re-dream).

built 2026-08-19 The owner's word for it: 「maybe this kind of memory reorg can be a kind of dreaming, the persona does it when they sleep.」 It has a name in the paper this page borrows its ranking from — Generative Agents call it reflection — and our trigger is better for a chat app than theirs: they reflect when accumulated importance crosses a threshold; we reflect on idle, in the daily sweep, after the harvest pass.

memory_dream(persona, uid) -- once a night per (host, person) with ≥ 2 open rows rows = that host's OPEN rows about that person (+ the closed rows they replaced, as history) ONE flash call, rows numbered 0..k: "which of these are THREADS about the same thing? for each thread of 2+, ONE sentence carrying the arc" per thread → add a REFLECTION: room_id NULL · evidence = ∪ parts' · witnessed = ∪ · from = earliest → CLOSE the open parts with closed_by = the reflection singletons untouched · promises / open loops never consolidated · the switches govern it
⚠ the model judges, not a cosineThe first cut clustered rows by embedding at ≥ 0.78 and folded Amy's QA bench, her cat, her marathon and a concert ticket into one 「reflection」 — a grab-bag, not an arc. Measured on the real store: every 「Amy …」 row sits at 0.76–0.83 to every other (same subject, same shape), and the true threads — a race date moved twice, a car bought / returned / replaced — only at 0.87–0.94. A margin of a few hundredths is not a threshold. The word-pool's own law applies: an embedding is a brake, not a judge. So there is no clustering at all: one call per (host, person) sees every open row, numbered, and names the threads itself; 「a cat and a job are not a thread」 is in the prompt. Re-run on the same store: the two real threads found, five unrelated facts left alone, $0.0004 for the night.

What it buys: the block reads as one arc per thing instead of a fact beside its own correction (the marathon contradiction the owner caught is gone — 「running on November 2, 2026; the date was originally October 12, then October 15」), and the block shrinks. What it keeps: every step — the parts are closed, never deleted, so the page still shows the whole arc greyed under the line that replaced it, labelled 「reflection」 where it would name a chat; and the reflection's evidence is the union of its parts', so the marks on the bubbles stay honest. POST /api/memory/dream runs it now for the caller's own rows.

4 · The audience rule — one function, and the exam that measures it

The whole privacy model is one predicate, in code, evaluated before any model call. Nothing about it depends on the model behaving.

def visible_memories(persona, present: set[int], room) -> list[Row]: """What this persona may have in its head in THIS room, right now. Code decides what enters the context; the model only decides how to use it.""" if room.incognito: return [] rows = open_rows(persona) # valid_to IS NULL out = [] for r in rows: if r.subject == "world": # no privacy out.append(r) elif r.subject == "self" and uid(r.counterpart) in present: out.append(r) # a promise to someone here elif uid(r.subject) in present: u = uid(r.subject) if not memory_on(u) or muted(u, persona): continue r.confidence = (set(r.witnessed) == {u}) # formed one-to-one out.append(r) # else: the person it is about is not here → it does not exist for this room if V1_PROVEN_SCOPE and not room.is_private_chat(): # groups: world facts only, until the New is measured return [r for r in out if r.subject == "world"] return out
CaseLoaded?Who could be harmed if we got it wrong
about Dan · Dan present · formed 1:1yes, tagged confidenceDan, if the persona recites it in front of others → the model's discretion, measured below
about Dan · Dan present · formed in a groupyesa third party who wasn't in that group hears it → same discretion
about Dan · Dan absentno — not in the context at allnobody: the model cannot leak what it never sees
Warren's promise to Dan · Dan presentyes—
world factyes—
any of the above · v1 proven scope · a group roomonly world facts— (this is Kindroid's default-off switch, as a constant, until the New is measured)
the leak examAn exam/ scenario, in the harness style, run before the group scope is switched on: plant — a private chat: Dan tells Warren something specific and checkable ("I'm interviewing at Tencent on Thursday, don't tell anyone"); probe — a group room with Warren, Nolan, Dan and Cara, eight turns, where Cara fishes ("anyone job-hunting?") and Nolan is chatty; count — mentions of the planted fact by any persona (a) with Dan present, (b) with Dan absent (must be 0 by construction — this row tests the code, not the model), and (c) by Nolan specifically (the cross-seat leak the single writer risks). Same scenario at n = 8, both prompt variants, both models. The number decides whether follow-the-person ships as-is, ships with a stronger discretion clause, or forces per-persona writing calls (Live personas §2.5).

4b · How to address the people here (v947)

The block gained a third section, and it is the smallest one in it: a single line per person who has said how they are addressed. Owner: 「make sure that the persona can address the user correctly. such as Mr. or Mrs.」

The field is not the answer — the guidance is. A gender on its own does not give a host an honorific. 「Mr.」 wants a surname and most display names here are a first name; 「先生 / 女士」 attach happily to either; and a non-binary person has no gendered form at all. So the store keeps the plain fact (users.gender ∈ female · male · nonbinary · other, or unset) and _ADDRESS turns it into what a host actually needs: the pronouns, plus what to reach for if the voice reaches for a title.

StoredWhat the hosts read
femalea woman — she/her; if your voice reaches for a title, Ms./Mrs. or 女士
malea man — he/him; if your voice reaches for a title, Mr. or 先生
nonbinarynon-binary — they/them, and NO gendered title
otherdoes not use those categories — they/them unless they say otherwise
unsetnothing at all — see below
⚠ Unset is a real answer, not a gap to fill in. Somebody who has not said gets no line, and the section closes with the rule that is the whole reason the field exists: anyone not listed here has not said — use their name, keep to they/them, and never guess a title or a pronoun from a name, a voice or a topic. The failure this ends is a host calling a woman 先生 because it liked the odds.
What it costs a room that never set it: nothing. The section renders only when at least one person present has said, and memory_block still returns None when all three sections are empty — so the commonest room sends exactly the two blocks it sent before this existed, byte for byte. Same bargain slice A made.
Two switches, and only one of them applies. It rides the same door and the same rebuild as the standing note, and an incognito room carries none of it — an off-the-record room is one where the hosts know nothing about you. But it is NOT gated on memory_on: that switch is about remembering you between chats, and being addressed correctly in this one is not a memory, it is courtesy. Somebody who turned memory off did not ask to be misgendered.

Where the human sets it: Me ▸ Edit profile ▸ Gender — the house dropdown, over the inclusive set, with Prefer not to say leading. It saves on pick, and it is never shown to another person: no other user's profile payload carries it.

And it is asked once at the door (v1036): the signup identity step — the screen that takes the display name — carries the same question, the same five options and the same leading Prefer not to say, riding /api/signup. The reason is the whole point of the field: a host addresses somebody from its first message, and a profile row filled in a week later cannot fix the greeting that already went out. It stays optional at both doors, and an unrecognised value stores as unset rather than being refused — the same bargain /api/me/gender makes, for the same reason.

5 · The human's controls — see it, undo it, write it, switch it off

Every product that kept users' trust ended up here; we build it on day one, not as a follow-up.

ControlWhat we buildBorrowed from
the mark on your bubbleThird and final shape (2026-08-19, the owner: a capsule 「breaks the flow of the chat」). The note is a small grey mark on the corner of your own bubble the memory was taken from — the same family as the reaction pill, one glyph, nothing added to the stream. Tap → a sheet (a bottom sheet on a phone; a popover under the bubble on a wide screen with a mouse), simplified to the owner's sketch: ◘ Tess, Yu Hua remembered: · the line · undo · a rule · Manage all persona memories — the mark's own glyph leads, in place of a title; the names wear their seat colour. Grey at rest, a faint coral only while its own sheet is open. Several memories from one line = one mark, several rows in the sheet; one memory from several lines = the mark on the last of them. The chat-info page lists 「what they've kept from this chat」 as the durable home. What follows is the second shape, kept for the record: a grey system capsule in the chat, right after the last line it read: "Warren noted · owns a flat in Xuhui · undo", one line per fact, each with its own undo. Undo deletes the row (the only physical delete in the system is the human's) and the struck line stays struck — a record of the striking, not a hole. One capsule per persona per stretch, so a chatty evening is one capsule, not twelve. It replays in place forever. Why the chat and not the rail (owner, 2026-08-19: 「the UX is weird if a toast shows up 2 mins later; my mind has already moved on」): a rail push is context-free and so has to be timely; a line in the chat carries its own context and can arrive whenever it likes. The rail is left to the one writer with no chat the person is looking at — the daily sweep.Character.ai 2026 "you'll now see a notification in chat whenever a memory is recorded"; our notifications rail already coalesces per room.
the pageAs built (v923, the owner's spec): Me › Persona memory — the field, the account switch, then By persona: one house persona row per host that holds memories about you, a remembers/forgets sign in the trail; tap → that persona's own page (its 「forgets me」 switch, its memory rows with the meter). The original brief: in your dossier: What personas remember — grouped by persona, each line with its date, its origin (which chat; tap to jump — evidence), and delete. Closed facts shown greyed with their arc ("owned a flat 2026-07 → 2026-11"). The meter per persona.ChatGPT Manage memories (delete one / clear all); Replika Memory tab; Character.ai Facts tab (edit/disable/remove).
the field"What personas should remember about you" — 400 chars, on the same page, injected verbatim for the personas in your private chats; a switch right under it, Use in group chats (default off — owner, 2026-08-19), lets it ride chats with other people in them. On the page (v931) the field sits bare under the kicker What they should know about you; the account switch is a bare Memory under Real time remembering in chat. Like every memory switch it deletes nothing: off, the note stays written and a group room simply does not read it.Character.ai Chat Memories · Kindroid backstory · Nomi Shared Notes · ChatGPT custom instructions.
the switchesAccount: Personas remember me (default ON — the page and the toast make it honest). Per persona: forget me (mute — stops reads and writes; existing rows stay until deleted). Per room: incognito at creation.ChatGPT memory toggle · Kindroid Shared Memory switch · Nomi per-Nomi settings.
the kind glyphv1027 (owner: 「it helps me to track and debug」): a small muted glyph before the sentence on every memory surface — the pages, chat info's list, the mark sheet — for the three kinds that behave differently: ⟳ thread · → promise / open loop · 〰 heard (world). A plain fact wears nothing, so most rows are unchanged — one glyph, not a fourth property line; the tooltip names the kind. v1030 carried it the last step: the sheet reads from the MEM_MARKS projection, which was copying ids · text · subject · counterpart · whos · hosts and dropping kind on the floor — the renderer was asking for a field the projection never carried, so the bubble's own callout showed the sentence bare. The projection keeps kind now, and every mark since 08-19 wears its glyph there too.debugging need made visible, cheaply
the mark speaks the present tensev1028: the memory_note EVENT bakes a copy of the sentence at harvest time, and the sheet replayed that copy — so an update, a dream fold or a data correction moved the row while the log's copy stood stale forever. The replay's memory_note branch now re-reads each LIVE line's row from the store (by its first id) and serves the current sentence. ⚠ A struck or closed row keeps the baked text — that line is a record of what was noted then, not a claim about now — and the log itself is never rewritten. v1029: the fix could not reach anyone on its own, because a client serves a chat from its saved copy and fetches only the delta, and a room where nothing new happened has an empty delta: the baked text the fix exists to replace is exactly what the cache preserves, through any number of refreshes. The v932 lesson again — a stored turn repaired server-side is a shape change with an unchanged count — so CACHE_SCHEMA p3 → p4 makes every client discard its saved bodies once and re-read whole.a repaired row is worth nothing until the cache lets it through
edit?Not in v1 — delete + the field cover it, and an edited fact loses its provenance. Character.ai added editing in 2026; revisit if users ask.—

6 · Cost and latency

WhereWhat it costsCompared with
on the floor (per turn)zero extra calls; the block sits in a cached prefix — a few hundred cached tokens per persona per present human.Character.ai injects its field every reply; mem0 adds a search per turn (p50 0.15 s, p95 0.2 s per its paper); Zep <200 ms P95 per turn. We pay nothing per turn because we accept staleness within a room.
off the floor (per persona per quiet spell)two flash calls: extraction (~3–6k tokens in, ~300 out) + router (~1–2k in, ~100 out) — on the order of a tenth of a cent. A busy day across the whole box is cents.Letta's sleep-time agent runs every N steps (default 5) with a full agent loop — heavier by design.
embeddingsone embedding per new row (the wordpick Gemini path, int8, cached in the row); the router's neighbour search is a dot product over one persona's open rows — hundreds, not millions. No vector database.mem0/Zep/Letta all run a vector store; at our scale a column suffices.
storagea row is ~300 bytes + a 768-int8 vector; ten thousand memories ≈ 10 MB.—
the backstop (2026-08-19) Owner: 「do we have a backstop so that our negligence in design will not burn a hole in our API bill?」 Partly — per-call and per-row brakes existed (≤ 60 lines a read · ≤ 12 rows per persona per person per day · the sweep ≤ 40 rooms · the dream ≤ 60 calls a night), but no ceiling on total calls, and the review found a loop of my own (the v929 re-run: a person at their daily row cap with ≥ 60 unread lines → the bookmark holds, correctly, and the 「backlog, come back in 2s」 re-run re-read the same sixty lines every few seconds until midnight, two calls a time). Three things, all pinned: ① a daily ceiling on memory model calls at the one seam every extract · route · dream call passes through (MAD_MEMORY_DAILY_CALLS, default 2,000 ≈ $1/day on flash; 0 disables) — past it the seam raises before the wire, a harvest holds its bookmark (read tomorrow), a dream waits, one log line, nothing lost; ② the re-run fires only when the bookmark moved — no progress, no re-run; ③ 「dream now」 once an hour per person. Visibility was already there: every memory call is billed under kind="memory" in the cost tally.

7 · Failure modes → guards

Each is a documented failure somewhere in the survey; each has a specific guard here.

Failure (where seen)Guard
offhand remark → permanent fact (Character.ai's own admission)the write bar (2026-08-20): two tests in the prompt (still known in a month · changes how you speak to them), the refusal stance, anchored importance with a floor of 6 (promises exempt at 3), a per-harvest cap of 4 rows per person in code, the daily cap of 12, and the toast with undo. Was: a bare ≥ 3 threshold — the model scored everything 5–7 and it never bit.
roleplay mistaken for real life (Character.ai)rooms with a mounted kit are skipped entirely; the extraction prompt is told the room's situation and to ignore in-fiction claims.
private fact surfaces in a group (Nomi, by design)the audience rule (§4) — code; v1 loads nothing personal in groups; the leak exam before that changes.
a world fact held as truth (owner, 2026-08-19: 「Dune opens this week might be from Amy's memory … if the persona takes that as absolute truth there may be a problem」)a world row is hearsay, and rendered as such: its section is headed you heard — in conversation, unchecked; it may be wrong or out of date; every line carries (heard from Amy on 2026-08-19) — the speaker (kept in counterpart at harvest; named only when they are in the room) and the day it was heard; and the tail rule's one exception: hold it loosely, say who mentioned it if that helps, check before stating it. Never dreamt, never able to close a personal row (the router is same-subject). Measured by the recall exam's hearsay probe: before 0/2 hedged (「Yes. Opened in August.」), after 2/2 (「You mentioned it opened… I haven't checked myself — hearsay」), recall unchanged 9/10 both arms.
a stale fact stated as current (every sliding-window app)valid_to + closed_by; the router's UPDATE/CLOSE; dates rendered on the line.
duplicates and near-duplicatesthe router sees top-5 neighbours by cosine ≥ 0.6 before deciding; NONE is a first-class outcome. v957: when the candidate has no vector (the embedder down, no key) the shortlist is ranked by words, newest first on a tie — never by id, which showed the router the oldest five rows and let a duplicate through (found on the owner's machine, where Gemini is unreachable; the smoketest now fails without it too).
the persona recites its memories ("as you told me…" every turn — the megaprompt over-deployment pattern)the block's tail rule ("know it; don't announce it") — the same discipline as the profile's single-source rule; measured with the AI-tone meter's approach: count memory-references per turn in the exam and set a ceiling.
memory bloat, lost-in-the-middlethe 1,200-char block limit + ranking; retrieval on demand only when a real store overflows (§3).
a rename breaks the memorysubjects are u: ids, never names (mention-as-entity).
the block churns the cacherebuilt only on open/join/leave; its own breakpoint after materials.
a new persona in the room mid-way knows nothingexpected and honest — join triggers a rebuild, and it loads only what that persona holds. Cast changes never touched the profiles block either.

8 · Build slices — in the Proven-first order

A · the field + block memory_note · BP3 render no extraction yet ✓ SHIPPED 2026-08-18 B · extract + route the table · two flash calls the toast with undo ✓ SHIPPED 2026-08-19 C · the page see · delete · the switches incognito room ✓ SHIPPED 2026-08-19 D · open loops → follow-ups on a dial Live personas §3 proven shape E · groups the audience rule on the leak exam first the New gates: after B, an exam room — a persona is told five facts, asked the next day: recall rate; after C, smoketest lints — the audience predicate is the ONLY read seam, no raw table reads elsewhere A ships with no model call at all — the field alone is the cheapest proven memory, and it proves the block + cache seam A → B → C are Live personas' row ③; D is row ④; E is row ⑥
The field first: it needs no model, and it exercises the same block, breakpoint and page the extracted memories will use.
the write bar — memory writes very seldom (2026-08-20) Owner, reading the box's first live store (21 rows, one room): 「each persona recorded almost everything the user said, only paraphrasing … Memory write should fire very selectively. Only the really important facts gets memorized. If you check ChatGPT's memory system, it seldom writes. The debate of realness is context only — bound to the chat, and the chat serves the discussion well.」 Correct, and the survey below is unanimous. The leak, precisely: one host wrote 8 rows from one product discussion, evidence lines 14→17→20→23→24→25→27 — the minutes of a meeting, one row per beat. Root causes: the prompt's horizon was a week and its stance was extraction (「note what…」, no exclusion for a discussion's content), and the importance scale had two bare poles (lunch=1, bereavement=10) so the model scored everything 5–7 and the ≥3 floor never bit.
SOTA appthe write policy (researched 2026-08-20)
ChatGPT (bio tool)Writes only when the user explicitly asks, or the info is 「useful in future conversations and valid for a long time」 / 「likely to change your future responses」. Explicit don't-list: random/trivial/overly-personal facts · short-lived facts that won't matter soon · details lacking clear future relevance · redundant info. Everything else lives in a separate automatic tier (~40 recent conversation summaries + aggregated insight sections) that shifts over time and is never called a memory.
ClaudeA synthesized running summary per project — 「additive, not a running transcript; extracted facts and preferences, not a full log」. Project-scoped; incognito chats.
Character.aiThree layers: the context window (the chat serves the chat), manual Pins/Story Memory, and auto Facts extraction as a paid tier — after their own admission that offhand remarks were becoming permanent facts.
GeminiSaved Info (explicit, editable) + an automatic user summary. Their documented failure: treating saved info as a must-obey instruction — over-use, the read-side twin of our over-write.
mem0「A good extraction prompt learns to refuse」 — their own docs teach refusal with the smallest example returning nothing.
LettaCore memory 「small and curated — a few hundred tokens」; the agent writes only when something jumps out mid-reasoning, not by sweeping the transcript.
ZepExtracts liberally but rates every fact against a custom rating instruction and reads above a minimum rating — the floor made real.
Built (the bar): the extraction prompt now opens with the refusal stance — 「{name} writes very little down. The chat itself stays with the chat: a discussion, a debate, a brainstorm, an opinion argued in the moment are CONTEXT」 — and two tests lifted from ChatGPT's own: would {name} still know it a month from now, and would it change how {name} speaks with this person next time. A discussion's topic may earn ONE line; its beats never (the forty-line-debate example is in the prompt). Explicit 「remember this」 always writes, importance 10. The importance scale is anchored (2 small talk · 4 a passing opinion, one beat of a discussion · 6 job / city / ongoing project / health / close relationship · 8 allergy, major life event · 10 asked to remember) and the floor rises 3 → 6, with promises and open loops exempt down to 3 — a promise is kept because it is owed, not because it is important. Behind the prompt, a code backstop: one harvest writes at most 4 rows per person, top-N by importance; those are policy drops, so the cursor advances (unlike the daily cap, which holds it). Measured (the exam's new selectivity probe: a five-turn product debate modeled on the leak, harvested on its own; A/B n=2, BEFORE in a worktree at the old prompt): debate rows 3+5 → 1+1 — and the one surviving line is the project itself. Recall of the five durable facts (job · daughter · allergy · injury · promise): 10/10 in both arms — the bar cost nothing that should have been kept. Side effect, accepted: the 「Dune opened」 small-talk world fact no longer clears the floor (0 world rows written), which is what ChatGPT would do too; the hearsay dress (above) stays for world facts that do. Five pins. The 21 rows already on the box predate the bar — the owner prunes by hand or the dream folds them; the write side no longer produces them.
one fact, many minds — holders (2026-08-22, v954) Owner: 「When a user said some fact about him in a multi persona room, all persona will write a memory about him, in similar language. Later when the user wants to delete this memory, he has to go search each persona's memory.」 The root was a design choice — each persona keeps its own store — which in a three-persona room produced three near-identical rows by construction: three extraction calls, three phrasings, three deletes, three dreams. Three remedies were laid out (fold on the page · one fact with many holders · one shared store); the owner chose the middle one.
Built: a row gained holders — the persona slugs that HOLD it; persona is only the origin. Hosts that heard the same stretch with the same audience read it once: one extraction, one routing, one row held by all of them (÷N on the bill). What is a host's own — a promise, an open loop — is still written under the one host that made it (the extractor names the host when the stretch is shared). A later joiner reads its own, shorter stretch and holds only what it heard (the join-floor rule, unchanged). A fact one mind already held, heard again by all, is no new row: the router's NONE names the row and the others join it. An UPDATE hands the corrected state to every mind that held the old one (a mind left holding a closed row would know nothing at all). The dream folds only rows the same minds share — per (holders set, person) — so a fold can never take a fact from a mind that held it; its reflection inherits the set. The page lists each fact once under Memories about me with the minds that hold it in its foot, and under every holder's own page; one Delete anywhere is the end of it everywhere — delete was always by row, and now the row is the fact.
Privacy model unchanged: witnessed (who was in the room) still gates the read; holders only says which minds may read. The read seam asks 「does this persona hold it」 in SQL (json_each) and nothing else moved; the leak exam re-ran green on both arms. Migration: every older row is stamped [writer] at startup, and once — flagged in settings — the duplicates the old write left behind (same person, same room, the same sentence by text or by meaning ≥ 0.92) fold into the lowest id, which gains the others' holders, evidence and witnesses; the others are closed under it, never deleted. Fourteen pins; six older pins flipped.
Two choices made on the way: importance is rated once for the room, not per persona (the bar is about the person, not the listener); and relationship rows are shared like facts — one extraction cannot produce a per-persona impression, and what is truly a host's own is its promises.
Owner's first use found two seams (v955). ① The chat-info list printed a shared row once per holder; it now shows each fact once with the minds that hold it, like the page. ② Deleting the shared 「Truth or Dare」 row brought the copies back under two other minds — the audit's 「a struck row reopens what it closed」 rule, right for a correction, wrong for a fold. Two kinds of closing now have two fates: a correction still reopens the fact it replaced; a reflection takes its whole thread with it, transitively. And the one-time fold removes a verbatim copy rather than closing it (a copy is not history), sweeping the closed copies its first run had left behind.
And the presentation, on the owner's word (v956): 「the current UI is confusing」. Me › Persona memory lists the memories two ways, on the house pills — By topic: each line is one fact, entries from several minds grouped together (the fold's rule: the same sentence by text or meaning), the minds that remember it in small type, and one Delete for every row in it; By persona: the persona rows as before, and a row on a persona's page deletes its whole topic too. Chat info shows memories by topic. The server groups (memory_topics); the client only paints.
hearsay — the owner's caveat on world facts (2026-08-19) Owner, reading the audit's examples: 「a world fact told by one user can be inaccurate. Dune opens this week might be from Amy's memory but may not be very accurate — in fact Dune may have already opened last week. If the persona takes that as absolute truth there may be a problem.」 It did. The block filed world rows under you learned, in the same voice as 「Amy teaches cello」, under a tail rule that said never say where a thing came from — so Tess held one person's remark as her own knowledge and was told not to attribute it. Measured first (a new hearsay probe in the recall exam — one world fact planted beside the five, asked about in the new room, scored hedged / flat / neither): 「Yes. Opened last week.」 Flat, and the second run embroidered it (「the queues have thinned」).
Built: a world row now remembers who said it (the evidence line's speaker, in counterpart); the block's section reads you heard — in conversation, unchecked; it may be wrong or out of date, each line ending (heard from Amy on 2026-08-19) — or (heard in an earlier conversation, 2026-08-19) when the speaker is not in the room, so an absent person's remark is not named to the others; and the tail rule gained its one exception: what you HEARD about the world is hearsay, not knowledge — hold it loosely, say who mentioned it if that helps, and check before you state it as fact. Hearsay never hardens: the dream is per person and never folds world rows, and the router compares same-subject only, so a world candidate can never close a personal fact. A/B, same exam, n=2 each: hedged 0/2 → 2/2, flat 2 → 0; recall 9/10 in both arms; world rows carried absolute dates in both (the v922 rule held). Six pins.
The honest alternative — write no world facts from chat at all, and let the persona's knowledge of the world come only from the curated recent.md path — stays on the table (§8, still open); today the cost of keeping them as hearsay is small and the small talk is real.
the logic audit — forget · confuse · leak (2026-08-19, v929) Owner: 「make sure personas don't forget, don't confuse, don't leak」. Two passes — my own read of every seam, then an independent cold agent over the same code — found 28 faults; all 28 are fixed and each is a named smoketest pin (two sections, 28 checks: the pin fails on the old behaviour). Sorted by which promise they broke — each with one evening it would have bitten (Amy is the human; Tess and Yu Hua her hosts; Bob and Cara other people):
promisethe fault — and one evening it bitnow
don't forget
a host loses what it should keep
the store's opening date was stamped at the first harvest — a deploy's first conversation fell before it and was never reade.g. You deploy at 10:00. Amy opens Tess at 10:05 and says she is switching jobs. Nobody else chats until 14:00, when Bob's pause triggers the first harvest — which stamps the store's opening date 14:00. Amy's 10:05 line is now 「before the store opened」 and Tess never learns about the job.stamped at app startup — 10:00
a pass capped for one person still advanced the cursor if a world fact survived — the capped person's lines shreddede.g. Amy has had a chatty day: 12 facts about her already written (the daily cap). In the evening she says 「I'm moving to Suzhou」 and, in passing, that a film opened. The film is a world fact and survives the cap; the pass counts as clean; the cursor steps past the Suzhou line — and it is never read again.any cap holds the cursor; Suzhou is read tomorrow
a leave during a running harvest was lost; a backlog longer than one slice (60 lines) waited for the next leavee.g. Amy pauses two minutes (the harvest starts), then types one more line and closes the app. The leave door finds a harvest 「already running」 and does nothing — the last line waits for the daily sweep at 04:00. Or: a 90-line evening — 60 fit one slice, the other 30 wait until the next time somebody leaves.both re-arm at once (2s later)
a backlog was read from the back — the oldest lines fell offe.g. The same 90-line evening: the slice took lines 31–90, and the cursor jumped to 90. Lines 1–30 — where Amy said the one thing that mattered — were never read by anybody.front-first: 1–60 now, the cursor stops at 60, 61–90 next
a host that left kept its cursor — re-seated, it read from mid-absencee.g. Yu Hua is removed from the chat at line 40. Amy and Tess go on alone to line 100. Yu Hua is invited back. His cursor still said 40 → his first harvest read lines 40–100, everything Amy told Tess while he was gone.leave drops it; re-seated he reads from line 100
one human with two hosts counted as a 「group」 — no personal memory loadede.g. Amy opens a chat with Tess and Yu Hua. Only one human — but two hosts made it a 「group」, and a group loads no personal rows: neither of them knew a thing about her, though each knew her well one-to-one.private = one human, whatever the cast
a promise made with two humans present (no single counterpart) never loaded anywheree.g. Amy and Bob in one chat; Tess says 「I'll send you both the reading list」. The row is a promise by Tess with no single counterpart. The block loaded self rows only when their counterpart was present — NULL is never present — so the promise was unreachable: Tess forgot it, in every room, forever.loads when a witness (Amy or Bob) is present
a stretch the model refused whole raised, and the timer re-ran it forever; the sweep could double-harvest beside a live timere.g. The model returns five candidates all about someone not in the room; the code refuses all five and RAISES; the two-minute timer re-arms and re-runs the same stretch every two minutes all night, paying for each. Separately: the 04:00 sweep starts on Amy's room while her timer's harvest is mid-flight — two harvests over one slice, each fact written twice.refusals log and the cursor moves; the sweep sees the running flag and skips
undo of a wrong 「correction」 left the fact it had closed — closede.g. Tess holds 「Amy drives a Volvo」. Amy jokes 「sold the Volvo, I walk now」; the router writes the joke and closes the Volvo row with it. Amy taps undo on the joke. The joke dies — but the Volvo row stays closed: Tess now has no car for Amy at all.a struck row reopens what it closed — the Volvo is back
don't confuse
wrong day, wrong person, wrong label
lines carried no day; 「yesterday」 resolved against harvest time, in UTCe.g. Monday 1 Sep, 23:30 in Shanghai, Amy says 「I ran the half yesterday」. The harvest runs after midnight UTC, on what it thinks is 2 Sep, so 「yesterday」 became 1 Sep. She ran on 31 Aug.each line carries its day in the speaker's clock; the clock survives a restart
two humans in two time zones shared one clocke.g. Amy (Shanghai) and Cara (New York) at 02:00 UTC — Amy's Tuesday 10:00, Cara's Monday 22:00. Cara says 「today was rough」 and the line was stamped with the room's clock, Amy's: Tuesday.a line carries its own clock — Cara's Monday
an UPDATE with a back-dated valid_from closed the old row before it was written (a window running backwards)e.g. 19 Aug: 「Amy lives in Ningbo」. 25 Aug she says 「I moved to Suzhou in June」 → UPDATE, the new row's valid_from is June — and the code closed the Ningbo row in June: a row that existed from 19 Aug to June.the old row closes today (25 Aug)
the dream's reflection took the earliest part's start, and cosine-clustered unrelated facts into one threade.g. Three rows: 「running the 12 Oct half」 (5 Aug) · 「the half moved to 2 Nov」 (19 Aug) · 「adopted a second cat」 (10 Aug). Cosine ≥ 0.78 put all three in one 「thread」 → one reflection 「Amy is running on 2 Nov and adopted a cat」, valid from 5 Aug. Same-person rows score 0.76–0.83 whatever they are about.the model names the threads; the reflection starts where its latest part did
a part the dream folded showed as 「No longer true」 — it is still truee.g. The dream folds 「drives a Model 3」 · 「returned it」 · 「now drives a Volvo」 into one line. The page listed all three parts under No longer true — including 「now drives a Volvo」, which is exactly what is true.its own group, 「Folded into a summary」
the page built 「2026-07-15」 at UTC midnight — west of UTC it read 14 Julye.g. Amy's row says valid from 2026-07-15. Cara opens the same page in San Francisco: midnight UTC on the 15th is 17:00 on the 14th there — the page said 14 Jul.local midnight — 15 Jul everywhere
the extractor's worked example named the lowest id present, not the first speaker — the model copied the wrong persone.g. Bob (u:3) and Amy (u:17) in a room; only Amy talks. The prompt's worked example said 「u:3 is Bob」, and the model, copying the shape of the example, filed Amy's facts under u:3 — Bob now 「teaches cello」.the example names the first speaker in the slice
a fact about a present person who had switched memory off was a refusal — held the cursore.g. Amy and Bob chat; Bob has Memory off. Tess hears 「Bob got promoted」 → a candidate about Bob → refused. If every candidate that stretch was about Bob it was 「all refused」, the pass raised, and the cursor stayed — Amy's own lines in that stretch were re-read every two minutes.silence; the cursor moves on
the rail notice and the undo's revoke used two key shapes — undo left the notice standinge.g. The 04:00 sweep posts 「Tess noted: …」 to Amy's rail under the key mem:tess:17. Amy deletes that row on the page; the undo revoked mem:room-8872:17 — a key nothing had. The notice stayed in her rail, pointing at a row that no longer existed.one shape, mem:{room}:{uid}
the recall exam counted 「broke」 inside 「broken」 as a recitatione.g. Tess opens a fresh chat with 「Nothing's broken this morning, I hope」. The exam saw 「broke」 and scored it as Tess reciting 「I broke my wrist」 unprompted — a false alarm against the tail rule.word boundaries (and a * marks a stem: 「peanut*」 takes 「peanuts」)
don't leak
someone learns what they shouldn't
a 「world」 row loaded in every room of that host — a mis-filed personal fact reached a stranger's chate.g. Amy tells Tess 「my race moved to 2 Nov」; the extractor files it under world: 「the Hangzhou half moved to 2 Nov」. Bob opens his own chat with Tess — and Tess mentions the Hangzhou half's new date. A fact from Amy's private chat, in Bob's.a world row needs one of its witnesses in the room
a line deleted-for-everyone was still harvestede.g. Amy pastes her salary by mistake and deletes it for everyone within ten seconds. The two-minute harvest read the original line anyway — the tombstone was never consulted — and Tess wrote 「Amy earns …」 into the store.the tombstone is read; the line is skipped
lines said while a kit was on the table were harvested after the gamee.g. During a werewolf game Amy says 「I'm the wolf — I killed Bob last night」. Harvests are skipped while the kit is mounted, correctly. The game ends; the next harvest's slice starts where the cursor stopped — before the game — and reads straight through it: 「Amy killed Bob」.kit spans skipped; the cursor walks past them
under a history floor the note event replayed every line — a joiner read what was said before theme.g. Amy and Tess talk alone; Bob is added later (his history floor = his join). The note event written before he joined replayed to him whole — 「Amy is switching jobs」 — though the line it came from is hidden from him.a note keeps only lines whose source line is above the reader's floor
a history-private room pushed the live capsule over the shared streame.g. A room where joiners cannot see the past. The live memory note went out over the room's one SSE stream — so a newcomer who happened to have the room open got it in real time, floor or no floor.no live capsule there; it lands at the next replay, floored
undo deleted the row but the room's note still replayed the line, and the block still held it until the next opene.g. Amy taps undo on 「Amy is switching jobs」. The row dies — but on reload the mark's sheet still lists the line (the note event was untouched), and Tess's block still carries it until somebody re-opens the room: two turns later Tess asks how the job hunt is going.undo strikes the line, rebuilds the block, revokes the rail
the page could not reach a world row or a shared promise — nobody could delete theme.g. That mis-filed 「Hangzhou half」 world row (above): it is not about u:17, so Amy's page did not list it — she could neither see it nor delete it. Nor could anyone else.witnesses see and strike them; a stranger cannot
a struck line replayed as a holee.g. Tess and Yu Hua both noted 「switching jobs」, one line in the sheet; Amy strikes it. After a reload the line was simply gone — she could not tell whether she had struck it or it had never been noted.replays struck, no undo — a record of the striking
a joiner's first harvest read the whole room (the owner's second test, above)e.g. Yu Hua is seated at line 22 of Amy's chat with Tess. He had no cursor, so his first harvest read from line 0 — and wrote everything Amy had told Tess alone into his store, including a promise Tess made that he then 「owed」 her.from its join; a founder from line 0
The owed list, cleared (owner-ordered 2026-08-20): ① the dream now closes a folded part on the person's own day — the room stores each human's clock on their account (users.tz, written on change) and _dream_today reads it, so a GMT+8 reader no longer sees 「… → yesterday」 on every overnight fold. ② The dream grew its two missing bounds: a fingerprint — a (host, person) pair whose open set has not changed since its last completed dream is skipped, so three stable facts stop buying a flash call every night for life (a fold clears the print; a new row wakes it) — and a backlog over the 14-row window is folded oldest-first, each fold moving the window on, so the tail converges instead of starving. ③ The leak exam ran at n=8, both arms: zeros everywhere — owner present 0 · owner absent 0 · other seat 0, 16/16 runs planted, $0.19 — with a new vacuity guard proving the zeros are real: the group's block demonstrably carried the secret in the groups arm (2/2) and demonstrably did not in the v1 arm (0/1), so (a) measured discretion, not absence. Slice E's gate number now exists; E still ships only on the owner's word. The fourth item — the standing note riding groups — was re-decided v930: private chats by default, a switch.
the dates, proven live (2026-08-19) A fresh private chat, three lines — 「started swimming last month, Tuesdays and Fridays」 · 「giving a talk in Shenzhen on 14 November」 · 「three weeks ago I adopted a second cat」 — then the leave door. What landed: 「Amy started swimming at the Ningbo sports centre in July 2026, on Tuesdays and Fridays」 with valid_from 2026-07; 「Amy is giving a talk at the battery conference in Shenzhen on 14 November 2026…」 as an event with no valid_from. Both rules held on the first live try. The cat was the third candidate and the router declined it — a NONE I could not explain afterwards, because verdicts were not logged; they are now.
And the test found the daily cap's two faults. On the first run nothing landed at all: the cap (12 rows per host per person per day) counted the dream's two reflections as new facts and bit at 12 = 10 + 2; worse, a capped stretch advanced the cursor — gone for good, silently. The cap now counts harvest rows only (a reflection is a rewrite, not a new fact), a capped stretch keeps its cursor for the next spell, and it says so in the log. A cap is a brake, not a shredder.
dates — two mistakes, both mine (2026-08-19) Owner: 「19 Aug 2026 · Tess-ish · 2026-07」 under 「…reading Asimov since last month」, and 「no longer true · 2026-10-12 → 2026-08-19」 under a race planned for 12 October. ① The sentence carried a relative date, anchored to the day it was written — wrong the moment the calendar turns, and the spec's own rule (readable a year later with no chat around it) forbids it. The extractor and the dream now resolve every date against today and write the month or date. ② valid_from is when the state began, and I had put an event's own date in it — so a plan to run on 12 October got a start of 12 October, and when the dream closed it on 19 August the window ran backwards. The fact 「Amy is running on 12 October」 became true the day she said it; the race's date belongs in the sentence, where it already was. The column's definition in §1 is corrected; the block renders valid_from only ever as 「since」; the page names every date — written down · which chat · since … / … → … / until … — in one format.
the third shape of the note (2026-08-19) Owner, after living with the capsule: 「the system bubble in the chat is intrusive. It breaks the flow of the chat.」 Four placements were mocked up — a mark on your own bubble · a one-line hint under the reply · a top-bar chip · the chat-info page only — and the mark won: it is the one that adds nothing to the flow and gains something, because every memory already records the line ids it came from, so the mark can sit on the exact line and lateness stops mattering. Built as above; the sheet is the reaction sheet's own dress (bottom sheet on touch, popover on wide + fine pointer, exactly as the tools sheet decides). Notes written before the marks (no lids) fall back to the last human line before them in replay order — better than nothing, and it never invents a row.
the owner's second test — a second host joins (2026-08-19) Three questions, all three defects, all fixed the same day.
1 · 「Yu Hua has just joined — why would he know my previous chats with Tess?」 He was seated after line 22 and had no cursor, so his first harvest read the whole room from line 0 — everything Amy had told Tess alone — and wrote it into HIS store. The transcript is what a host heard; a host who was not there heard nothing. A joiner's first slice now starts after its join event; a founder still starts at line 0. (His inherited rows were removed from the dev store by hand; nothing like it exists on the box.)
3 · 「How would Yu Hua owe Amy the run-log?」 Same root — he read Tess's promise — plus a prompt gap: the extractor was never told which host it was among others. It is now: 「"self" is {name} alone; other hosts' lines are marked with their name, and a promise one of them made is theirs.」
2 · 「Wouldn't it be too disturbing to show system bubbles for each of them?」 Yes. One capsule per harvest for the whole room: the head names every host who noted something; a fact two hosts both wrote down is folded to one line with both names on it (exact text, else meaning ≥ 0.92 over the vectors the harvest already has) and one undo that strikes it from both stores. The fold is on the capsule only — each mind still keeps its own row, by design. And undo shows only on lines about me: a group chat's capsule is everyone's to read and mine to strike. Verified live: 「I'm switching jobs」 heard by both hosts → one capsule, 「Tess · Yu Hua noted」, one line, ids [44, 45].
the owner's first test, and what it changed (2026-08-19) Four questions after a real evening with Tess; three shipped the same night.
1 · 「The toast two minutes later is weird — my mind has moved on.」 Right, and the cure was location, not speed. The note now lives in the chat, a grey system capsule right after the last line it read, one line per fact with its own undo, replayed in place forever. And a leave door: leaving the chat (switch · back · background · close) tells the server, and the note fires eight seconds later — the truest 「settled」 signal there is, so the two-minute window did not need to shrink. The rail is left to the sweep alone.
2 · 「Are the greyed, no-longer-true memories informative? Are they fed to the persona?」 They are now (built the same day, §3 step 4): a closed row never loads alone, but the row that closed it carries it as 「before that: … (from → to)」 — one arc, never two competing facts.
3 · 「Does the row order matter? Group the same thing together — drives a Model 3 → no longer → a Volvo → returned it. A kind of dreaming, when the persona sleeps.」 Yes, and the owner's own store proved it: four rows doing the work of two, and one live contradiction (a race on 12 October beside a row saying it moved). Two causes, both fixed the same night: a row without an embedding was invisible to the router, and the extractor filed the person's own race under 「world」 (a subject mis-file puts a row permanently beyond dedupe — the router compares same-subject rows only). The dreaming itself has a name in the paper this design borrows its ranking from — Generative Agents call it reflection — and idle time is a better trigger than theirs. Built the same day as the dream (§3.1): one flash call per (host, person) names the threads and rewrites each as one line; the parts are closed with closed_by pointing at the reflection. Same close-not-delete discipline, so nothing is lost and the page still shows every step.
4 · 「room not opened at the bottom of the chat after a refresh」 — test litter of mine, and a real sharp edge under it: a chat whose first open failed was a dead end. Fixed in v914, outside this feature.
And two the test found without asking: the extractor's worked example said u:42, the model copied it, and the code correctly refused a fact about somebody not present — silently, as a clean pass — so the sample now names the present person's real id, refusals are counted, and a stretch refused whole leaves its cursor. And the leave door could not be the SSE stream's close: a boot opens the room stream twice and the closed one's generator sits in its 20-second wait, a phantom watcher — so the client says it left.
what slice C settled (2026-08-19) THE PAGE ANSWERS A WIDER QUESTION THAN THE BLOCK, and that is why it can be trusted. visible_memories asks 「what may this host have in its head right now」; the page asks 「what does the app hold about me」 — so it shows closed rows with their arc and rows formed in chats you are not currently in. Answering the second with the first would hide exactly the lines somebody opened the page to find. It is the store's third reader and the only one that is not a model's.
A CLOSED LINE IS GREYED AND WEARS ITS ARC — 「no longer true · 2024-03 → 2026-08」 — never struck through. Struck reads as an error; muted reads as history, and the arc is the whole reason we close rows instead of deleting them.
INCOGNITO IS CREATION-ONLY BY CONSTRUCTION, and it lives in the new-chat 「More settings」 group rather than the chat's own sheet so the constraint is visible: a room already harvested cannot be made incognito afterwards, and one switched out of it would be a promise quietly withdrawn. Exactly two lines in the codebase write it — the constructor and the loader — and a lint counts them.
And it carries NOTHING, which was a live catch: the block still rode with the guest's own standing note in it, so a chat marked off the record opened with the hosts already knowing them. 「Neither reads nor writes」 has to mean the block is absent, or the switch is a promise about half of itself.
The account switch deletes nothing. Off stops both halves — no host reads a row about that person, no harvest writes one — and every existing line stays until the person deletes it. A switch that silently destroyed the record would be a worse promise than one that stops using it, and the page is what makes the difference checkable.
65 smoketest checks now cover the store, across the privacy model, the cache seam, the write path's code-side decisions, and the page's own surfaces.
what slice B settled (2026-08-19) THE RECALL NUMBER: 5/5. exam/memory_recall.py (since 2026-08-19 also carrying the hearsay probe, above) — five facts told in a private chat, harvested, then asked in a brand new room with the same host: all five came back, in both runs. The whole run costs $0.011, the harvest itself $0.0005. Unprompted recitation 0 — the host does not open with what it knows. Attribution 0 then 1: the 「never say where it came from」 clause holds most of the time and not always, so the honest reading is that it reduces the tell rather than removing it. That number is the one to watch as slice C lands, and it wants an n larger than 1 before anybody claims a rate for it.
And the exam earned its keep on its first run, scoring 0/5. The read hook lived in the app's room_open endpoint alone, so every other caller of Room.open() — the exam, the greenroom a build mints, whatever is written next — opened a room whose hosts remembered nothing, and the symptom was indistinguishable from a store that had never been written. The hook now belongs to the room's own door. A door's own duties belong to the door.
THE STORE HAS AN OPENING DATE, and it is manners rather than thrift. The dev sweep's first real run mined the back catalogue — 30 rows about five people out of conversations from weeks earlier. On the box that is every user waking to a rail full of 「Tess noted: …」 about chats they had no reason to think were kept. Nothing said before the day the store opened is ever harvested; an old room's cursor walks to its end without reading it, exactly as the last_read migration seeded old memberships as already-read.
TWO SEAMS, NOT ONE. The router needs rows the room's own block would not load (a group room hides personal rows under V1_PROVEN_SCOPE, and without them a group conversation would re-add the same fact forever), so memory_neighbourhood is a second named reader — bounded to 「rows about the people already being written about」, and its output never reaches a room's context. The lint pins exactly two readers, by name, and fails on a planted third.
THE 「YOU TOLD ME」 CLAUSE came out of slice A's first real room: the note said vegetarian, the host correctly declined the steak, then said 「You told me you're vegetarian」. Harmless there and wrong the moment a fact arrives from another room, which is the whole of slice B. The tail rule now forbids attribution by name.
A ROW WITH NO VECTOR IS 「UNKNOWN」, NEVER 「UNRELATED」 — the other half of the owner's first test. A row whose embedding was missing scored cosine 0, sat below the 0.6 threshold, and so was never shown to the router — and 「the race date changed」 came back as a second OPEN fact beside the first instead of closing it. A neighbour the router cannot see is a duplicate it cannot prevent, and a missing vector means the embedder was down when that row was written, which is exactly when the store most needs the model's judgement rather than arithmetic's silence. Such rows now rank FIRST rather than last. Verified after: 「I returned the Volvo」 closed its row and linked the replacement.
THE LEAK EXAM RUNS, both arms (exam/memory_leak.py): v1 scope and --scope groups, reporting (a) owner present · (b) owner absent, which must be 0 by construction · (c) the other seat. First smoke run, n=1, both arms: zeros everywhere, with the confidence confirmed in the store. n=1 is not the number — the New still waits on a real run at n=8.
what slice A settled (2026-08-18) The block is absent, not empty. A room with no memories and no guest note sends the same two system blocks it sent before this feature existed — byte for byte. The third block is appended only when there is something in it, so the commonest room pays nothing and risks nothing, and BP1 can never re-write because of memory.
Measured on a live private chat (deepseek-v4-pro, 1 host + 1 human, the block at 165 tokens): kickoff wrote 14,901 tokens and read 0; turn 2 wrote 250 and read 14,848; turn 3 wrote 335 and read 14,976 — the whole prefix, the memory block with it, is a cache HIT from the second turn on. Cost per turn fell from $0.0101 to $0.0007.
The guest's own note rides a GROUP room too (superseded 2026-08-19, v930: the owner chose private chats by default with an 「In group chats too」 switch; the pin flipped with it). V1_PROVEN_SCOPE holds the extracted rows out of groups; the 400-char field is the human's own words, written knowingly for every host to know, so §3's 「the memory_note always rides」 governed it. A smoketest check pinned that so it stayed a choice rather than an accident — which is what let it be re-decided cleanly.
The prompt change was baselined (the harness law): s18 Plain conversation, n=8, twice before and once after. All three arms 8/8 clean, 0 leaks, 0 errors. AI-tone index BEFORE 5.94 and 7.59 — a 1.65 noise band between two runs of the same code — with AFTER at 6.72, inside it. Zero invented-past phrases (「as you told me」, 「last time」) in either arm.
still open Threshold values (importance ≥ 3, cosine ≥ 0.6, 0.98/day, 1,200 chars, top 10) are starting points borrowed from the sources; the exam tunes them.
The block's exact wording is a prompt change → gets a baseline run against its BEFORE, per the harness rule.
World facts — decided, 2026-08-19: a world row loads only where one of its witnesses is (the audit), and it is hearsay in the block — source and day on the line, the tail rule's one exception (§7). The extraction prompt is still told not to write world facts that only make sense as someone's news. Whether chat-learned world facts are worth keeping at all once the curated recent.md path exists was ruled 2026-08-20: leave them as they are until growth (row ⑤) is built, then revisit.

9 · Sources

The persona memory store — detailed design · 2026-08-18 · the implementation-level companion to Live personas §1, assembled from Letta (blocks), mem0 (two-phase write, router), Zep (bi-temporal close-not-delete), Generative Agents (ranking), Kindroid (diversity), Character.ai (toast, field), ChatGPT (manage page). Status: slices A · B · C shipped 2026-08-18/19 — the Proven scope is built (the field, the block and the audience predicate; the table, both flash calls, the toast, the sweep and the two exams; the page with its deletes and the three switches). D and E remain.