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
# 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.
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.
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.
# 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.
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
- 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.
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.