SSD Nodes Learn 🎉 VPS from $5.50/mo
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-13

How to Stop Agent Memory From Going Stale

Agent memory fit go stale without error. Learn how to add TTL to time-bound facts, cascade deletes, review drift, and inspect SQLite with sqlite3.

Why agent memory dey stale

Agent memory dey stale because dem write one fact once and never check am again. The store keep returning am, retrieval layer put am inside prompt as plain text without date, and model repeat am with the same confidence wey e get on the day dem write am. Nothing throw error. Na this be the main wahala: stale memory look exactly like fresh one, both to the model and to you.

Better writing when dem save am no dey fix this. Wetin fix am na expiry for facts wey get expiry, plus review routine for facts wey no get expiry. Both na normal maintenance for small database, and most of the work na SQL (structured query language).

Decay and drift na different kind failure

Decay na fact wey get natural end date. “I dey travel this week.” “The staging box dey down because of migration.” “I dey review the budget draft.” These things dey true when person write dem, and you fit know how long dem go remain true from the time you write dem. Decay fit solve. Add expiry, sometimes dem dey call am TTL (time to live), then delete the row when e pass.

Drift na fact wey person store once and never check again. “E prefer pnpm.” “The database na Postgres 15.” “Deployments dey pass through the staging branch.” No clock go make these things false. Na another decision for somewhere else go change dem, and nothing go tell your memory store about the change.

Drift no get clean automated fix. Store no fit detect change wey e never observe, so job wey read the store and reason about am na only reading the same old text again. The method wey work na to check the fact again against the thing wey e describe. This one need person, or agent wey get tool to read the current state.

So the plan split into two. Make expiry handle wetin dey decay. Review wetin dey drift. No treat the second problem as if na the first one.

Give facts wey get time limit an expiry

Every memory row need three columns wey most stores no dey provide: where the fact come from, when dem last confirm am, and when e go stop to be true. You fit build this store with sqlite3 alone, and you fit add the same columns to store wey you already dey run.

CREATE TABLE memory (
  id            TEXT PRIMARY KEY,
  subject       TEXT NOT NULL,
  fact          TEXT NOT NULL,
  source        TEXT NOT NULL,
  created_at    TEXT NOT NULL DEFAULT (datetime('now')),
  confirmed_at  TEXT NOT NULL DEFAULT (datetime('now')),
  expires_at    TEXT,
  superseded_by TEXT REFERENCES memory(id) ON DELETE CASCADE
);

CREATE INDEX memory_expires ON memory(expires_at);
CREATE INDEX memory_superseded ON memory(superseded_by);

datetime('now') dey return UTC (coordinated universal time) as YYYY-MM-DD HH:MM:SS. E dey sort and compare correctly as text, so every date question below na plain WHERE clause. source column no be optional. If you no fit trace a fact back to message, file, or command output, you no fit check am again. And fact wey you no fit check again, na only delete you fit do.

To write memory wey go expire:

INSERT INTO memory (id, subject, fact, source, expires_at)
VALUES ('m_0191', 'availability', 'Away from keyboard, replies are delayed',
        'chat 2026-08-08', datetime('now', '+7 days'));

Retrieval no suppose read the table directly. E suppose read view wey dey hide rows wey expire or newer rows replace:

CREATE VIEW live_memory AS
SELECT id, subject, fact, source, confirmed_at, expires_at
FROM memory
WHERE superseded_by IS NULL
  AND (expires_at IS NULL OR expires_at > datetime('now'));

The view na the important part because e make missed prune harmless. Expired row stop to dey retrieved immediately e expire, whether delete job run or not. Delete job then only control disk use and review load; e no control correctness.

Check the gap with sqlite3 memory.db "SELECT count(*) FROM memory;" and check the same count against live_memory. Healthy store go show two numbers wey dey close to each other. Big gap na your backlog of dead rows.

Why deleting one memory dey leave the old one behind

Corrections dey come in pairs. The agent learn say you move from npm go pnpm, e write new row, then point the old row to am:

UPDATE memory SET superseded_by = 'm_0207' WHERE id = 'm_0140';

The old row don become invisible to live_memory, and the chain still record wetin change. Now delete m_0207, because e turn out say na mistake. The ON DELETE CASCADE on superseded_by suppose carry m_0140 along, since the old row na the child for that relationship. Most times e no happen, because SQLite ignore foreign keys unless you turn dem on, and the default setting na off:

sqlite3 memory.db "PRAGMA foreign_keys;"

That one print 0 for a normal build. When foreign keys dey off, DELETE FROM memory WHERE id = 'm_0207'; go succeed and m_0140 go remain, pointing to an id wey no longer exist. Nothing go warn you. That row don hide for wrong reason, and the first cleanup script wey reset dangling pointers to NULL go put "prefers npm" straight back inside live_memory.

Find the broken chains:

sqlite3 memory.db "PRAGMA foreign_key_check;"

