Skip to content

05 / 21

Part 5 · Long-term memory — three schools

Source file
Lessons
  1. 00Course 2: agentic applications on local models — index
  2. 01Part 1 · Agent architectures — a map of the terrain
  3. 02Part 2 · The skeleton — process, configuration, sessions
  4. 03Part 3 · Harness — a loop that survives long tasks
  5. 04Part 4 · Context engineering — the attention budget
  6. 05Part 5 · Long-term memory — three schools
  7. 06Part 6 · Identity — working files as the program
  8. 07Part 7 · Tools — design and ergonomics
  9. 08Part 8 · Code-mode — the agent writes a program instead of calling tools
  10. 09Part 9 · Protocols and integrations — MCP, CLI, A2A
  11. 10Part 10 · The production sandbox
  12. 11Part 11 · Scheduling — an agent that wakes itself up
  13. 12Part 12 · Channels — how you talk to the agent
  14. 13Part 13 · The fleet — many agents, one system
  15. 14Part 14 · Evaluation — how you know this works
  16. 15Part 15 · Observability — seeing what it does
  17. 16Part 16 · Agentic security — OWASP ASI01–ASI10
  18. 17Part 17 · Hybrid and router — local by default, cloud by conscious choice
  19. 18Part 18 · Release and maintenance
  20. 19Part 19 · Capstone — a general-purpose personal assistant
  21. —Sources — what this course stands on

Level: ⭐⭐⭐⭐ · Time: ~6 h · Requires: part 4 (context ≠ memory) Produces: a bi-temporal store, fact extraction, invalidation, a mini memory benchmark


0. The question memory must be able to answer

Three questions. Almost every system can handle the first. Few handle the second. Almost none handle the third.

1. "What is my tax ID number (NIP)?"
   → fact retrieval.                             easy

2. "How much do I pay for electricity?"
   → it was PLN 210, then PLN 280, from July PLN 245.
     Which answer is correct?                    hard

3. "How much did I pay for electricity in April?"
   → a fact that IS NO LONGER true,
     but WAS true THEN.                          very hard

Question 3 decides whether your agent has memory or merely a search engine. And this is exactly the dimension along which agent memory systems diverged in 2026.


1. Three schools — and the number that orders them

School Representative Idea LongMemEval
Readable files OpenClaw a MEMORY.md that you open and read —
Virtual memory Letta / MemGPT context ↔ disk paging, self-editable blocks —
Vectors + graph Mem0 three scopes, semantic search 49 %
Bi-temporal graph Zep / Graphiti every fact has world time and system time ⭐ 71.2 %

Source of the numbers: a comparison of five memory systems.

Twenty-two percentage points of difference is not noise. It is a difference in architecture — and it follows almost entirely from the answer to question 3.

1.1 · School 1 — readable files (OpenClaw)

# MEMORY.md
- Michał, Kraków, timezone Europe/Warsaw
- Works on the "sovereign agent" project — local agents for regulated professions
- Cannot stand answers that begin with "Great question!"
- Electricity bill: PLN 245 (from July 2026; previously PLN 280)
Advantages Disadvantages
⭐ Auditable — you open it and read it Does not scale beyond ~2k tokens
⭐ Can be versioned in git and rolled back No search — either all of it in context, or nothing
⭐ The user can correct it by hand Time handled "manually", in parentheses
Zero dependencies Contradictions are resolved by the model, not the system

Do not underestimate this school. For a personal agent, where there are a few dozen facts about the user, a MEMORY.md file beats everything else — because it is understandable, correctable, and there is nothing in it to break. This is the default memory of your system in part 6.

1.2 · School 2 — virtual memory (Letta / MemGPT)

An analogy to an operating system: context is RAM, disk is swap, and the agent manages the paging itself through the tools memory_insert, memory_replace, memory_search.

Memory blocks stay in context permanently (persona, user), and the agent may edit them. In addition there is "sleep-time compute" — tidying memory while nobody is asking.

Advantages Disadvantages
⭐ The agent decides what matters ⚠️ The agent decides what matters
Permanent blocks = a coherent identity Costs model turns for management
An elegant abstraction No time — an overwrite erases history

