Remembering Between Runs 💾
Chapter 4 gave it memory that lasts until you quit. This gives it memory that survives, using nothing more exotic than a JSON file, plus one trick that stops a crash destroying it.
Save the conversation to disk so closing the terminal does not wipe its memory.
about 25 minutesMemory that outlives the process
Your list of messages lives in RAM, so it dies with the program. Since the list is plain dictionaries of strings, saving it is exactly the JSON work from Lesson 23.
import json
from pathlib import Path
HISTORY_FILE = Path("history.json")
def save_history(messages, path=HISTORY_FILE):
"""Write the conversation to disk as JSON."""
path.write_text(json.dumps(messages, indent=2), encoding="utf-8")
def load_history(path=HISTORY_FILE):
"""Read the conversation back, or start fresh if there is nothing there."""
if not path.exists():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
# a truncated or hand-edited file should not kill the program
print("History file was unreadable, starting fresh.")
return []
if not isinstance(data, list):
return []
return data
# demonstrate with a temporary file
demo = Path("demo-history.json")
save_history([
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "Hi there."},
], demo)
loaded = load_history(demo)
print("saved and loaded", len(loaded), "messages")
print("first:", loaded[0]["role"], "->", loaded[0]["content"])
demo.unlink()
print("file removed:", not demo.exists())
saved and loaded 2 messages
first: user -> hello
file removed: True
Two things in there are deliberate and worth keeping in your own version.
The try around json.loads means a corrupted or half-written
file costs you your history but not your program. Reading a file you wrote yourself feels
like it cannot fail, right up until the day you Ctrl-C mid-write.
The isinstance(data, list) check means a file containing valid JSON of the
wrong shape (say, {}) does not blow up later in a confusing place. Validate
at the boundary, not three functions deeper.
Wire it into the loop
Two changes to chat.py: load at the top, save after each exchange.
- Replace
messages = []withmessages = load_history(). - After appending the assistant's reply, call
save_history(messages).
Saving every turn rather than on exit is deliberate: exit is precisely when crashes happen, and a save that only runs on a clean shutdown is a save that eventually does not run.
The rename trick
Writing directly to history.json has a real failure mode. If the program
dies halfway through the write, the file is truncated and the whole history is gone. The
fix is standard practice and costs two lines:
import json
from pathlib import Path
def save_atomically(data, path):
"""Write to a temporary file, then rename it into place.
A rename is atomic on every system you care about. Without this, a
crash or Ctrl-C halfway through writing leaves a half-written file
and your entire history is gone. With it, the old file survives
untouched until the new one is complete.
"""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_suffix(path.suffix + ".tmp")
temp.write_text(json.dumps(data, indent=2), encoding="utf-8")
temp.replace(path) # atomic swap
target = Path("atomic-demo.json")
save_atomically([{"role": "user", "content": "safe"}], target)
print("written:", target.exists())
print("no leftover temp:", not target.with_suffix(".json.tmp").exists())
print("contents:", json.loads(target.read_text())[0]["content"])
target.unlink()
written: True
no leftover temp: True
contents: safe
Write to a temporary file, then rename it over the real one. Renaming is atomic: it either happened or it did not, with no in-between state. Until the moment it succeeds, the previous good file is still sitting there intact.
One file per day
A single ever-growing file gets unwieldy and makes it hard to say "what did I ask it on Tuesday". Splitting by date is trivial:
from datetime import date
from pathlib import Path
def session_path(folder="sessions", today=None):
"""One file per day, so history is browsable instead of one huge blob."""
day = (today or date.today()).isoformat()
return Path(folder) / f"{day}.json"
print(session_path(today=date(2026, 8, 17)))
print(session_path(folder="chats", today=date(2026, 1, 2)))
sessions/2026-08-17.json
chats/2026-01-02.json
Combine that with the trimming from chapter 4 and you get a sensible arrangement: the file on disk keeps everything for the day, while only the most recent exchanges get sent to the API. Your archive and your context window are different problems and should not share a limit.
Remember what is in that file
It is a plain-text record of everything you have said to your assistant. That is
exactly why .gitignore in chapter 2 lists history.json. If you
ever publish this project, publish the code and not your diary.
If it went wrong
JSONDecodeErroron startup The file is truncated, probably from a crash mid-write. Delete it and adopt the rename trick.TypeError: Object of type ... is not JSON serializableSomething that is not a plain string or dict got into the list. Store the reply text, not the response object.- It remembers far too much and answers get expensive You are sending
the whole file. Load everything, but send only
trim(messages).
Talk to your assistant, quit with Ctrl-C, start it again, and ask it what you were discussing. It knows. A history.json exists and is readable JSON.