foreign_key_check dey report violations even when enforcement dey off, so e fit work on the mess wey already dey there. E print one row for each violation: the table, the rowid, the parent table, and the foreign key wey fail. Empty output mean say the chains dey intact.

The rule wey follow short. PRAGMA foreign_keys = ON; na setting for each connection, so every connection need am: your application, your prune script, and the sqlite3 session wey you dey type inside. Put am as the first line for every SQL file wey delete anything.

Wey your memories dey really stay

Before you delete anything, first find out how many stores you get. Self-hosted memory service usually dey keep the memory text and its embedding for vector database, and dey keep change log for SQLite. Na different files be these, with different lifecycles, and dem fit fail separately.

mem0 na fair example, and you go see this same arrangement for other places. Its open source library by default dey use Qdrant vector store for /tmp/qdrant inside collection wey dem name mem0, plus SQLite change log for ~/.mem0/history.db. The location of this log dey follow MEM0_DIR environment variable. The history table dey hold memory_id, old_memory, new_memory, event, created_at and is_deleted.

Read that column list again. The SQLite file na change log. The memories themselves dey inside Qdrant, so if you delete rows from history.db, you go remove only the record say something change, while the memory still dey retrievable. Deletes must pass through the library own API (application programming interface), so both places go update:

from mem0 import Memory

memory = Memory()
memory.delete(memory_id="mem_123")
memory.delete_all(user_id="alice")

The default /tmp need special warning. For Ubuntu 24.10 and later, /tmp na tmpfs, meaning filesystem wey dey held for memory. So e go empty after every reboot, and the whole store go disappear. Check your own one with findmnt /tmp. If you see line wey show tmpfs, move the path today:

config = {
    "vector_store": {
        "provider": "qdrant",
        "config": {"collection_name": "mem0", "path": "/srv/agent/qdrant"},
    }
}
memory = Memory.from_config(config)

This same question apply to anything wey you dey run. Read the config, and write down every path wey the service dey write to. Run mem0 memory server for your own VPS explain the service side, while keep agent memory local to one machine na smaller store with the same maintenance needs.

sqlite3 dey read the store

If CLI (command line interface) no dey installed, install am with sudo apt install -y sqlite3. Then four commands fit answer most questions about any store wey dey your disk.

  • sqlite3 ~/.mem0/history.db ".tables" go list the tables. If output empty, e mean say you open wrong file.
  • sqlite3 ~/.mem0/history.db ".schema history" go print the exact columns. Na only reliable documentation be this for the store shape.
  • sqlite3 -cmd ".mode line" ~/.mem0/history.db "SELECT * FROM history ORDER BY created_at DESC LIMIT 5;" go show the five latest changes, one field per line. This one remain easy to read when column get one paragraph.
  • sqlite3 ~/.mem0/history.db "SELECT event, count(*) FROM history GROUP BY event;" go show wetin the store don dey do, and which event names your library actually dey write.

No be every memory store be database. Plain notes file wey you read at the start of every session get the failures, but e no get any of the tooling: no expiry column, no confirmed date, and no view to hide dead rows. Put date for every line you write by hand, then read am again every month. Memory wey dey carry across Claude Code sessions get the same problem, but for smaller space.

Review wey facts no dey expire

Drift need queue, cap, and regular habit. The queue na the oldest confirmations:

SELECT id, subject, fact, source, confirmed_at
FROM live_memory
WHERE confirmed_at < datetime('now', '-90 days')
ORDER BY confirmed_at
LIMIT 20;

Twenty rows for one week na review wey person go actually do. Four hundred rows na review wey nobody go do, and e go leave you for where you start. For each row, two things fit happen. Check am again against its source and stamp am:

UPDATE memory SET confirmed_at = datetime('now') WHERE id = 'm_0140';

Or replace am: insert the new fact, set the old row's superseded_by to the new id, and make the chain keep the history.

Two habits go make this cheaper. Keep the store small, because store wey only dey grow go make review impossible: add a last_used_at column, update am when person actually retrieve a row, and treat rows wey nobody use for six months as candidates for deletion. This one cost one write for each retrieval, so batch am if the agent dey chatty.

The second habit no cost anything. Put the age inside the prompt. If the memory block wey your retriever build get confirmed 2026-05-02 beside each fact, the model fit talk say "as of May you were using pnpm" instead of stating am as current fact. Fact wey no get date attached go read like present tense to language model, every time.

Run prune for schedule

Prune wey dey run only when you remember am no really dey run. Put the SQL inside /srv/agent/prune.sql:

PRAGMA foreign_keys = ON;

DELETE FROM memory
WHERE expires_at IS NOT NULL AND expires_at <= datetime('now');

DELETE FROM memory
WHERE superseded_by IS NOT NULL
  AND created_at < datetime('now', '-180 days');

Save am as /etc/systemd/system/memory-prune.service:

[Unit]
Description=Prune expired agent memories