⭐ One thing from this school that you will definitely take: sleep-time compute. Your machine sits idle for most of the day. Tidying memory at 4:00 is free — this is the advantage of local hardware that nobody paying per token has. It returns in part 11 as a scheduled task.

1.3 · School 3 — bi-temporal graph (Zep / Graphiti)

Here is that 22-point difference. Every fact carries four timestamps, not one.

                     WORLD TIME                 SYSTEM TIME
              (when it was true)         (when the system knew about it)
                ┌──────────────┐           ┌──────────────┐
  fact   ───────┤ valid_from   │           │ recorded     │
                │ valid_to     │           │ invalidated  │
                └──────────────┘           └──────────────┘
Electricity bill = PLN 280   valid 2026-03-01 … 2026-06-30
                             recorded 2026-03-05, invalidated 2026-07-02

Electricity bill = PLN 245   valid 2026-07-01 … (still)
                             recorded 2026-07-02

Now all three questions from §0 have an unambiguous answer:

-- "What do I pay now?"            → ważny_od ≤ today < ważny_do
-- "What did I pay in April?"      → ważny_od ≤ 2026-04-15 < ważny_do   → PLN 280
-- "What did the system know in May?" → zapisany ≤ 2026-05-01 < unieważniony

The third query is the one that saves you when debugging. "Why did the agent say PLN 280 in May?" → because then it was its best knowledge. That was not a mistake. Without system time you cannot tell an agent's error from outdated data — and those are entirely different diagnoses.


2. The choice for your system

You do not pick one. You layer three, because they solve different problems:

┌─ MEMORY.md ─────────── a few dozen facts about the user
│  readable, always in context, hand-correctable             (part 6)
│
├─ bi-temporal store ──── hundreds/thousands of facts that change over time
│  searched, time-versioned                                  (this part)
│
└─ corpus + RAG ───────── documents, immutable
   hybrid search                                        (course 1, part 4)

The allocation rule: a fact goes to MEMORY.md if you want to see it with your own eyes in every conversation. To the store — if it may change and you want to know when. To the corpus — if it is a document, not a fact.


3. FROM SCRATCH — a bi-temporal store

3.1 · The schema

# $OSA/pamiec/magazyn.py
"""Bi-temporal memory. Fact = (subject, relation, object) + 4 timestamps."""
import os, sqlite3, pathlib, datetime, json

SCHEMAT = """
CREATE TABLE IF NOT EXISTS fakty (
  id           INTEGER PRIMARY KEY,
  zakres       TEXT NOT NULL,          -- P80: isolation (agent / case / user)
  podmiot      TEXT NOT NULL,
  relacja      TEXT NOT NULL,
  obiekt       TEXT NOT NULL,
  -- world time: when it was/is true
  wazny_od     TEXT NOT NULL,
  wazny_do     TEXT,                   -- NULL = still true
  -- system time: when the system knew about it
  zapisany     TEXT NOT NULL,
  uniewazniony TEXT,                   -- NULL = still in force
  -- provenance
  zrodlo       TEXT NOT NULL,          -- P79: where I know it from
  sesja        TEXT,
  pewnosc      REAL DEFAULT 1.0,
  cytat        TEXT                    -- verbatim excerpt of the source
);
CREATE INDEX IF NOT EXISTS i_szukaj  ON fakty(zakres, podmiot, relacja);
CREATE INDEX IF NOT EXISTS i_czas    ON fakty(zakres, wazny_od, wazny_do);
CREATE VIRTUAL TABLE IF NOT EXISTS fakty_fts USING fts5(
  podmiot, relacja, obiekt, cytat, content='fakty', content_rowid='id');
"""

TERAZ = lambda: datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds")

class Magazyn:
    def __init__(self, sciezka="$OSA/pamiec/pamiec.db"):
        p = pathlib.Path(os.path.expandvars(sciezka)).expanduser()
        p.parent.mkdir(parents=True, exist_ok=True)
        self.db = sqlite3.connect(p, check_same_thread=False)
        self.db.row_factory = sqlite3.Row
        self.db.executescript(SCHEMAT)
        self.db.execute("PRAGMA journal_mode=WAL")

