Jarvis · Chapter 7

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.

Goal

Save the conversation to disk so closing the terminal does not wipe its memory.

about 25 minutes

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

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

✅ Checkpoint

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.

+150 XP