Skip to content

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_log table. The append-only timeline. Always on (ADR-0012).
  • Semantic recall (tool) — "What did we talk about that was like this?" The memory.semantic_recall Legion worker embeds past chat.completed rows and ranks them by cosine similarity. Opt-in via CAESAR_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.completed rows, 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.completed row. 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:

  1. Monday's chat lands in audit_log as a chat.completed row.
  2. The memory.extract worker polls within CAESAR_MEMORY__FACTS_EXTRACT__INTERVAL_SECONDS (default 60s).
  3. 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}].
  4. The result lands in personal_facts (one row per fact, keyed by dog.name).
  5. Thursday's /v1/chat request 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.
  6. 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 value or confidence. Submits a 303 redirect back to the list. Emits a memory.fact.user_edited audit row so the dashboard timeline records the correction.
  • Delete — removes the fact. Emits memory.fact.user_deleted (deliberately distinct from the generic memory.fact.deleted so 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 no recall_fact tool 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 your caesar.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:

  1. Monday's chat lands in audit_log as a chat.completed row.
  2. The memory.summarise worker polls within CAESAR_MEMORY__EPISODES_SUMMARISE__INTERVAL_SECONDS (default 60s).
  3. 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.").
  4. The summary is embedded (same Embedder as v0.4 semantic recall — Voyage when configured, the deterministic StubEmbedder otherwise) and written to episode_summaries.
  5. Thursday's /v1/chat embeds the latest user message, ranks every recent summary by cosine × 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.
  6. 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_log row passes CAESAR_MEMORY__RETENTION_DAYS.
  • An explicit operator delete via /dashboard/facts (memory.fact.user_deleted).
  • A future /dashboard/episodes delete (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 the memory.episode.summarised rows.
  • Wipe the episode history:
  • via SQL: DELETE FROM episode_summaries; and DELETE FROM memory_summarise_cursor; against your caesar.sqlite3. The summariser will re-process from scratch the next time it polls.
  • or change CAESAR_MEMORY__RETENTION_DAYS to 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_extract and memory_summarise.
  • Architecture — where memory sits in the Praetor / Legion picture.