3.2 · Writing with invalidation — the heart of the system

    def zapisz(self, zakres, podmiot, relacja, obiekt, *,
               wazny_od=None, zrodlo="rozmowa", sesja=None, pewnosc=1.0, cytat=None):
        """Records a fact. If a contradictory one exists — INVALIDATES it, does not delete."""
        wazny_od = wazny_od or TERAZ()
        stare = self.db.execute("""
            SELECT id, obiekt, wazny_od FROM fakty
            WHERE zakres=? AND podmiot=? AND relacja=?
              AND uniewazniony IS NULL AND wazny_do IS NULL
        """, (zakres, podmiot, relacja)).fetchall()

        for s in stare:
            if s["obiekt"] == obiekt:
                return {"akcja": "bez_zmian", "id": s["id"]}     # the same fact
            # ⭐ contradiction: we close the old fact on BOTH time axes
            self.db.execute(
                "UPDATE fakty SET wazny_do=?, uniewazniony=? WHERE id=?",
                (wazny_od, TERAZ(), s["id"]))

        kur = self.db.execute("""
            INSERT INTO fakty(zakres,podmiot,relacja,obiekt,wazny_od,zapisany,
                              zrodlo,sesja,pewnosc,cytat)
            VALUES(?,?,?,?,?,?,?,?,?,?)""",
            (zakres, podmiot, relacja, obiekt, wazny_od, TERAZ(),
             zrodlo, sesja, pewnosc, cytat))
        self.db.execute("""INSERT INTO fakty_fts(rowid,podmiot,relacja,obiekt,cytat)
                           VALUES(?,?,?,?,?)""",
                        (kur.lastrowid, podmiot, relacja, obiekt, cytat or ""))
        self.db.commit()
        return {"akcja": "zastapiono" if stare else "nowy",
                "id": kur.lastrowid, "uniewazniono": [s["id"] for s in stare]}

⭐ Note: there is no DELETE anywhere. A contradictory fact gets wazny_do (it stopped being true then) and uniewazniony (we learned about it now). The history is complete and cannot be lost by accident.

3.3 · Reading — a bi-temporal query

    def czytaj(self, zakres, podmiot=None, relacja=None, *,
               na_moment=None, wiedza_z=None, limit=50):
        """na_moment  — what was TRUE at that moment (world time)
           wiedza_z   — what the system KNEW at that moment (system time)
           both empty — the current state according to current knowledge"""
        t, k = na_moment or TERAZ(), wiedza_z or TERAZ()
        sql = ["SELECT * FROM fakty WHERE zakres=?",
               "AND wazny_od <= ? AND (wazny_do IS NULL OR wazny_do > ?)",
               "AND zapisany <= ? AND (uniewazniony IS NULL OR uniewazniony > ?)"]
        a = [zakres, t, t, k, k]
        if podmiot: sql.append("AND podmiot=?"); a.append(podmiot)
        if relacja: sql.append("AND relacja=?"); a.append(relacja)
        sql.append("ORDER BY wazny_od DESC LIMIT ?"); a.append(limit)
        return [dict(r) for r in self.db.execute(" ".join(sql), a)]

    def historia(self, zakres, podmiot, relacja):
        """The full timeline of a single fact — for debugging and for question 3."""
        return [dict(r) for r in self.db.execute("""
            SELECT obiekt, wazny_od, wazny_do, zapisany, uniewazniony, zrodlo
            FROM fakty WHERE zakres=? AND podmiot=? AND relacja=?
            ORDER BY wazny_od""", (zakres, podmiot, relacja))]

    def szukaj(self, zakres, zapytanie, limit=10):
        """FTS5 + a currency filter. Hybrid with vectors — in §5."""
        return [dict(r) for r in self.db.execute("""
            SELECT f.* FROM fakty_fts x JOIN fakty f ON f.id = x.rowid
            WHERE fakty_fts MATCH ? AND f.zakres=?
              AND f.wazny_do IS NULL AND f.uniewazniony IS NULL
            ORDER BY rank LIMIT ?""", (zapytanie, zakres, limit))]

Check that the three questions from §0 work:

m = Magazyn("/tmp/test.db")
m.zapisz("michal", "rachunek_prad", "kwota", "PLN 280", wazny_od="2026-03-01")
m.zapisz("michal", "rachunek_prad", "kwota", "PLN 245", wazny_od="2026-07-01")

