GLaDOS Vol 10 · Reaching Out
ch 98 / 99
Chapter 98

Remembering Across Sessions Without Replaying Them

What the night takes with it

Tonight she will hold a useful conversation. You will mention that walnuts are off the menu, that deliveries go to flat 2, that the third servo buzzes under load and you think it is the bracket. Tomorrow morning she will be exactly as ignorant as she was this morning. Every one of those lines was written to disk: chapter 21's log_interaction has been appending to the interactions table since volume 3, so nothing was lost. Nothing was read, either. The log has been a write-only diary.

At this point in a build, the tempting fix is training. You have transcripts, you have a GPU sitting idle at three in the morning, and every forum thread says fine-tune. Do not. There are three reasons, and the third is the one you cannot undo.

The first is cost. Her chat model answers out of roughly two gigabytes of quantized weights; training the same model wants those weights at working precision plus a gradient for every one of them plus optimizer state on top, several times the memory of just running it, on a board with eight gigabytes shared between CPU and GPU that is already holding her ears, her voice and the model itself. You would take the assistant offline for hours to learn something you could have typed in four seconds.

The second is what training actually moves. Gradient descent has no compartment marked "Tuesday": every weight it touches also encodes how English works and how she talks. A fact said once inside a two-thousand-word day is a whisper against the pretraining corpus, so to make it stick you repeat it, and repetition is precisely what teaches style. You end up with a model that sounds like your Tuesday without knowing anything about it, and slightly worse at everything it could do before. The literature calls that catastrophic forgetting, and small models suffer it hardest.

The third is the one that settles the argument. There is no DELETE against a weight. If she absorbs a wrong fact, a joke read literally, or a sentence you said at midnight and regretted by breakfast, the only rollback is the checkpoint from before that run, so undoing one bad night also undoes every good night after it. A memory you cannot edit line by line is a memory you do not own.

So the work is extraction, and the design fits in one sentence. Her weights never change; what changes is a row in a file you can read, correct and delete. After a conversation, read the window of log the day left behind, decide what in it was durable, write that through the same upsert chapter 14 already uses, and let the same budgeted loader decide when it comes back. Note that even the training route would need the log, so the log is the first thing to build whichever way you go.

◆ Note — nothing new to install, and how to read the numbers

Everything here is Python's standard library plus Ollama, both already in the workspace. The database is the one she has been filling since volume 2, glados/data/memory.db. Model output below is a capture from the bench and yours will differ: a 3b model asked to summarise your day will pick different words and sometimes different facts. The SQL results, the arithmetic and the error messages are exact, and you can check every one of them against your own file.

Reading the day, then deciding what lasts

▣ Build · stage 1 — three tables and two columns, added to a database already in use
# labs/memory_distill.py
"""Turn a day of conversation into rows she can read, correct and delete."""
import json
import sqlite3
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

from labs.long_term_memory import store_summary     # chapter 11
from labs.memory_context import load_context, save_fact   # chapter 14

DB_PATH = Path("glados/data/memory.db")
OLLAMA_URL = "http://localhost:11434/api/generate"
MODEL = "llama3.2:3b"


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


