Memory¶
CAESAR's memory comes in five layers, each answering a different question:
- Audit log — "What happened?" Every brain decision, every
tool call, every dashboard edit lands in the
audit_logtable. The append-only timeline. Always on (ADR-0012). - Semantic recall (tool) — "What did we talk about that was
like this?" The
memory.semantic_recallLegion worker embeds pastchat.completedrows and ranks them by cosine similarity. Opt-in viaCAESAR_SEMANTIC__ENABLED=true(ADR-0010 amendment). The brain calls this explicitly when it wants a recall. - Personal facts — "What did the operator tell me?" v1.8.
A background extractor reads
chat.completedrows, pulls out durable facts, and auto-injects them into every subsequent chat's system prompt. The reason CAESAR can finally remember your dog's name (ADR-0033). - Episode summaries — "What did we discuss recently that
matters?" v1.9. A second background worker writes a 1-2
sentence summary per
chat.completedrow. Auto-recall on every chat surfaces the most relevant summaries into the system prompt — no explicit tool call required (ADR-0034). - Importance ranking — "When the inject cap fires, what gets dropped first?" v1.9. Facts and episodes both get a retrieval-time score combining freshness, confidence, and past operator interest. The lowest-importance entries fall off first; nothing is auto-deleted (ADR-0034).
This page covers facts (v1.8) and episodes + decay (v1.9). The first two layers are well-trodden and documented in their ADRs.
What v1.8 adds¶
The gap v1.8 closes:
Mon: "Hey CAESAR — by the way my dog's name is Beans."
Thu: "What should I get Beans for his birthday?"
Before v1.8, Thursday's brain run sees the user message, calls no tools, and either guesses or asks who Beans is. After v1.8:
- Monday's chat lands in
audit_logas achat.completedrow. - The
memory.extractworker polls withinCAESAR_MEMORY__FACTS_EXTRACT__INTERVAL_SECONDS(default 60s). - It calls an LLM with a fact-extraction system prompt and the
conversation transcript. The LLM returns
[{"key": "dog.name", "value": "Beans", "confidence": 0.95}]. - The result lands in
personal_facts(one row per fact, keyed bydog.name). - Thursday's
/v1/chatrequest loads all current facts, renders them as natural-language sentences, and folds them into the system prompt as a "What you've told me:" block. - Thursday's brain answers as if it remembered the whole time.
Each step audits a row so the loop is operator-visible:
| Event | When |
|---|---|
memory.fact.added |
Extractor finds a new key |
memory.fact.confirmed |
Extractor re-sees the same {key, value} (confidence ratchets up) |
memory.fact.updated |
Extractor sees the same key with a new value (overwrites) |
memory.fact.deleted |
Some non-operator path removed a fact (rare in v1.8) |
memory.fact.user_edited |
You edited via /dashboard/facts |
memory.fact.user_deleted |
You deleted via /dashboard/facts |
Setup¶
1. Pick an extraction model¶
The extractor calls the LLM gateway with task="memory_extract".
By default that hits whichever provider you configured for chat —
fine, but it means every chat in your history triggers one extra
call to that model. For homelab budgets that adds up.
Recommended: route memory_extract to a local Ollama model so
extraction is free. In your .env:
# Existing chat config — Anthropic for the foreground brain
CAESAR_LLM__PROVIDER=anthropic
CAESAR_LLM__MODEL=claude-haiku-4-5-20251001
CAESAR_LLM__ANTHROPIC__API_KEY=sk-ant-...
# Route memory extraction to a local 8B-class model
CAESAR_LLM__OLLAMA__BASE_URL=http://localhost:11434
CAESAR_LLM__TASK_ROUTING={"memory_extract":{"provider":"ollama","model":"llama3.1:8b"}}
Ollama 8B-class models are perfectly capable of "did the operator share a durable fact in this transcript" — it's a structured-output task, not a reasoning marathon. See Picking a model for the broader task-routing story.
2. Turn on extraction¶
# .env
CAESAR_MEMORY__FACTS_EXTRACT__ENABLED=true
CAESAR_MEMORY__FACTS_EXTRACT__INTERVAL_SECONDS=60 # how often to poll
CAESAR_MEMORY__FACTS_EXTRACT__BATCH_SIZE=8 # rows per poll
Restart Praetor. The background extractor starts immediately;
incoming chats are processed within ~INTERVAL_SECONDS of
completion.
3. Injection is on by default¶
When extraction is on, injection is on too. You can decouple them if you want (e.g. collect facts for the dashboard but don't change brain behaviour yet):
CAESAR_MEMORY__FACTS_INJECT__ENABLED=true # default
CAESAR_MEMORY__FACTS_INJECT__MAX_FACTS=30 # ≥0; older drop first
CAESAR_MEMORY__FACTS_INJECT__MAX_CHARS=2048 # block size cap
The cap is "first 30 facts (most recently confirmed) that fit in 2048 chars including the header." A pathological 1000-fact operator won't blow the context window — older / lower-confidence facts drop quietly.
What gets extracted (and what doesn't)¶
The extraction system prompt asks for durable personal facts: things about the operator, their household, their preferences, their environment. Not transient stuff (today's weather), not the operator's questions to CAESAR, not anything CAESAR said.
Concretely, the LLM tends to extract:
- Names:
dog.name,spouse.name,child.name - Locations:
address.city,address.country - Preferences:
preference.coffee,preference.communication_style - Routine:
schedule.work_hours,routine.morning - Work:
work.role,work.employer - Devices:
device.thermostat,home.heating_system
And it tends to skip:
- Questions ("when's my next dentist appointment?" — that's a question, not a fact about you).
- One-shot events ("we're going to the lake this weekend" — not durable).
- CAESAR's own outputs.
- Anything the LLM rates below ~0.3 confidence.
That said, the extraction LLM will be wrong sometimes. A generous model reads "imagine if my dog was named Beans" as a fact statement. That's what the dashboard is for — see below.
Inspect & correct: /dashboard/facts¶
Every fact CAESAR has remembered shows up at
http://127.0.0.1:8000/dashboard/facts (login with
CAESAR_DASHBOARD__TOKEN). The page lists key, current value,
confidence, when it was last confirmed, and a link back to the
audit row that produced it.
Two operator actions per row:
- Edit — change
valueorconfidence. Submits a 303 redirect back to the list. Emits amemory.fact.user_editedaudit row so the dashboard timeline records the correction. - Delete — removes the fact. Emits
memory.fact.user_deleted(deliberately distinct from the genericmemory.fact.deletedso the timeline distinguishes operator deletes from system deletes).
Operator edits always win. If the extractor re-sees the original
value later (because you didn't delete the underlying chat), it'll
restore the fact — at which point you can either delete it again
(it'll keep coming back as long as the source chat exists) or
prune the source chat.completed row.
Templates: how {key, value} becomes a sentence¶
The injected system-prompt block reads in natural language, not key-value syntax:
What you've told me:
- Your dog's name is Beans.
- You prefer coffee black with no sugar.
- You live in Portland.
Sentences come from a template registry in
caesar/memory/templates.py. Two shapes:
- Exact match:
dog.name→Your dog's name is {value}. - Wildcard:
preference.*→You prefer {value}.
For keys with no template, the fallback renders
{key}: {value}. The brain still gets the data; the operator's
dashboard view shows it the same way regardless.
If a key you care about reads awkwardly, add a template. It's a
~3-line patch in templates.py; no tests required beyond the
matcher already covering wildcard vs exact precedence.
Privacy and bounds¶
Facts are durable data about the operator. v1.8 makes a few deliberate choices to keep the surface tight:
- Facts live in their own table (
personal_facts), not re-indexed into the semantic store. The brain reads them via the system prompt only — there's norecall_facttool to dispatch. - The audit log records every read-/write-path event, including operator edits and deletes. Nothing CAESAR believes about you is unilateral.
- v1.8 is single-user. When multi-user lands (a future milestone), facts become per-identity and don't cross between household members.
- The 30-fact / 2048-char cap means the system-prompt growth from facts is bounded. The default budget is ~1-2% of a Claude Haiku context window.
How to turn it off¶
Three flavours:
- Stop extracting:
CAESAR_MEMORY__FACTS_EXTRACT__ENABLED=false. Existing facts in the table stay; injection keeps reading them. This is the "freeze the model of me" posture. - Stop injecting:
CAESAR_MEMORY__FACTS_INJECT__ENABLED=false. Facts still accumulate (if extraction is on); the brain just doesn't see them. Useful for collecting a dashboard view without changing brain behaviour. - Wipe the model of me:
- via the dashboard: delete every row at
/dashboard/facts. - via SQL:
DELETE FROM personal_facts;against yourcaesar.sqlite3. Both extraction and injection start clean.
Failure modes¶
| Symptom | Likely cause |
|---|---|
/dashboard/facts is empty after several chats |
Extractor not enabled, or chats too short. Check CAESAR_MEMORY__FACTS_EXTRACT__ENABLED and look for memory.fact.added audit rows. |
/dashboard/facts is empty AND memory.fact.added rows exist |
The extractor wrote facts then something deleted them. Look for memory.fact.deleted / memory.fact.user_deleted rows. |
| Facts get added but the brain still doesn't know them | Injection disabled, or facts cap exhausted. Check CAESAR_MEMORY__FACTS_INJECT__ENABLED and the per-chat system prompt (logged at DEBUG). |
memory.extract.llm_failed audit rows |
Provider for the memory_extract task is down or unreachable. The cursor still advances so a persistent failure doesn't lock the queue — fix the provider and the next batch picks up. |
| Wrong fact extracted (e.g. role-play turned into a real fact) | Edit or delete via /dashboard/facts. Tighten the operator system prompt to discourage the LLM from speculating during chat. |
Untemplated key renders as key: value |
Add an entry to caesar/memory/templates.py. The fallback is a feature, not a bug — the data still reaches the brain. |
personal_facts table missing on restart |
Migration didn't run. caesar praetor migrate to fix. |
What v1.9 adds: episodes + ranking¶
v1.8 made CAESAR remember durable facts about you. v1.9 makes it recall what you discussed without you having to name the conversation, and ranks both facts and episodes so the inject budget spends itself on what matters.
The new gap v1.9 closes:
Mon: "We're switching to a heat pump — the install date is the 14th
and the rebate paperwork is in the cabinet by the door."
Thu: "What were we doing about heating?"
In v1.8, Thursday's brain run loads the facts inject (which
captures atomic things like device.heating_system = "heat pump"
if extraction picked it up) but doesn't know the operational
context — the install date, the paperwork, what "we" decided.
After v1.9:
- Monday's chat lands in
audit_logas achat.completedrow. - The
memory.summariseworker polls withinCAESAR_MEMORY__EPISODES_SUMMARISE__INTERVAL_SECONDS(default 60s). - It calls an LLM with a summarisation system prompt and the transcript. The LLM returns a 1-2 sentence summary ("We discussed switching to a heat pump; CAESAR noted the install date is the 14th and the rebate paperwork is in the cabinet by the door.").
- The summary is embedded (same Embedder as v0.4 semantic
recall — Voyage when configured, the deterministic
StubEmbedderotherwise) and written toepisode_summaries. - Thursday's
/v1/chatembeds the latest user message, ranks every recent summary bycosine × freshness × recall_weight, takes the top-K above the similarity threshold, and folds them into the system prompt as a "Possibly relevant past conversations:" block. - Thursday's brain answers as if it remembered the heat-pump thread.
Each step audits a row so the loop is operator-visible:
| Event | When |
|---|---|
memory.episode.summarised |
Summariser writes a new (or replacement) row |
memory.episode.summarise_skipped |
Trivial chat / empty summary — cursor still advances |
memory.episode.summarise_failed |
Embedder failed mid-write (LLM hiccup is logged but not audited) |
memory.episode.recalled |
Auto-recall surfaced this summary into a chat (carries cosine, freshness, recall_weight, score) |
memory.episode.deleted |
Retention sweep or operator removed an episode |
Setup¶
Two flavours of LLM call power v1.9: the summarisation call (one
per chat.completed row, in the background) and the embedder
call (one per summary, then one per chat for the recall query).
1. Pick a summarisation model¶
Same pattern as the facts extractor: route the
memory_summarise task to whatever's cheapest. Summarisation is
a few-sentence transformation — a local 8B model handles it.
# Route episode summarisation to a local Ollama model (no extra spend)
CAESAR_LLM__TASK_ROUTING={"memory_extract":{"provider":"ollama","model":"llama3.1:8b"},"memory_summarise":{"provider":"ollama","model":"llama3.1:8b"}}
If you don't route, the summariser uses your default chat provider, which is fine if you're running fully local already and costs cloud tokens otherwise.
2. Configure an embedder¶
The summariser and the auto-recall path both need an Embedder.
CAESAR uses Voyage when you provide an API key, and otherwise
falls back to a deterministic stub (StubEmbedder) that's good
enough for tests and offline play but not production semantic
search.
# Optional but recommended for production:
CAESAR_SEMANTIC__VOYAGE_API_KEY=pa-...
CAESAR_SEMANTIC__MODEL=voyage-3.5
CAESAR_SEMANTIC__EMBEDDING_DIM=1024
You don't need CAESAR_SEMANTIC__ENABLED=true for v1.9 — that
flag controls the v0.4 semantic_recall tool and its indexer.
The v1.9 embedder is built whenever the summariser or auto-recall
needs it.
3. Turn on episode summarisation¶
CAESAR_MEMORY__EPISODES_SUMMARISE__ENABLED=true
CAESAR_MEMORY__EPISODES_SUMMARISE__INTERVAL_SECONDS=60
CAESAR_MEMORY__EPISODES_SUMMARISE__BATCH_SIZE=4 # smaller than the
# facts extractor:
# each row costs
# two upstream calls
Restart Praetor. The background summariser starts immediately;
new chats are summarised within ~INTERVAL_SECONDS of
completion. Existing chats from before you turned this on are
processed in cursor order on first run.
4. Auto-recall is on by default¶
Once the summariser has written rows, recall surfaces them on every chat. The defaults match the ADR-0034 §3 numbers:
CAESAR_MEMORY__EPISODES_RECALL__ENABLED=true # default
CAESAR_MEMORY__EPISODES_RECALL__TOP_K=3 # surfaced per chat
CAESAR_MEMORY__EPISODES_RECALL__SIMILARITY_THRESHOLD=0.45
CAESAR_MEMORY__EPISODES_RECALL__MAX_AGE_DAYS=90 # candidate window
CAESAR_MEMORY__EPISODES_RECALL__HALFLIFE_DAYS=30.0
CAESAR_MEMORY__EPISODES_RECALL__RECALL_WEIGHT_SCALE=0.25
CAESAR_MEMORY__EPISODES_RECALL__MAX_CHARS=2048 # block size cap
The threshold guards against off-topic summaries; the recall weight rewards episodes you keep coming back to.
How the recall ranking works¶
Every candidate episode (within MAX_AGE_DAYS of now) gets:
score = cosine_similarity × freshness × recall_weight
freshness = exp(-age_days / HALFLIFE_DAYS) (default halflife 30d)
recall_weight = 1.0 + RECALL_WEIGHT_SCALE × log1p(recall_count)
recall_count increments each time auto-recall surfaces the
episode. The log1p shape means past relevance compounds slowly
— one viral conversation doesn't bury everything else.
Anything below SIMILARITY_THRESHOLD (default 0.45) never makes
the cut. The top remaining TOP_K go into the system prompt as:
Possibly relevant past conversations:
- 3 days ago: We discussed switching to a heat pump; CAESAR
noted the install date is the 14th.
- last week: The operator asked about packing for Portugal;
CAESAR recommended layered clothing.
Relative phrasing ("3 days ago", "last week", "2 months ago") is rendered at retrieval time so it's always current.
Importance ranking for facts (v1.9 §4)¶
The v1.8 fact inject capped at 30 facts / 2048 chars and dropped the oldest-confirmed entries when the cap hit. v1.9 makes the drop principled: every fact gets a retrieval-time score and the lowest-importance ones fall off first.
importance = freshness × confidence × edit_weight
freshness = exp(-age_days / HALFLIFE_DAYS) (default halflife 60d)
edit_weight = 1.0 + EDIT_WEIGHT_SCALE × edit_count (default scale 0.5)
edit_count is the number of memory.fact.user_edited audit
rows scoped to that fact's key — every dashboard correction is a
strong signal you care about that fact, so it adds linear weight
on top of freshness × confidence.
Knobs:
CAESAR_MEMORY__FACTS_IMPORTANCE__HALFLIFE_DAYS=60.0
CAESAR_MEMORY__FACTS_IMPORTANCE__EDIT_WEIGHT_SCALE=0.5
The default halflife (60d) is twice the episode halflife (30d) because facts are inherently more durable than per-chat context.
Inspect importance: /dashboard/facts¶
The dashboard facts page grows an Importance column showing each fact's current score, sorted descending. Hover the cell to see the breakdown — freshness, confidence, edit_weight, edit count — so you can see why a row ranks where it does.
There's no edit surface for the score itself. Importance is a
function of facts you've already touched (or haven't); the
operator's edit/delete buttons already drive ranking via
edit_count.
Episodes don't have a dashboard page¶
v1.9 deliberately doesn't ship /dashboard/episodes. Episodes
ride the existing audit timeline — memory.episode.summarised
and memory.episode.recalled rows show what got summarised when
and what got pulled into which chat. Clicking through to the
source chat.completed is how you see "what got surfaced when."
If an episode's summary is wrong, the fix is to delete the
source chat.completed (the retention sweep, ADR-0020, will do
it after RETENTION_DAYS) — re-summarising one chat would
silently rewrite history.
"Decay" means ranking, not deletion¶
A subtle thing worth saying explicitly: v1.9 never auto-deletes a fact or an episode. The freshness factor down-weights old entries so they lose retrieval competitions, but the rows stay in the table. The only thing that hard-deletes is:
- The retention sweep (ADR-0020) when a row's underlying
audit_logrow passesCAESAR_MEMORY__RETENTION_DAYS. - An explicit operator delete via
/dashboard/facts(memory.fact.user_deleted). - A future
/dashboard/episodesdelete (not in v1.9).
This is on purpose. Aggressive auto-pruning is hard to recover from; ranking-only decay lets the operator inspect what's happening before any data is lost.
How to turn v1.9 off¶
Three flavours, parallel to v1.8:
- Stop summarising:
CAESAR_MEMORY__EPISODES_SUMMARISE__ENABLED=false. Existing summaries in the table stay; recall keeps reading them. - Stop recalling:
CAESAR_MEMORY__EPISODES_RECALL__ENABLED=false. Summaries still accumulate (if summarisation is on); the brain just doesn't see them in its system prompt. The audit log still shows thememory.episode.summarisedrows. - Wipe the episode history:
- via SQL:
DELETE FROM episode_summaries;andDELETE FROM memory_summarise_cursor;against yourcaesar.sqlite3. The summariser will re-process from scratch the next time it polls. - or change
CAESAR_MEMORY__RETENTION_DAYSto a smaller window and wait for the sweep.
v1.9 failure modes¶
| Symptom | Likely cause |
|---|---|
| Episodes never appear | EPISODES_SUMMARISE__ENABLED=false, or no chats have completed since enabling. Check for memory.episode.summarised audit rows. |
memory.episode.summarise_skipped rows pile up |
Many chats are too short (one-shot acks). Expected; not a bug. The cursor still advances. |
memory.episode.summarise_failed rows |
Embedder is misconfigured or down. Check CAESAR_SEMANTIC__VOYAGE_API_KEY if you set it, or expect the StubEmbedder fallback. |
| Recall never surfaces anything | Threshold too tight (lower SIMILARITY_THRESHOLD), no candidates within MAX_AGE_DAYS, or the embedder for the query doesn't match the one that wrote the embeddings (model mismatch returns cosine == 0). |
| Recall surfaces irrelevant episodes | Threshold too loose. Raise SIMILARITY_THRESHOLD (try 0.55, 0.65). |
| Importance scores all near zero | Facts are old (older than ~3× halflife). Raise HALFLIFE_DAYS or accept that facts age out by design. |
episode_summaries table missing on restart |
Migration didn't run. caesar praetor migrate to fix. |
See also¶
- ADR-0033 — the v1.8 design decisions: schema shape, extractor pattern, retrieval cap, privacy bounds.
- ADR-0034 — the v1.9 design decisions: episode summaries, auto-recall, ranking-only decay, rejected alternatives (synchronous summarisation, hard pruning, cross-indexing facts into the semantic store).
- ADR-0010 — the broader memory story (episodic + semantic) that v1.8 and v1.9 sit alongside.
- Picking a model — task routing, including
the "chat on the hosted provider, everything else on Ollama"
pattern that fits
memory_extractandmemory_summarise. - Architecture — where memory sits in the Praetor / Legion picture.