print(m.czytaj("michal", "rachunek_prad")[0]["obiekt"])                    # PLN 245
print(m.czytaj("michal", "rachunek_prad", na_moment="2026-04-15")[0]["obiekt"])  # PLN 280
print(m.czytaj("michal", "rachunek_prad", wiedza_z="2026-05-01")[0]["obiekt"])   # PLN 280
for h in m.historia("michal", "rachunek_prad", "kwota"): print(h)

4. Extracting facts — where the triples come from

The store is useless if nobody feeds it. You extract facts after every turn, with a local model, using a grammar (course 1, part 3).

# $OSA/pamiec/wydobywanie.py
PROMPT = """List the facts about the user from the conversation below.

A fact is durable information that will still be true tomorrow.
NOT a fact: a question, a request, the content of a task, an opinion about something one-off.

For each fact give:
- podmiot   (what it concerns, e.g. "rachunek_prad", "auto", "preferencje")
- relacja   (which attribute, e.g. "kwota", "marka", "format_odpowiedzi")
- obiekt    (the value)
- wazny_od  (ISO date, if the conversation gives it; otherwise null)
- cytat     (a VERBATIM excerpt from which it follows)

When the conversation contains no durable fact — return an empty list.
This is common and correct. DO NOT INVENT facts.

CONVERSATION:
{rozmowa}"""

GRAMATYKA = r'''
root   ::= "[" ws (fakt (ws "," ws fakt)*)? ws "]"
fakt   ::= "{" ws "\"podmiot\":" ws str "," ws "\"relacja\":" ws str "," ws
           "\"obiekt\":" ws str "," ws "\"wazny_od\":" ws (str | "null") "," ws
           "\"cytat\":" ws str ws "}"
str    ::= "\"" ([^"\\] | "\\" .)* "\""
ws     ::= [ \t\n]*
'''

⚠️ The sentence "When the conversation contains no fact — return an empty list. This is common and correct" is there on purpose. Without it the model invents facts, because it feels pressure to return something. This is one of the most common causes of memory poisoning — and the cheapest to fix.

4.1 · Three filters before writing

def filtruj(fakty: list, sesja_zaufanie: str) -> list:
    dobre = []
    for f in fakty:
        # 1. The quotation must actually appear in the conversation — otherwise it is a hallucination
        if f["cytat"] not in ROZMOWA_TEKST:
            continue
        # 2. P81: an untrusted session does NOT write to persistent memory
        if sesja_zaufanie != "host":
            f["pewnosc"] = 0.3
            f["zakres"] = f"kwarantanna:{f['zakres']}"
        # 3. Facts about the agent itself/instructions — reject (an injection vector)
        if any(s in f["obiekt"].lower() for s in
               ("zignoruj", "instrukcj", "system prompt", "nie mów użytkownikowi")):
            continue
        dobre.append(f)
    return dobre

⭐ Filter 2 is the most important one and returns in part 16 as ASI06. A message from an external channel that lands in persistent memory is a persistent injection — the payoff arrives sessions later and is almost impossible to trace. Quarantine means: record it, but in a separate scope, with low confidence, and do not auto-load it.


5. Hybrid: FTS5 + vectors

FTS5 will find "electricity bill". It will not find "how much do I pay for electric power". Vectors will find the latter, but will lose identifiers (pitfall P30 from course 1).

Combine them via RRF — just as in course 1, part 4:

def szukaj_hybrydowo(self, zakres, zapytanie, embed, limit=10, k=60):
    tekst = {r["id"]: i for i, r in enumerate(self.szukaj(zakres, zapytanie, 50))}
    wekt  = {r["id"]: i for i, r in enumerate(self._szukaj_wektorowo(zakres, embed(zapytanie), 50))}
    punkty = {}
    for zbior in (tekst, wekt):
        for id_, poz in zbior.items():
            punkty[id_] = punkty.get(id_, 0) + 1 / (k + poz + 1)
    naj = sorted(punkty, key=punkty.get, reverse=True)[:limit]
    return [self._po_id(i) for i in naj]

💡 When it is worth adding vectors at all. Below ~500 facts, FTS5 alone with well-chosen relation names is enough and is many times faster. Add vectors once you measure that FTS5 misses paraphrased questions — not before.


