GLaDOS Vol 2 · Her Mind
ch 11 / 99
Chapter 11

A Memory That Survives

She forgets you every night

Volume 1 ended with a conversation, and a dirty secret under it: her memory is a Python list, and the instant the process exits, the list is gone. Restart the loop and she greets you like a stranger: no idea what your name is, what you fixed yesterday, or that you spent an hour teaching her your coffee order. An assistant that forgets everything between runs can answer questions, but it cannot build on anything, and building on things is most of what "knowing someone" means.

The tempting fix, which exercise 2 of chapter 10 already flirted with, is dumping the history to a JSON file on exit and loading it at boot. It works for a weekend and rots fast: you load the whole file to read one fact, every write rewrites the entire file (and a crash mid-write corrupts everything), and "the five most recent summaries" means sorting the whole list in Python every time. JSON is a document format. What her memory needs is a query engine.

She already has one. sqlite3 ships inside Python: no server, no install, one file on disk, with atomic writes, indexes, and real SELECT ... ORDER BY ... LIMIT queries. The design for this chapter: her long-term memory is a single SQLite file, facts in one table, conversation summaries in another, every row stamped with a UTC timestamp, recalled by recency. Databases in full are Database Zero's subject; this chapter teaches exactly the slice a mind needs.

A schema, then rows, then recall