def ensure_schema(conn: sqlite3.Connection) -> None:
    """New tables, plus two columns the facts table has been missing since volume 2."""
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS distillations (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            from_id INTEGER NOT NULL,
            to_id INTEGER NOT NULL,
            created_at TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS fact_history (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            key TEXT NOT NULL,
            old_value TEXT,
            new_value TEXT,
            source_id INTEGER,
            changed_at TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS forgotten (
            key TEXT PRIMARY KEY,
            forgotten_at TEXT NOT NULL,
            through_id INTEGER NOT NULL);
    """)
    present = {row[1] for row in conn.execute("PRAGMA table_info(facts)")}
    for column, ddl in (("origin", "TEXT NOT NULL DEFAULT 'stated'"),
                        ("source_id", "INTEGER")):
        if column not in present:
            conn.execute(f"ALTER TABLE facts ADD COLUMN {column} {ddl}")
    conn.commit()
$ uv run python -m labs.memory_distill --schema
facts columns before: key, value, created_at
facts columns after:  key, value, created_at, origin, source_id
tables: conversations, distillations, fact_history, facts, forgotten, interactions

Three tables carry the three hard parts of this chapter. fact_history records what every change replaced, so a belief that turns out wrong can be traced to the night it arrived. forgotten holds tombstones, so a deletion is remembered as a deletion. distillations records which stretch of log has already been read, so no exchange is distilled twice and none is skipped.

The PRAGMA table_info guard is the interesting line. ALTER TABLE ... ADD COLUMN has no IF NOT EXISTS form, and running it a second time raises sqlite3.OperationalError: duplicate column name: origin, which would crash her at boot on every start after the first. Reading the current columns and adding only the missing ones restores the property every startup path in this book has: safe to run again. And yes, that f-string builds SQL, which chapter 11 told you never to do. The rule is about values, which can come from a user; column names and types cannot be parameterised in any database, and these two come from a tuple written in the source file. If a name ever comes from outside the program, you are writing a different and much more dangerous function.

▣ Build · stage 2 — the window the day left behind
def last_covered_id(conn: sqlite3.Connection) -> int:
    """The highest interaction id any previous pass has already read."""
    row = conn.execute("SELECT MAX(to_id) FROM distillations").fetchone()
    return row[0] or 0


def window(conn: sqlite3.Connection, limit: int = 40) -> list[tuple[int, str, str]]:
    """Exchanges logged since the last pass, oldest first."""
    return conn.execute(
        "SELECT id, user_input, response FROM interactions"
        " WHERE id > ? ORDER BY id LIMIT ?",
        (last_covered_id(conn), limit),
    ).fetchall()
$ uv run python -m labs.memory_distill --window   # her log on the bench: yours will differ
last distilled:  id 117
window:          ids 118..137, 20 exchanges, 1,463 words
first line:      [118] I'm out of coffee filters again.
last line:       [137] Goodnight.

One integer decides everything about this pass. Asking for rows after MAX(to_id) means the pass is resumable: run it nightly, run it twice by accident, run it after four days away, and it always reads exactly the exchanges nobody has read yet. MAX of an empty table returns NULL, which Python receives as None, and None or 0 gives the zero that makes the first run read from the beginning.

Note what the query does not do: it never touches the log except to read it. The interaction table stays append-only, and every product of this chapter lands somewhere else. That keeps the day's record and her beliefs about the day as two separate objects, and the rest of the chapter depends on the split.

▣ Build · stage 3 — one call, JSON by construction
EXTRACT_PROMPT = """You maintain the long-term memory of a house assistant.
Read the transcript and return JSON with two keys.

"facts": a list of objects, each {"key", "value", "origin", "from"}.
  Include a fact only if it will still be true in a month.
  "origin" is "stated" if the user said it in plain words, "inferred" if you
  worked it out. "from" is the [id] of the line that says it.
  snake_case keys. At most 8 facts.
"summary": one paragraph, 80 words at most, describing what happened.

TRANSCRIPT:
{transcript}"""


def ask_model(prompt: str, model: str = MODEL, timeout: int = 120) -> dict:
    """One Ollama call, constrained to emit JSON, decoded twice."""
    payload = json.dumps({
        "model": model,
        "prompt": prompt,
        "format": "json",
        "stream": False,
        "options": {"temperature": 0.0},
    }).encode()
    request = urllib.request.Request(
        OLLAMA_URL, data=payload, headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=timeout) as response:
        envelope = json.loads(response.read())
    return json.loads(envelope["response"])


def transcript_of(rows: list[tuple[int, str, str]]) -> str:
    """The window as text, every line tagged with an id the model can cite."""
    return "\n".join(f"[{i}] User: {user}\n[{i}] GLaDOS: {reply}"
                     for i, user, reply in rows)
$ uv run python -m labs.memory_distill --extract   # a bench capture: yours will differ
{
  "facts": [
    {"key": "coffee_order", "value": "black, no sugar", "origin": "stated", "from": 124},
    {"key": "allergy", "value": "walnuts", "origin": "stated", "from": 121},
    {"key": "home_address", "value": "14 Ashgrove Lane, flat 2", "origin": "stated", "from": 129},
    {"key": "user_name", "value": "Caleb", "origin": "stated", "from": 133},
    {"key": "last_topic", "value": "servo wiring", "origin": "stated", "from": 134},
    {"key": "mood", "value": "tired today", "origin": "stated", "from": 137},
    {"key": "coffee_filters", "value": "buys the size 4 cone", "origin": "inferred", "from": 118}
  ],
  "summary": "Kaleb was out of coffee filters again, confirmed a walnut allergy, gave 14 Ashgrove Lane as the delivery address, and spent the evening on servo wiring for the arm."
}
1,463 words in, 212 tokens out, 17.4 s

"format": "json" is doing more than politeness. Ollama compiles a grammar for JSON and lets the sampler pick only tokens that keep the output valid, so a chatty preamble or a fenced code block cannot be generated: they are not reachable moves. Asking nicely in the prompt gets you valid JSON most of the time, which is the worst frequency for a bug, because it fails on the night you are not watching. Newer Ollama takes a whole JSON schema in that field instead of the string "json", which pins the key names too.

Two decodes, two envelopes. The outer json.loads parses Ollama's reply object, whose response field is a string holding the model's answer; the inner one parses that string. Also notice what the prompt asks for besides the fact: an origin, and a from citing the line the fact came from. Neither is decoration. The gate in the next stage refuses to store anything the model merely deduced, and the forgetting code later needs to know which line in the log produced a belief.

▣ Build · stage 4 — durable, or just true at nine o'clock
VOLATILE = ("today", "tonight", "tomorrow", "right now", "this morning",
            "currently", "minutes", "o'clock")
EVENT_KEYS = ("timer", "reminder", "weather", "asked", "last_", "current_", "_now")


def judge(candidate: dict) -> tuple[bool, str]:
    """Keep it only if it will still be true in a month, and only if you said it."""
    key = candidate.get("key", "").strip()
    value = str(candidate.get("value", "")).strip()
    if not key or not value:
        return False, "empty key or value"
    if candidate.get("origin") != "stated":
        return False, "she worked it out; you did not say it"
    if any(word in key for word in EVENT_KEYS):
        return False, "the key names an event, not a property"
    if any(word in value.lower() for word in VOLATILE):
        return False, "the value is pinned to a moment"
    if len(value) > 120:
        return False, "too long to be one fact"
    return True, "durable"


if __name__ == "__main__":
    conn = sqlite3.connect(DB_PATH)
    candidates = ask_model(
        EXTRACT_PROMPT.format(transcript=transcript_of(window(conn))))["facts"]
    kept = 0
    for candidate in candidates:
        keep, reason = judge(candidate)
        kept += keep
        if keep:
            print(f"  keep  {candidate['key']:<15} = {candidate['value']}")
        else:
            print(f"  drop  {candidate['key']:<15} : {reason}")
    print(f"{kept} kept, {len(candidates) - kept} dropped")
$ uv run python -m labs.memory_distill --judge
  keep  coffee_order    = black, no sugar
  keep  allergy         = walnuts
  keep  home_address    = 14 Ashgrove Lane, flat 2
  keep  user_name       = Caleb
  drop  last_topic      : the key names an event, not a property
  drop  mood            : the value is pinned to a moment
  drop  coffee_filters  : she worked it out; you did not say it
4 kept, 3 dropped

Three of seven candidates went in the bin, and binning that share is healthy. The test the code applies is one you can apply in your head: would this still be true in a month, and would she be wrong to act on it then? "I take my coffee black" passes. "Set a timer for ten minutes" fails, because in a month it is not merely stale, it is false, and a memory full of last month's timers makes every prompt worse. That is the difference between a property of the person and an event in a day. Events already have a home: they are in the log, and tonight's summary mentions the ones that mattered.

The origin check is the strictest rule here and the one to keep. "You did not say it" throws away real information (the model's guess about your filter size may well be right), and it buys something better: every row in facts traces back to a sentence a human said. Inferences are cheap to generate and expensive to disprove, and once one is sitting in her prompt she will state it as flatly as your name. If you want to keep them, send them to the knowledge table from chapter 37, where they can live at low confidence and be hedged when they are read out.

Keyword lists are a blunt instrument and will misfire eventually: a fact whose value legitimately contains "minutes" ("the kettle takes four minutes") gets dropped because a keyword said so. Blunt in this direction is the right kind of wrong. A dropped durable fact costs you nothing you cannot say again; a stored momentary one sits in every prompt she builds until you go and find it.

Writing it back, then correcting it later

▣ Build · stage 5 — through the same upsert, with a record of what it replaced
def forgotten_through(conn: sqlite3.Connection, key: str) -> int:
    """The last interaction id covered by a deletion of this key, or 0."""
    row = conn.execute("SELECT through_id FROM forgotten WHERE key = ?", (key,)).fetchone()
    return row[0] if row else 0


def resolve_source(candidate: dict, ids: set[int]) -> int | None:
    """Trust the model's citation only when it names a line actually in the window."""
    try:
        source_id = int(candidate.get("from"))
    except (TypeError, ValueError):
        return None
    return source_id if source_id in ids else None


RANK = {"inferred": 0, "stated": 1, "corrected": 2}


def outranks(writing: str, stored: str) -> bool:
    """A write lands only if the new writer ranks at least as high as the old one."""
    return RANK.get(writing, 0) >= RANK.get(stored, 0)


def apply_facts(conn: sqlite3.Connection, kept: list[dict]) -> list[str]:
    written = []
    for candidate in kept:
        key, value = candidate["key"], candidate["value"]
        source_id = candidate.get("source_id")
        if (source_id or 0) <= forgotten_through(conn, key):
            continue                       # deleted, and this line predates the deletion
        row = conn.execute(
            "SELECT value, origin FROM facts WHERE key = ?", (key,)).fetchone()
        if row and row[0] == value:
            continue                       # already believed; nothing to write
        if row and not outranks(candidate["origin"], row[1]):
            continue                       # a guess never overwrites a statement
        conn.execute(
            "INSERT INTO fact_history (key, old_value, new_value, source_id, changed_at)"
            " VALUES (?, ?, ?, ?, ?)",
            (key, row[0] if row else None, value, source_id, now()))
        save_fact(conn, key, value)        # chapter 14's upsert, unchanged
        conn.execute("UPDATE facts SET origin = ?, source_id = ? WHERE key = ?",
                     (candidate["origin"], source_id, key))
        written.append(key)
    conn.commit()
    return written


def distill(conn: sqlite3.Connection, limit: int = 40) -> dict:
    """One pass: read the window, ask once, gate, write, record what was covered."""
    rows = window(conn, limit)
    if not rows:
        return {"covered": 0, "written": [], "summary": ""}
    ids = {row[0] for row in rows}
    result = ask_model(EXTRACT_PROMPT.format(transcript=transcript_of(rows)))
    kept = []
    for candidate in result.get("facts", []):
        keep, _ = judge(candidate)
        if keep:
            candidate["source_id"] = resolve_source(candidate, ids)
            kept.append(candidate)
    written = apply_facts(conn, kept)
    summary = result.get("summary", "").strip()
    if summary:
        store_summary(conn, summary)       # chapter 11's append-only summaries
    conn.execute(
        "INSERT INTO distillations (from_id, to_id, created_at) VALUES (?, ?, ?)",
        (rows[0][0], rows[-1][0], now()))
    conn.commit()
    return {"covered": len(rows), "written": written, "summary": summary}
$ uv run python -m labs.memory_distill --run
covered ids 118..137 (20 exchanges)
wrote: coffee_order, allergy, home_address, user_name
stored 1 summary

$ uv run python -m labs.memory_distill --context
Known facts about the user:
  - user_name: Caleb
  - home_address: 14 Ashgrove Lane, flat 2
  - allergy: walnuts
  - coffee_order: black, no sugar
  - last_topic: servo wiring
  - favorite_language: Python
Recent conversation summaries:
  - Kaleb was out of coffee filters again, confirmed a walnut allergy, gave 14 Ashgrove Lane as the delivery address, and spent the evening on servo wiring for the arm.

The second command is chapter 14's load_context, unchanged, reading the same tables it has always read. That is the whole integration: the nightly pass adds rows, and the loader that was already budgeting them keeps budgeting them. Ten facts is still the cap, newest still wins, and the four facts written tonight sit at the top because the upsert stamped them with tonight's timestamp.

The rank ladder is the part to carry away. Three kinds of writer touch this table: the extractor guessing, the extractor quoting you, and you at the keyboard, and outranks lets a write land only against an incumbent of equal or lower rank. The gate already refuses inferences, so today the ladder mostly protects corrections, which is its own reward: once you have overruled the extractor on a key, no pass gets to quietly overrule you back. The bottom rung matters the day something writes here without passing the gate, and something always eventually does: a migration, an import from a backup, a second extractor you write next year.

resolve_source carries a habit that belongs with every model that cites its sources. A cited line number is a claim, and a 3b model will occasionally cite a line that was never in the window or hand you the string "124" where you wanted an integer. Checking membership against the ids you actually sent costs one set lookup and turns a plausible number into a verified one. When it fails the fact is stored with source_id null, which is honest: she believes it and cannot tell you where she got it.

And one line in that output is wrong, written tonight, by a pass that followed every rule it was given. She thinks your name is Caleb.

▣ Build · stage 6 — a correction that stays visible
def correct(conn: sqlite3.Connection, key: str, value: str) -> None:
    """You, at the keyboard, overruling what she extracted."""
    row = conn.execute("SELECT value FROM facts WHERE key = ?", (key,)).fetchone()
    conn.execute(
        "INSERT INTO fact_history (key, old_value, new_value, source_id, changed_at)"
        " VALUES (?, ?, ?, NULL, ?)",
        (key, row[0] if row else None, value, now()))
    save_fact(conn, key, value)
    conn.execute(
        "UPDATE facts SET origin = 'corrected', source_id = NULL WHERE key = ?", (key,))
    conn.commit()


def history(conn: sqlite3.Connection, key: str) -> None:
    rows = conn.execute(
        "SELECT changed_at, old_value, new_value, source_id FROM fact_history"
        " WHERE key = ? ORDER BY id", (key,)).fetchall()
    for changed_at, old, new, source_id in rows:
        where = f"interaction {source_id}" if source_id else "you, at the keyboard"
        print(f"{changed_at[:19].replace('T', ' ')}  {(old or '(nothing)'):<16} -> "
              f"{(new or '(forgotten)'):<16} {where}")
$ uv run python -m labs.memory_distill --correct user_name Kaleb
2026-08-23 03:00:07  Kaleb            -> Caleb            interaction 133
2026-08-23 08:12:44  Caleb            -> Kaleb            you, at the keyboard

$ sqlite3 glados/data/memory.db "SELECT user_input FROM interactions WHERE id = 133"
Stop calling me Caleb. My name is Kaleb, with a K.

The history reads like an argument you can settle. A value that had stood since long before this pass existed was replaced at three this morning out of interaction 133, and interaction 133 turns out to be you telling her the speech model keeps getting your name wrong. The extractor read a complaint about a name as a statement of one, and picked the wrong half of the sentence. Two queries, no guessing, and the wrong belief is traced to the line that caused it.

Notice what could not have saved you here. Both writers were quoting you, so both claimed stated, so the rank check passed and newest won. That is the harder kind of contradiction: two facts sharing a key, equally entitled, and only one of them right. No comparison of strings resolves it, because resolving it means knowing which sentence you meant. So the code does the two things it can do correctly: it keeps a record of what changed and when, and it accepts an override from you. origin = 'corrected' is that override, and the ladder in stage 5 puts it above anything the extractor writes, so tomorrow's pass reading the same line cannot quietly undo it.

The second kind of contradiction is harder and the schema cannot see it at all. coffee_order: black and takes_milk: yes are different keys, so nothing collides, nothing overwrites, and both ride into the same prompt where a 3b model will cheerfully act on whichever it read last. No amount of table design catches that, because it needs to know what the words mean. What catches it is a person reading the list, which is the next stage and the reason it exists.

Forgetting has to reach the log

▣ Build · stage 7 — everything she believes, on one screen
# labs/memory_review.py
import sqlite3
from pathlib import Path

DB_PATH = Path("glados/data/memory.db")


def review(conn: sqlite3.Connection) -> None:
    rows = conn.execute(
        "SELECT key, value, origin, source_id, created_at FROM facts ORDER BY key"
    ).fetchall()
    print(f"{'key':<18}{'value':<28}{'origin':<11}{'from':>5}  stored")
    for key, value, origin, source_id, created_at in rows:
        print(f"{key:<18}{value:<28}{origin:<11}"
              f"{(source_id if source_id else '-'):>5}  {created_at[:10]}")
    orphans = sum(1 for row in rows if row[3] is None)
    tombs = conn.execute("SELECT COUNT(*) FROM forgotten").fetchone()[0]
    print(f"{len(rows)} facts, {orphans} with no interaction behind them, "
          f"{tombs} forgotten keys")


if __name__ == "__main__":
    review(sqlite3.connect(DB_PATH))
$ uv run python -m labs.memory_review
key               value                       origin      from  stored
allergy           walnuts                     stated       121  2026-08-23
coffee_order      black, no sugar             stated       124  2026-08-23
favorite_language Python                      stated         -  2026-03-02
home_address      14 Ashgrove Lane, flat 2    stated       129  2026-08-23
last_topic        servo wiring                stated         -  2026-08-14
user_name         Kaleb                       corrected      -  2026-08-23
6 facts, 3 with no interaction behind them, 0 forgotten keys

Six rows today. In three months, if nothing tends it, this table is forty rows of which nine are wrong and twelve describe an afternoon in September, and her prompt carries the ten newest of them into every reply. A memory that grows unattended becomes a memory full of noise, and the noise is not harmless: each junk line spends tokens and gives a small model something irrelevant to latch onto. So the table gets read by a person, the way you read a log.

Today's list already has two problems in it. last_topic is exactly the sort of thing stage 4 now refuses, mined by the regex extractor from volume 2 before the gate existed, and it has been telling her since the 14th that you are still on servo wiring. And three rows claim stated with nothing in the from column, because the ALTER TABLE in stage 1 gave every pre-existing row that default. They may well be true. She cannot show you where any of them came from, so they are the ones to check first.

Then there is home_address, which is true, which she stored correctly, and which you have decided you would rather she did not hold.

▣ Build · stage 8 — deleting the row is the easy half
def forget(conn: sqlite3.Connection, key: str) -> dict:
    """Delete the belief, tombstone the key, and blank the line that produced it."""
    row = conn.execute(
        "SELECT value, source_id FROM facts WHERE key = ?", (key,)).fetchone()
    if row is None:
        return {"key": key, "deleted": 0, "redacted": 0}
    value, source_id = row
    conn.execute("DELETE FROM facts WHERE key = ?", (key,))
    conn.execute(
        "INSERT INTO fact_history (key, old_value, new_value, source_id, changed_at)"
        " VALUES (?, ?, NULL, ?, ?)", (key, value, source_id, now()))
    through = conn.execute("SELECT MAX(id) FROM interactions").fetchone()[0] or 0
    conn.execute(
        "INSERT OR REPLACE INTO forgotten (key, forgotten_at, through_id)"
        " VALUES (?, ?, ?)", (key, now(), through))
    redacted = 0
    if source_id:
        redacted = conn.execute(
            "UPDATE interactions SET user_input = '[redacted]', response = '[redacted]'"
            " WHERE id = ?", (source_id,)).rowcount
    conn.commit()
    conn.execute("VACUUM")
    return {"key": key, "was": value, "deleted": 1,
            "redacted": redacted, "through": through}
$ uv run python -m labs.memory_distill --forget home_address
forgot home_address (was: 14 Ashgrove Lane, flat 2)
tombstoned through interaction 137, redacted 1 log row

$ sqlite3 glados/data/memory.db "SELECT id, user_input FROM interactions WHERE user_input LIKE '%Ashgrove%'"
$

A one-line version of this function is obvious and wrong: DELETE FROM facts WHERE key = ? removes the belief and leaves the sentence that produced it sitting in interaction 129, ready to be read again. The worked failure at the end of the chapter is the morning that came back to bite. What the version above does instead is treat a deletion as something to record, not merely something to perform.

Three defences, and each covers a hole in the others. Redacting the cited line removes the sentence the belief came from. The tombstone blocks any candidate whose source predates it, so an older line the model finds later cannot resurrect the key either. And because through_id records a point in time rather than the key alone, telling her the address again tomorrow works normally: that line has a higher id than the tombstone, so apply_facts writes it. Forgetting is a claim about the past, not a permanent ban.

Two honest limits. The redaction reaches one line, the one the fact cited; if you mentioned the address in four other exchanges, they are still there, and a real purge means searching the log yourself with the LIKE query above and blanking what it finds. And DELETE in SQLite marks pages free without overwriting the bytes, so the old text sits in the file until something reuses that page. VACUUM rewrites the database and drops the free pages, which is why the last line of forget runs it outside the transaction, where SQLite requires it.

Why this works: two rates of change

Two things grow at different speeds here, and the whole design comes from refusing to store them in the same place. The log grows with your days. This window held 1,463 words across 20 exchanges, about 73 each; a busy day of 40 exchanges is 2,920 words, and a month of busy days is 87,600. English runs near three words to four tokens, so that month is roughly 116,800 tokens of conversation, more than twenty-eight times the 4,096-token window Ollama serves her model in by default.

What she carries into a prompt does not grow at all. Ten facts at eight words each, plus three summaries at eighty, is 320 words, call it 430 tokens: a tenth of the window, and the same tenth on day 300 as on day 3. The log went up by a hundred thousand tokens and the prompt went up by nothing, because recall is capped by a budget while storage is capped by your disk. Replaying the transcript ties those two numbers together and pays the whole month on every single call.

The nightly cost lands in the right place too. One model call reads a day and writes a paragraph; her per-reply latency is untouched, because nothing about answering you got longer. Contrast the fine-tune from the opening, where storage and recall are the same object: the knowledge lives inside the weights, so you cannot read what she believes, cannot fix one row, and cannot delete one thing. Every capability in this chapter, reading the list, correcting a name, forgetting an address, exists because the two jobs were kept apart.

Generalize it past this book. When a system writes far more than it reads, keep the writing append-only and cheap, derive a bounded view for the reader, and record how each derived row was produced. Metrics pipelines do this with raw samples and rollups; accounting does it with a ledger and a balance. Once the derivation is recorded, every derived value is a question you can answer instead of a number you have to trust.

◆ Note — when to run the pass

Not after every exchange. Each pass is a full generation on a board where her chat model, her voice and her eyes already compete for the same eight gigabytes, and running one mid-conversation is a stall you will hear. Two triggers work well: at the end of a session, on the same shutdown path that stops her other services, and on a timer at three in the morning for the exchanges no session boundary caught. Both are the same function call; only the schedule differs. Give the timer version a lock file, because two passes running at once will read overlapping windows and write the same facts twice.

⚠ Worked failure — the address that came back

The first version of forget was one statement long, and for two weeks it looked perfect:

def forget(conn: sqlite3.Connection, key: str) -> None:
    conn.execute("DELETE FROM facts WHERE key = ?", (key,))   # BUG: the log still says it
    conn.commit()

Then you improved the extraction prompt, and did the sensible thing with an append-only log: threw away the derived rows and rebuilt them from the beginning, so the whole history would be read by the better prompt.

$ uv run python -m labs.memory_distill --rebuild   # clears distillations, re-reads from id 1
covered ids 1..137 (137 exchanges, 4 passes)
wrote: home_address

$ uv run python -m labs.memory_distill --history home_address
2026-09-06 11:20:44  (nothing)        -> 14 Ashgrove Lane, flat 2 interaction 129

Symptom to cause in one query, and the one row the rebuild changed was the row you had removed. The address you deleted in August is back in September, sourced to interaction 129, which is still sitting in the log saying exactly what it always said. Nothing malfunctioned. The rebuild is supposed to reproduce her memory from the log, the log is the authority, and the log never heard about the deletion, so the deletion was undone by a feature working correctly.

The general form is worth more than this one bug. Whenever a derived store can be rebuilt from a source of truth, a delete that touches only the derived store has an expiry date, and it expires at the worst possible moment: a restore from backup, a migration, a reprocessing run. Deletion has to be recorded as an event in the same place the facts come from. That is what the forgotten table is, and why forget also blanks the line it can name. Rebuild now and interaction 129 says [redacted], and even if it did not, the tombstone would refuse a candidate whose source predates the deletion.

Checkpoint, and a day that runs without you

✓ Checkpoint — what you can now do
  • I can give the three reasons a nightly fine-tune is the wrong tool for household facts, and say which one of them is irreversible.
  • I can apply the durability test to a candidate in my head, and say where a rejected momentary fact still lives.
  • I can trace one fact from the sentence I said it in, through the gate, into facts, and back out in the assembled prompt.
  • I can find the night a belief changed and the interaction that changed it, using two queries and no guessing.
  • I can say what a DELETE from facts leaves behind, and name the three things forget has to touch instead.
  • I can explain why the injected memory stays near 430 tokens on day 300 while the log passes a hundred thousand.
⚡ Exercises — try first, then reveal
Exercise 1 — run the gate over your own log, without writing anything. Add a --dry-run flag that reads the real window, calls the model, prints every candidate with its verdict, and stops before apply_facts. Read the drops before you trust the keeps.

Wrap the write in the flag and return the same dict, so the two paths cannot drift: written = [] if dry_run else apply_facts(conn, kept), with the distillations insert skipped as well, or the next real run will skip the window you only pretended to read. On a normal day a 3b model returns six to eight candidates and the gate keeps two or three. If it returns nothing, your window is probably too short to summarise; if it returns eight glowing observations about your personality, they are inferences and the origin check is about to earn its place.

Exercise 2 — prove the delete did not erase anything. Store a fact with a memorable made-up value, forget it with VACUUM commented out, then search the raw database file for the string. Uncomment VACUUM and search again.

grep -ac Ashgrove glados/data/memory.db prints 1 after the delete and 0 after the vacuum (-a makes grep treat the binary file as text). The row is unreachable by SQL the moment you delete it, and the bytes stay on the page until something reuses it. For a memory that holds addresses and health details, that gap matters: back up the file between those two states and you have quietly backed up the thing you deleted. PRAGMA secure_delete = ON makes SQLite zero the bytes as it goes, at the cost of extra writes on every delete.

Exercise 3 — let her forget out loud. Wire "forget my address" into the rule table from volume 2 so a spoken sentence reaches forget(), and have her read back what she is about to delete before she does it.

The interesting part is not the rule, it is the gap between "my address" and the key home_address. Search the keys instead of guessing: SELECT key, value FROM facts WHERE key LIKE ? with %address% returns one row, and one row means you can confirm and delete. Two rows mean she has to ask which, and zero means she says so instead of silently doing nothing. Say it to her, hear "Deleting home_address, which says 14 Ashgrove Lane, flat 2. Confirm," say yes, and watch the review table come back one row shorter.

She now ends a day with something to show for it: a handful of rows you can read, a paragraph she wrote about your Tuesday, and a delete that stays deleted. Every piece she owns runs on its own clock now, and none of them has been asked to run all day together. The last chapter starts her at six in the morning and follows a whole day end to end, including the part where a subsystem dies at noon and she keeps going, and finishes with this pass, so the day leaves something behind.