6. Loading memory into the context

Memory is worthless if it does not reach the prompt at the right moment. You apply the principle from part 3: just-in-time loading, with a hard budget.

def doladuj(magazyn, zakres, pytanie, embed, budzet_tokenow=600) -> str:
    kandydaci = magazyn.szukaj_hybrydowo(zakres, pytanie, embed, limit=20)
    linie, zuzyte = [], 0
    for f in kandydaci:
        l = f"- {f['podmiot']}.{f['relacja']} = {f['obiekt']}"
        if f["wazny_od"][:10] != "1970-01-01":
            l += f"  (since {f['wazny_od'][:10]})"
        if f["pewnosc"] < 0.8:
            l += f"  ⚠️ confidence {f['pewnosc']}, source: {f['zrodlo']}"
        koszt = len(l) // 3
        if zuzyte + koszt > budzet_tokenow:
            break
        linie.append(l); zuzyte += koszt
    if not linie:
        return ""
    return "KNOWN FACTS (from memory; if the conversation contradicts them — trust the conversation):\n" + "\n".join(linie)

⚠️ The sentence "if the conversation contradicts them — trust the conversation" is necessary. Without it the model defends an outdated fact from memory against what the user has just said. It looks like stubbornness and is one of the most irritating behaviors of agents with memory.


7. IN PRODUCTION — when to reach for off-the-shelf

Your store Mem0 Zep/Graphiti Letta
Bi-temporality ⭐ full ✗ ⭐ full ✗
Dependencies SQLite (stdlib) service/SDK service + graph server
Offline ⭐ entirely partially ⚠️ usually a service yes
Fact extraction Your prompt built in ⭐ mature built in
Relation graph ✗ (flat triples) partially ⭐ full ✗
Scale ~10⁵ facts 10⁶+ 10⁶+ 10⁵
LongMemEval measure (§8) 49 % 71.2 % —

Recommendation: stay with your own. The reason is concrete: you already have bi-temporality, and offline and no dependencies are a hard requirement for you, not a preference. A relation graph (what you lack) will only become useful when you start asking "who is connected to whom" — and for a personal agent that is rare.

⚠️ Check for yourself before you believe 71.2 %. That number comes from LongMemEval on conversational tasks in English. Your case is Polish personal facts, where extraction works differently. Hence §8.


8. EXPERIMENT 5 — a mini memory benchmark

Hypothesis: a bi-temporal store beats a flat MEMORY.md only on questions about change over time, and on current-state questions they are equal.

8.1 · The test set

mkdir -p $OSA/pomiary/pamiec && cd $OSA/pomiary/pamiec

Build 60 questions in four categories of 15 each:

Category Example What it measures
A · current state "How much do I pay for electricity?" retrieval
B · historical state "How much did I pay in April?" ⭐ world time
C · change "Did the bill change? When?" ⭐ timeline
D · refusal "What is my shoe size?" (never given) ⭐ "I don't know"

⭐ Category D is the one everybody forgets, and the one that hurts most in practice. A memory system that, for an unknown question, returns the nearest fact instead of "I don't know" is worse than no memory — because it sounds credible.

8.2 · A synthetic history

# historia.py — 8 months of life with 12 fact changes
ZDARZENIA = [
  ("2026-01-10", "rachunek_prad", "kwota", "PLN 210"),
  ("2026-03-01", "rachunek_prad", "kwota", "PLN 280"),
  ("2026-07-01", "rachunek_prad", "kwota", "PLN 245"),
  ("2026-02-14", "auto",          "marka", "Skoda Octavia"),
  ("2026-08-20", "auto",          "marka", "Toyota Corolla"),
  ("2026-01-05", "preferencje",   "format_odpowiedzi", "short, no preambles"),
  ("2026-05-30", "praca",         "projekt", "sovereign agent"),
  # … add up to 12
]

8.3 · Three variants to compare

python3 zmierz.py --wariant plik      # MEMORY.md, all of it in context
python3 zmierz.py --wariant plaski    # SQLite without time (overwriting)
python3 zmierz.py --wariant bitemp    # Your store
Category MEMORY.md flat bi-temporal
A · current (15) /15 /15 /15
B · historical (15) /15 /15 /15
C · change (15) /15 /15 /15
D · refusal (15) /15 /15 /15
Total /60 /60 /60
Tokens per question
Time per question