▣ Build · stage 1 — open a database, create a table
# labs/long_term_memory.py
import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("""
    CREATE TABLE IF NOT EXISTS facts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        key TEXT NOT NULL,
        value TEXT NOT NULL,
        created_at TEXT NOT NULL
    )
""")
tables = conn.execute(
    "SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
print("Tables:", tables)
conn.close()
$ uv run python labs/long_term_memory.py
Tables: [('facts',), ('sqlite_sequence',)]

":memory:" spins up a throwaway database, so you can experiment without touching disk; the real file arrives in stage 4. CREATE TABLE IF NOT EXISTS is chapter 1's idempotence habit wearing SQL clothes: safe to run at every startup, a no-op when the table is already there. And you asked for one table and got two: sqlite_sequence is SQLite's own bookkeeping for AUTOINCREMENT columns. You never write to it, but knowing it belongs there saves you a confused ten minutes someday.

▣ Build · stage 2 — store and recall facts, safely
from datetime import datetime, timezone

def store_fact(conn, key: str, value: str) -> None:
    conn.execute(
        "INSERT INTO facts (key, value, created_at) VALUES (?, ?, ?)",
        (key, value, datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()

def recall_facts(conn) -> dict[str, str]:
    rows = conn.execute(
        "SELECT key, value FROM facts ORDER BY created_at DESC"
    ).fetchall()
    return {row[0]: row[1] for row in rows}

store_fact(conn, "user_name", "Kryo")
store_fact(conn, "preferred_language", "Python")
print("Remembered facts:", recall_facts(conn))
$ uv run python labs/long_term_memory.py
Remembered facts: {'preferred_language': 'Python', 'user_name': 'Kryo'}

Two details deserve your suspicion. The ? placeholders send the SQL template and your values to SQLite as two separate things, so the values are never parsed as SQL; that is the only real defense against a user named O'Brien, and the failure box makes the case the hard way. And the dict comes back "reversed" because ORDER BY created_at DESC puts the newest fact first, and the comprehension preserves that order. conn.commit() after every write is mandatory; SQLite buffers until you commit, and uncommitted rows die with the process, recreating exactly the amnesia you came here to cure.

▣ Build · stage 3 — summaries, recalled by recency
def store_summary(conn, summary: str) -> None:
    conn.execute(
        "INSERT INTO conversations (summary, created_at) VALUES (?, ?)",
        (summary, datetime.now(timezone.utc).isoformat()),
    )
    conn.commit()

def recall_summaries(conn, limit: int = 5) -> list[str]:
    rows = conn.execute(
        "SELECT summary FROM conversations ORDER BY created_at DESC LIMIT ?",
        (limit,),
    ).fetchall()
    return [row[0] for row in rows]
$ uv run python labs/long_term_memory.py
Recent summaries: ["User switched the wake word to 'Hey GLaDOS'.", 'User asked about TTS and confirmed Piper works.']

Facts are for things that stay true; summaries are for things that happened. The LIMIT ? makes "the five most recent" one fast query instead of a load and sort, and yes, even the integer goes through a placeholder. Never format a value into SQL, no matter how harmless it looks; the habit only protects you if it has no exceptions. Notice too that the apostrophes in 'Hey GLaDOS' round-tripped without any escaping from you. That is parameterization quietly earning its keep.

▣ Build · stage 4 — the real file, and the module she keeps
# labs/long_term_memory.py — final form
import sqlite3
from pathlib import Path
from datetime import datetime, timezone

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

def init_db(db_path: Path) -> sqlite3.Connection:
    db_path.parent.mkdir(parents=True, exist_ok=True)
    conn = sqlite3.connect(db_path)
    conn.execute("""CREATE TABLE IF NOT EXISTS facts (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        key TEXT NOT NULL, value TEXT NOT NULL, created_at TEXT NOT NULL)""")
    conn.execute("""CREATE TABLE IF NOT EXISTS conversations (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        summary TEXT NOT NULL, created_at TEXT NOT NULL)""")
    conn.commit()
    return conn

if __name__ == "__main__":
    conn = init_db(DB_PATH)
    store_fact(conn, "user_name", "Kryo")
    store_summary(conn, "First persistent session. She will remember this.")
    print("Facts:", recall_facts(conn))
    print("Summaries:", recall_summaries(conn, limit=3))
    conn.close()
$ uv run python labs/long_term_memory.py   # second run, after a first run and a restart
Facts: {'user_name': 'Kryo'}
Summaries: ['First persistent session. She will remember this.', 'First persistent session. She will remember this.']

Run it, kill the process, run it again: the facts are still there, which is the entire point of the chapter, witnessed. The double summary in the second run is not a bug in SQLite; it is the demo inserting the same line on every launch, and it points at a real design question — facts are keyed and could be de-duplicated, while summaries are a log and duplicates are honest history. Chapter 14 makes that distinction do real work when memory starts flowing into her prompts.

Why this works: the template and the data never mix

A parameterized query compiles INSERT ... VALUES (?, ?, ?) once, then binds your values into the prepared statement. The values travel as data, never as SQL text, so nothing a value contains (apostrophes, semicolons, a whole malicious DROP TABLE) can change the statement's structure. String formatting glues data into the SQL text itself, and then SQLite has no way to tell your content from your code. One path is immune by construction; the other is a bug that waits for the right input to become a disaster.

The timestamp choice is quieter but just as deliberate: datetime.now(timezone.utc).isoformat() produces strings that sort lexicographically in chronological order, so ORDER BY created_at is correct without parsing a single date, and UTC sidesteps the twice-a-year lies your local clock tells. Two small disciplines, timestamps that sort and queries that bind, and every memory feature this book builds from here sits on them.

⚠ Worked failure — the user named O'Brien

Here is the f-string version, which works in every test you will think to run, because none of your test names contain an apostrophe:

key, value = "user_name", "O'Brien"
conn.execute(
    f"INSERT INTO facts (key, value, created_at) VALUES ('{key}', '{value}', 'now')"
)
$ uv run python labs/fail_fstring.py
Traceback (most recent call last):
  File "labs/fail_fstring.py", line 8, in <module>
    conn.execute(
sqlite3.OperationalError: near "Brien": syntax error

The apostrophe in O'Brien ended the SQL string early, and the parser hit the rest of the name where it expected syntax. Read the error's location, near "Brien": your data has leaked into the language. The crash is the friendly version; input crafted on purpose does not crash, it executes things you never wrote. The fix is not escaping apostrophes yourself (you will miss one), it is the placeholder form from stage 2, where the problem cannot be expressed. Some bug classes you fix; the better ones you make impossible.

Checkpoint, with a past

✓ Checkpoint — what you can now do
  • I can say why JSON-on-exit rots as a memory store and name the three things a database gives her instead.
  • I can write a parameterized insert from memory and explain, mechanically, why bound values cannot alter the statement.
  • I know why UTC ISO-8601 strings are a correct sort key with no date parsing anywhere.
  • I can explain what commit() buffers, and what happens to uncommitted rows on a crash.
  • I have killed the process and watched a fact survive it.
⚡ Exercises — try first, then reveal
Exercise 1 — de-duplicate the facts. Change store_fact so a repeated key updates the old row instead of adding a new one. What does SQLite call this, and what schema change makes it easy?

An upsert: add UNIQUE to the key column and write INSERT INTO facts ... ON CONFLICT(key) DO UPDATE SET value=excluded.value, created_at=excluded.created_at. Now "user_name" is always one row, always current. Summaries stay append-only on purpose; the difference between state and log is worth keeping in the schema, not just in your head.

Exercise 2 — wire memory into the loop. Import this module from chapter 10's voice loop: load facts at boot into the system prompt, and store one summary line on Ctrl+C. What does she know at next boot?

Whatever you stored: greet her after a restart and she can use your name without being told, because the fact rode into the prompt from disk. You have just built the primitive version of chapter 14's context engine, and you will also notice its crudeness — every fact, every boot, relevant or not. Selection is the next problem, and it is a real one.

Exercise 3 — prove atomicity the rude way. Insert a thousand summaries in a loop with the commit outside the loop, and kill the process halfway. What is in the table afterward, and why is that the right answer?

Nothing from the interrupted batch: the transaction never committed, so SQLite rolled it back cleanly. Compare that with the JSON file the chapter opened with, where a mid-write crash leaves half a file that fails to parse and takes every memory with it. All-or-nothing is precisely what you want holding her past.

She remembers now, across restarts, in a file you can inspect with any SQLite browser. Next chapter reworks who "she" is: the single frozen system prompt becomes a composed personality — a base self, a passing mood, a tone — so the character can finally react to the day she is having.