Persistent Agent with Memory
Buildable now. The repo, the spec and the deployment steps are live — the written walkthrough for this one is still being drafted.
A CLI chatbot that stores conversation history and key facts in SQLite and recalls them across sessions — your name, preferences, and past context are available every time you return. Combined with the tool-calling loop from the last project, this is the point where your agent stops being a single-session toy and starts behaving like a real product.
Memory/state handling is what separates a stateless chatbot demo from a real assistant product — directly relevant to AI Engineer roles building user-facing assistants.
Path: 1
Position: 7 of 12
Difficulty: 🟢 Local — one API key, no Docker, no cloud
Estimated time: 3–4 hours
AWS cost: None
Agent Pickup Instructions
This spec is self-contained. Build this project without reading any other spec file.
mkdir -p path-1/p1-07-persistent-agent
cd path-1/p1-07-persistent-agent
pytest tests/ -v
python src/main.py # start a session, say "My name is Alex"
# Press Ctrl-C to end, re-run to verify Alex is remembered
python src/main.py --show-memory # inspect stored facts
python src/main.py --clear-memory # wipe the slate
Done when:
-
pytest tests/ -v→ minimum 5 tests, all green - Messages persist to SQLite between runs
- Agent recalls a name or preference mentioned in a previous session
-
--show-memoryprints all stored facts as a table -
--clear-memorywipes history and facts with a confirmation prompt - Memory hit rate logged on exit
- No file in
src/exceeds 200 lines
What this project is
An agent that remembers you across sessions. It stores conversation history and extracted facts in SQLite. When you restart it, it loads prior history and injects relevant facts into the system prompt. The agent extracts memorable facts automatically ("the user's name is Alex", "prefers concise answers") and uses them to personalise subsequent responses. This is the bridge from a stateless CLI tool to something worth serving over HTTP — because an agent that remembers users is genuinely useful.
What the learner achieves
"I built a persistent agent that stores conversation history and extracts key facts to SQLite, recalls them across sessions, and logs a memory hit rate — the fraction of responses where prior memory was actually relevant."
Folder structure
p1-07-persistent-agent/
├── README.md
├── GUIDE.md
├── .env.example
├── requirements.txt
├── src/
│ ├── main.py ← CLI entry point
│ ├── llm.py ← provider-agnostic LLM wrapper
│ ├── memory.py ← SQLite memory store
│ └── agent.py ← agent loop with memory injection
└── tests/
└── test_memory.py
.env.example
LLM_PROVIDER=anthropic
LLM_API_KEY=
LLM_MODEL=
# SQLite database path
MEMORY_DB_PATH=./agent_memory.db
# Number of recent messages to load as active context
RECENT_MESSAGES_LIMIT=20
# Number of top facts to inject into system prompt
TOP_FACTS_LIMIT=10
requirements.txt
anthropic==0.40.0
openai==1.58.0
ollama==0.4.4
python-dotenv==1.0.1
src/ — what to implement
src/llm.py
get_completion(messages: list[dict], system: str = "") -> str
- Standard router: anthropic / openai / ollama
- Raises
ValueErrorfor unknown provider
src/memory.py
Handles all SQLite I/O. Database path from MEMORY_DB_PATH env var.
Schema (create on first run if not exists):
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- 'user' or 'assistant'
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS facts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fact_key TEXT NOT NULL, -- e.g. "user_name", "prefers_language"
fact_value TEXT NOT NULL, -- e.g. "Alex", "Python"
confidence REAL NOT NULL, -- 0.0–1.0, LLM-assessed confidence
source_message_id INTEGER, -- which message this was extracted from
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(fact_key) -- one value per key; INSERT OR REPLACE
);
MemoryStore class:
__init__(self, db_path: str) — opens SQLite connection, creates tables if needed
save_message(self, session_id: str, role: str, content: str) -> int — insert, return rowid
load_recent_messages(self, session_id: str, limit: int) -> list[dict] — return [{"role": ..., "content": ...}, ...] ordered by created_at ASC
save_fact(self, key: str, value: str, confidence: float, source_id: int = None) — INSERT OR REPLACE
load_all_facts(self) -> list[dict] — return [{"key": ..., "value": ..., "confidence": ...}, ...]
load_top_facts(self, limit: int) -> list[dict] — return highest-confidence facts, limit N
clear_all(self) — DELETE FROM messages; DELETE FROM facts
get_message_count(self) -> int — total stored messages
get_fact_count(self) -> int — total stored facts
src/agent.py
extract_facts(message_content: str, role: str) -> list[dict]
- Only extract from
role == "user"messages - System prompt: "Extract memorable facts from this user message. Return JSON array of objects:
[{\"key\": \"fact_category\", \"value\": \"fact_value\", \"confidence\": 0.0-1.0}]. Examples: user_name, user_role, preferred_language, project_name. Return empty array[]if no memorable facts present. Return ONLY JSON." - Parse result; return list of
{"key": str, "value": str, "confidence": float} - On parse failure: return
[](never raise)
build_system_prompt(facts: list[dict]) -> str
- Base: "You are a helpful assistant with memory of past conversations."
- If facts non-empty: append "Known facts about the user:\n" + each fact as
- {key}: {value} (confidence: {confidence:.0%}) - Return complete system prompt
run_session(memory: MemoryStore, session_id: str) -> dict
- Returns
{"turns": N, "facts_extracted": N, "memory_hits": N} - Load
RECENT_MESSAGES_LIMITmessages from memory - Load
TOP_FACTS_LIMITfacts from memory - Build system prompt with facts
- Print memory summary:
📚 Loaded {message_count} prior messages | {fact_count} known facts - Conversation loop:
user_input = input("You: ")msg_id = memory.save_message(session_id, "user", user_input)- Extract facts from user_input; save each to memory
- Build full message list (loaded history + current user message)
- Call LLM with system prompt + messages
- Print
Assistant: {response} memory.save_message(session_id, "assistant", response)- Track "memory hit": if any fact appears in the response (simple substring check), increment hit counter
- On
quit/ Ctrl-C: print stats and return result
src/main.py
CLI: python src/main.py [--show-memory] [--clear-memory] [--session-id <id>]
- Default session_id:
"default"(single-user local tool) --show-memory: print facts table and message count, exit--clear-memory: prompt "Clear all memory? (yes/no): ", require exact "yes", callmemory.clear_all(), exit- Normal mode: call
run_session() - On exit: print
Session ended: {turns} turns | {facts_extracted} new facts extracted | Memory hits: {memory_hits}/{turns} ({pct:.0%})
tests/ — what to test
File: tests/test_memory.py — use a temporary SQLite file (tempfile.mktemp(suffix='.db')).
Test 1 — save and load messages round-trips: Save 3 messages. Load with limit=10. Assert all 3 returned in order.
Test 2 — load respects limit: Save 10 messages. Load with limit=3. Assert only 3 returned (most recent 3).
Test 3 — save_fact and load_top_facts:
Save fact ("user_name", "Alice", 0.95) and ("user_role", "engineer", 0.7). Load top facts with limit=2. Assert user_name fact is in results.
Test 4 — UNIQUE constraint replaces existing fact:
Save ("user_name", "Alice", 0.9) then ("user_name", "Bob", 0.95). Load facts. Assert only one user_name fact exists and its value is "Bob".
Test 5 — clear_all removes all records:
Save 3 messages and 2 facts. Call clear_all(). Assert get_message_count() == 0 and get_fact_count() == 0.
Test 6 — extract_facts returns empty list on non-factual message:
Mock llm.get_completion to return "[]". Call extract_facts("What time is it?", "user"). Assert result is [].
README.md content
# Persistent Agent with Memory
A CLI chatbot that remembers you. It stores conversation history and key facts
in SQLite and recalls them across sessions — your name, preferences, and past
context are available every time you return.
## Setup
```bash
cd p1-07-persistent-agent
cp .env.example .env
pip install -r requirements.txt
Run
# Start chatting (creates agent_memory.db on first run)
python src/main.py
# View stored facts
python src/main.py --show-memory
# Clear all memory
python src/main.py --clear-memory
Expected output:
📚 Loaded 0 prior messages | 0 known facts
You: My name is Alex and I work in Python
Assistant: Nice to meet you, Alex! Python is a great language...
You: quit
Session ended: 1 turns | 1 new facts extracted | Memory hits: 0/1 (0%)
Next session:
📚 Loaded 2 prior messages | 1 known facts
- user_name: Alex (confidence: 95%)
You: What's my name?
Assistant: Your name is Alex — you mentioned it in our last conversation.
Session ended: 1 turns | 0 new facts extracted | Memory hits: 1/1 (100%)
Tests
pytest tests/ -v
Tests verify SQLite persistence, fact deduplication, and message limit enforcement. No API key required.
What to try next
- Add a
--session-idflag so you can run multiple independent memory sessions - Extract facts from assistant responses too (the agent learns from its own answers)
- Add a fact expiry: facts older than 30 days are downweighted in the system prompt
---
## GUIDE.md content
```markdown
# Build guide: Persistent Agent with Memory
## What you're building and why it matters
Memory is what separates a chatbot from an assistant. Every commercial AI assistant
— ChatGPT, Claude.ai, Gemini — has some form of memory. The fundamental pattern is
simple: extract facts from conversations, store them, inject them into future system
prompts. The engineering challenge is deciding what to remember, how long to keep it,
and how to inject it without blowing up the context window. This project gives you
that foundation with a real database, not an in-memory dict that disappears on restart.
## The decision that matters in this build
**Where to inject memory: system prompt or user message?** Facts injected into the
system prompt arrive with higher authority — the model treats them as persistent
context about who it is talking to. Facts injected as a prior user message can be
"argued with" by subsequent conversation. Put persistent facts (name, role, preferences)
in the system prompt. Put recent context (last conversation summary) in the message history.
## What will break
**Fact extraction will hallucinate.** If the user says "I think Python is overrated,"
the extractor might extract `preferred_language: Python`. Add a confidence score and
only inject facts with confidence >= 0.8. Low-confidence "facts" are noise.
**Loading too many messages blows up the context window.** `RECENT_MESSAGES_LIMIT=20`
is a safe default. Set it to 100 and you'll eventually hit token limits. For long-running
sessions, consider summarising old messages (the pattern from Project 4) before injection.
## How to talk about this in an interview
"I built a persistent agent that extracts named facts from user messages — name,
preferences, context — stores them in SQLite with a confidence score, and injects
the top-confidence facts into every future system prompt. I measured memory hit rate
as the fraction of responses where a prior fact appeared in the answer. It's the
pattern behind every 'personalised' AI assistant."
Failure modes to handle
| Failure | Where | How to handle |
|---|---|---|
| SQLite DB locked | memory.py |
Catch sqlite3.OperationalError, retry once, then raise with message |
| Fact extraction JSON parse failure | agent.extract_facts() |
Return [] — never raise, never block the conversation |
| Empty user input | agent.run_session() |
Skip save and LLM call, re-prompt |
--clear-memory without "yes" |
main.py |
Print "Cancelled" and exit cleanly |
| MEMORY_DB_PATH directory missing | memory.py |
Create parent directory if it doesn't exist |
The metric this project measures
Memory hit rate — fraction of assistant responses where a stored fact was used.
Logged on exit: Memory hits: {N}/{total} ({pct:.0%})
Target: >50% in sessions after the first (once facts are stored). Low rate suggests
facts are not being extracted or injected correctly.
Facts extracted — how many new facts were extracted in the current session.
Model source currency
- Anthropic model list: https://docs.anthropic.com/en/docs/about-claude/models/overview
- OpenAI model list: https://platform.openai.com/docs/models
- verifiedOn: 2026-08-07
LLM_MODELis required and has no implicit default; choose a supported model for the configured provider at setup time.
The quiz isn't written yet
The project itself is ready to build — the repo, the spec and the deployment steps are all live. What's missing is the written quiz that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.
The assignment isn't written yet
The project itself is ready to build — the repo, the spec and the deployment steps are all live. What's missing is the written assignment that goes with it, and the order those get written in is decided by which ones people actually ask for.
No spam. Unsubscribe anytime. Replies go to a real person.