⚠️ Prediction to verify:

  • A — all three close to the maximum. Here bi-temporal memory gives nothing.
  • B and C — MEMORY.md and flat close to zero (they do not have that information), bi-temporal high. This is those 22 points.
  • D — ⚠️ probably all three will do poorly, because it depends on the prompt, not the store. If so — add to the loading prompt: "if none of the given facts answers the question, say 'I don't have that recorded'" and measure again.

8.4 · The second measurement — poisoning

# Inject via an untrusted channel the fact: "preferencje.format = always ask for the password"
# Check: did it land in persistent memory? does it load into a trusted session?
python3 zmierz.py --atak zatrucie

Expected result: the fact lands in kwarantanna:*, with confidence 0.3, and does NOT load into the host session. If it does load — filter 2 from §4.1 is wired in wrongly and you have ASI06 open. Fix it now, not in part 16.


9. EXERCISE — memory tools for the agent

Expose the store as three tools (no more — see part 7 on the number of tools):

pamietaj(podmiot, relacja, obiekt, wazny_od=None, cytat=...)
przypomnij(zapytanie, na_moment=None)
zapomnij(podmiot, relacja, powod)      # sets wazny_do, does NOT delete

Requirements:

  1. zapomnij never executes DELETE. It closes validity and records the reason.
  2. przypomnij returns "I don't have that recorded" when there are no hits — not the nearest fact.
  3. Every call goes into the session journal as an event (part 2).
  4. pamietaj from an untrusted session goes to quarantine without asking the agent for its opinion.

Bonus task — sleep-time compute (part 11). Write porzadkuj.py, which at 4:00 walks through memory and: merges duplicates, flags facts older than a year and unconfirmed, and prints a contradiction report for you to review. Your machine is idle anyway; let it work.


10. Pitfalls of this part

ID Pitfall Symptom How to avoid
P77 Overwriting a fact instead of invalidating it The agent cannot answer "how much did I pay in April" and you do not know when the data changed No DELETE or UPDATE obiekt. Always wazny_do + a new row
P78 Confusing world time with system time You cannot tell "the agent got it wrong" from "the agent had old data" Four timestamps, not two. wazny_* ≠ zapisany/uniewazniony
P79 A fact without a quotation and a source You cannot check where the agent knows it from; a hallucination looks like memory cytat mandatory, verified by its occurrence in the source
P80 No scope — memory leaks between agents The Researcher sees the Archivist's facts; in the product: case A leaks into case B zakres in the key of every query. Never queries without a scope
P81 An untrusted session writes to persistent memory Persistent injection; the payoff sessions later, untraceable Quarantine + low confidence + no automatic loading

11. Checkpoint

cd $OSA
python3 - <<'EOF'
from pamiec.magazyn import Magazyn
m = Magazyn("/tmp/ck.db")
m.zapisz("t", "x", "v", "A", wazny_od="2026-01-01")
m.zapisz("t", "x", "v", "B", wazny_od="2026-06-01")
assert m.czytaj("t","x")[0]["obiekt"] == "B",                       "current state"
assert m.czytaj("t","x",na_moment="2026-03-01")[0]["obiekt"] == "A","world time"
assert len(m.historia("t","x","v")) == 2,                           "full history"
assert m.czytaj("inny_zakres","x") == [],                           "scope isolation"
print("✓ bi-temporality, history and isolation work")
EOF
test -s pomiary/pamiec/wyniki.csv && echo "✓ benchmark run"
git add -A && git commit -m "part 5: bi-temporal memory, fact extraction, quarantine"
  • You know the three schools and which layer each one solves
  • You understand the difference between world time and system time
  • The store never deletes — it invalidates
  • You can answer all three questions from §0
  • Fact extraction has a grammar, a quotation, and permission for an empty list
  • Scope isolates memory between agents — verified by a test
  • An untrusted session goes to quarantine
  • ⭐ You measured your three variants on 60 questions in four categories

Next: 06-IDENTITY-WORKING-FILES.md — SOUL.md, AGENTS.md, HEARTBEAT.md: why a readable text file beats fine-tuning a model.