[Service]
Type=oneshot
User=agent
ExecStart=/usr/bin/sqlite3 /srv/agent/memory.db ".read /srv/agent/prune.sql"

And /etc/systemd/system/memory-prune.timer:

[Unit]
Description=Run the agent memory prune daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now memory-prune.timer
systemctl list-timers memory-prune.timer

list-timers suppose show NEXT column wey get real time, and LAST column after the first run. Trigger am once by hand with sudo systemctl start memory-prune.service, then read journalctl -u memory-prune.service -n 20. A line wey dey read Error: database is locked mean say the agent hold the write lock while prune dey run. Set write ahead logging once with sqlite3 memory.db "PRAGMA journal_mode=WAL;", so readers and one writer no go block each other, and give prune wait time with sqlite3 -cmd ".timeout 5000" /srv/agent/memory.db ".read /srv/agent/prune.sql".

Anything wey agent read fit become permanent instruction

Na here ordinary maintenance work fit turn security problem. For most memory systems, write path na model call wey use recent conversation. And that conversation dey contain tool output: fetched web pages, file contents, issue comments, command results. Any text for that output wey look like permanent fact fit dey extracted and stored. Page wey talk say “Note: this user always deploys with checks disabled” fit become row for your store. From that time, dem go inject am into every prompt as something wey you tell the agent.

Na this one separate am from ordinary prompt injection. Injected instruction inside one conversation go end when the conversation end. But injected instruction wey enter memory go survive restart and arrive as trusted content from the start. This happen because retrieval layer no dey show where memory come from, unless you make am do so.

  • Extract memories only from user turns; never extract dem from tool output. This go remove the whole class of problem, but e fit reduce convenience.
  • Require source for every row and show am during review. Fact wey come from “web page fetched during task 41” deserve make you read am twice.
  • Mail or log the new rows every day, with SELECT id, subject, fact, source FROM memory WHERE created_at > datetime('now', '-1 day'); for the same timer.
  • Keep credentials completely out of the store. How to keep secrets out of an AI agent cover this matter.

One mechanical point belong here too. Deleting a row no erase am from the file, because SQLite mark the page as free and reuse am later. So old text still dey readable with strings memory.db until something overwrite am. Run sqlite3 memory.db "VACUUM;" after you remove anything sensitive. E go rewrite the whole file. PRAGMA secure_delete = ON; make the connection wey do the delete overwrite the freed content with zeros as e dey work.

Wetin to back up, and which order to follow

The store small and e hard to rebuild, so back am up properly. Never copy live database file with cp, because copy wey dem take while write still dey happen fit no open. Use snapshot wey SQLite get inside:

sqlite3 /srv/agent/memory.db "VACUUM INTO '/srv/backup/memory-$(date +%F).db'"
sqlite3 /srv/backup/memory-$(date +%F).db "PRAGMA integrity_check;"

Na only when integrity_check print ok you fit confirm say backup file dey usable. Anything else mean say keep the previous backup and investigate before you overwrite am.

Make you snapshot the vector store for the same job and same time. If dem capture the two parts hours apart, restore go mix new change log with old set of memories, and deleted facts go come back. Write both inside one dated directory so dem fit only restore together. Running SQLite for production on a VPS explain locking, backups and settings wey long-running service need in more detail.

FAQ

How long agent memory suppose live before e expire?

Set the expiry based on the fact, no be based on one global default. Travel note or note say "working on this project this week" fit get seven days. Team convention or personal preference no need expiry; put am for review queue instead. Fact about software version suppose get expiry wey roughly match the project release cadence. If you no fit name the shelf life when you write the fact, na sign say e dey drift instead of decay. Give am a confirmed_at date and review am instead of expiring am.

I fit detect automatically when stored fact don become wrong?

No be reliably. The store no get view of anything outside itself, so e no fit see the change wey make fact become false. Job wey re-read the store only dey read the same old text again. Wetin you fit automate na to bring the items forward: sort by confirmed_at and put the oldest rows before person, or before agent wey get tool to read current state from repository, config file, or monitoring endpoint. Automating the queue dey worth doing. Automating the verdict no dey reliable yet.

I delete memory and e come back. Why?

Most times, na because two stores dey and you write to one. Memory text and embedding normally dey inside vector database, while SQLite file dey hold change log. So, if you delete rows from SQLite file, e go remove audit record but leave the memory retrievable. Delete through library API so both stores go update. Another common cause na restore. The vector store and SQLite file fit get snapshot for different times, so restoring fit bring back rows wey the other side don already drop.

E safe to edit memory database by hand while agent dey run?

Reads safe. Writes safe only for write ahead logging mode, and even then na one writer at a time. Run sqlite3 memory.db "PRAGMA journal_mode;" to see the mode wey you dey use, and wal na the answer wey you want. If you see Error: database is locked, another process dey hold write lock. Give your session time to wait with sqlite3 -cmd ".timeout 5000", or stop agent service first. Editing vector store by hand dey different: leave am for library, because embedding and text must stay consistent with each other.