0034 — Episodes and decay: making semantic recall actually useful¶
- Status: Accepted
- Date: 2026-05-18
- Deciders: @Sinidious
- Related issues / PRs: v1.9 milestone; extends ADR-0010 (memory stores), ADR-0020 (retention), ADR-0033 (facts layer).
Context¶
v1.8 closed the facts half of the memory gate question: if the operator told CAESAR a fact, the next chat sees it. The episodes half is still missing:
Mon: "I've been struggling with sleep — keep waking up at 3am."
... long conversation about possible causes ...
Thu: "I tried the thermostat thing you suggested."
Thursday's brain run sees "the thermostat thing you suggested" and
has no idea what suggestion the operator means. The Monday
conversation exists in audit_log.payload.reply, and the v0.4
SemanticIndexer (ADR-0010 amendment) has embedded it — but the
brain only consults that index if it thinks to call
memory.semantic_recall. It doesn't, because nothing told it to.
The same gate question shape as v1.8: technical memory exists, operator-visible memory doesn't. v1.9 closes the gap.
The v1.8 planning round committed v1.9 to "episodes + decay" with ranking-only decay semantics. This ADR cashes out that plan.
Decision¶
CAESAR v1.9 will ship per-chat episode summaries + auto-recall into the system prompt + ranking-based decay that orders both facts and episodes by importance. No data is ever auto-deleted.
1 — episode_summaries table¶
CREATE TABLE episode_summaries (
id INTEGER PRIMARY KEY,
audit_log_id INTEGER NOT NULL UNIQUE REFERENCES audit_log(id),
summary TEXT NOT NULL,
embedding JSON NOT NULL, -- list[float], same shape as semantic_chunks
model TEXT NOT NULL, -- embedder model id
created_at DATETIME NOT NULL,
last_recalled_at DATETIME, -- bumped each time auto-recall surfaces this row
recall_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_episode_summaries_audit ON episode_summaries(audit_log_id);
CREATE INDEX idx_episode_summaries_created ON episode_summaries(created_at);
UNIQUE(audit_log_id)means one summary perchat.completedrow. The worker is idempotent — re-running on the same audit row is a no-op.embeddingis a JSON array of floats; cosine search runs in Python at recall time (same shape as v0.4'ssemantic_chunks).last_recalled_at+recall_countfeed the v1.9 ranking model. An episode that's been surfaced often is more likely to be surfaced again — the operator's behaviour reveals what matters.
A singleton cursor table mirrors the v1.8 extractor:
CREATE TABLE memory_summarise_cursor (
id INTEGER PRIMARY KEY,
last_audit_id INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL
);
2 — memory.summarise worker¶
Twin of v1.8's memory.extract. Polls chat.completed rows since
the cursor, calls an LLM via gateway.complete(..., task="memory_summarise"),
gets back a 1-2 sentence summary, then embeds it via the
configured Embedder and writes the row.
The summarisation prompt asks for a third-person, time-anchored summary so the brain reads it as "what happened last week" rather than a slice of dialogue:
"We discussed the operator's trouble sleeping; CAESAR suggested lowering the bedroom thermostat to 65°F and tracking sleep over a week."
Empty / trivial chats (e.g. "what time is it?") return empty summaries; the worker writes an empty-summary row to advance the cursor without leaving the chat permanently un-summarised. Auto- recall filters those out by skipping empty summaries.
Task routing recommendation (ADR-0026): point memory_summarise at
the same cheap local model as memory_extract. Both are
structured-output tasks.
3 — Auto-recall in /v1/chat¶
At the start of every chat turn (and every proactive run), after loading facts and before composing the system prompt:
- Embed the new user message via the same
Embedderthe summariser used. - Run cosine search against
episode_summaries. - Take top-K results (default 3) above a similarity threshold (default 0.45). Filter to summaries from the last 90 days.
- For each surfaced summary: bump
recall_count, updatelast_recalled_at, auditmemory.episode.recalled. - Render as a system-prompt block between the facts block and the operator prompt:
Possibly relevant past conversations:
- 3 days ago: We discussed the operator's sleep trouble and tried
a thermostat adjustment.
- last week: The operator's spouse Alice mentioned a holiday in
Portugal; CAESAR offered packing tips.
Time anchors come from created_at; relative phrasing
("3 days ago", "last week") is rendered at retrieval time so it
stays current.
This path is independent of the existing memory.semantic_recall
Legion tool. That tool keeps working for explicit recall requests
("what did we say about the renovation?"); the auto-recall path is
the implicit "here's what's relevant" inject.
4 — Importance ranking (ranking-only decay)¶
The v1.8 facts inject already caps at 30 facts / 2048 chars, dropping facts past the cap. v1.9 makes the drop principled:
Fact importance score (computed at retrieval, not stored):
importance = freshness × confidence × edit_weight
where:
freshness = exp(-age_days / FRESHNESS_HALFLIFE_DAYS) # default 60d half-life
confidence ∈ [0, 1]
edit_weight = 1.0 + 0.5 × operator_edit_count # operator interest is signal
FactsStore.list_all() orders by this score descending. The
existing cap (#150) still applies; it now drops the
least-important facts first.
Episode importance score (auto-recall ranking):
score = cosine_similarity × freshness × recall_weight
where:
freshness = exp(-age_days / EPISODE_HALFLIFE_DAYS) # default 30d
recall_weight = 1.0 + 0.25 × log1p(recall_count) # past relevance compounds
Auto-recall ranks candidates by this combined score, not raw cosine. An episode that semantic-matches strongly but is six months old loses to a moderately-matching episode from yesterday.
Operator edit_count for facts: counted from
memory.fact.user_edited audit rows scoped to the fact's key.
Computed on demand at list_all() time — no extra column needed.
Slower than a stored counter, but the operator's edit history
isn't large enough to matter (typical operator: <100 edits
lifetime).
5 — Dashboard surface¶
/dashboard/facts (v1.8) grows one Importance column showing
the computed score, sorted descending. The audit row that bumped
freshness or confidence is linkable as before. No new
columns, no manual override (yet). The dashboard's role is
visibility into the ranking, not editing it — the operator's
existing edit/delete actions already drive importance via
edit_count.
No new episodes page in v1.9. Surfaced episodes ride the existing
audit timeline via memory.episode.recalled rows; clicking
through to the source chat.completed is how operators see
"what got surfaced when."
6 — Audit event types¶
New rows (joining the existing v1.8 memory.fact.* family):
memory.episode.summarised— written bymemory.summariseworker. Carriesaudit_log_id(the source chat), the rendered summary text, and the embedder model id.memory.episode.summarise_skipped— written when the worker decides a row isn't worth summarising (empty / trivial). The cursor still advances.memory.episode.summarise_failed— LLM or embedder failed; audited so persistent provider issues are visible. Cursor advances anyway (same posture as v1.8 extractor).memory.episode.recalled— written each time auto-recall surfaces an episode into the system prompt. Carries the source audit_log_id, the cosine score, the freshness factor, and the resulting combined score.
7 — Config¶
New settings nested under MemorySettings:
class EpisodesSummariseSettings(BaseModel):
enabled: bool = False
interval_seconds: float = 60.0
batch_size: int = 4 # smaller than fact-extract; each is two LLM calls
class EpisodesRecallSettings(BaseModel):
enabled: bool = True # on by default when summaries exist
top_k: int = 3
similarity_threshold: float = 0.45
max_age_days: int = 90
halflife_days: float = 30.0
recall_weight_scale: float = 0.25
class FactsImportanceSettings(BaseModel):
halflife_days: float = 60.0
edit_weight_scale: float = 0.5
EpisodesSummariseSettings.enabled = False by default for the
same reason memory.facts_extract defaulted off: per-chat LLM
calls cost money. Operators opt in.
Alternatives considered¶
- Reuse
semantic_chunksfor summaries instead of a new table. Rejected: existing chunks store full-reply embeddings; akindcolumn or replacement breaks live data. Cleaner to leave the v0.4 semantic store untouched and addepisode_summariesalongside. - Cluster chats into multi-turn episodes (session ids,
time-bounded windows). Rejected for v1.9: every
chat.completedrow IS already a coherent unit (one operator message + brain response). The summariser handles multi-turn context via themessagesfield. Clustering is a v1.10 concern at earliest. - Auto-delete stale episodes below a relevance threshold. Rejected per the planning question: v1.9 is ranking-only. Operators can prune manually via the audit log's TTL sweep (ADR-0020) if they want.
- Compute importance once at write time (stored column). Rejected: stored importance goes stale the moment time advances. Computing freshness at read time is cheap and always correct.
- Generated-column importance score (SQLite supports it). Rejected: same staleness issue, plus the read-side compute is trivial (Python arithmetic over already-loaded rows).
- Episode summaries written by the brain at end-of-chat rather than a background worker. Rejected: adds an LLM call's latency to every chat turn, same trade-off the v1.8 extractor decided against.
- Operator-visible "pin this fact" button. Rejected for v1.9
as scope creep. The
operator_edit_countsignal already lets operators raise a fact's importance by touching it. A formal pin is a v1.10 consideration if anyone hits a wall. - Time-bucketed importance (last 24h, last week, older). Rejected: the exponential-decay halflife shape is smooth and needs no thresholding.
Consequences¶
Positive¶
- Closes the second half of the v1.8 gate. Thursday's brain remembers what Monday's brain heard, in the same way Monday's brain remembers the dog's name from last week.
- Reuses every primitive from v1.8: cursor-based polling Legion worker, task-routed gateway, audit-row contract, dashboard pattern. The architectural shape feels familiar.
- Operator-visible importance column makes the dashboard show "what CAESAR considers important about you" without exposing any new editing surface. Right amount of transparency for v1.9.
- The decay model is reversible: an operator who hated the
ranking can lower
halflife_daysto make freshness count less, or raise it to make age count more. Nothing has been deleted.
Negative¶
- Two more LLM calls per chat for operators who turn both v1.8 extractor and v1.9 summariser on. With the recommended Ollama routing this is free; on Anthropic-only it's two extra Haiku calls per chat (~$0.0005 each at current pricing). Documented and capped (operators can keep summariser off while running extractor).
- Auto-recall changes brain behaviour silently the first time
it surfaces an episode. Mitigated by the
memory.episode.recalledaudit row showing exactly what got surfaced and why. - The episodes ranking is a hand-tuned weighted-sum formula. Not ML, not learned, not magical. Should be revisited in a future milestone if it doesn't feel right — but for v1.9, the dumb formula is good enough.
Neutral¶
- One Alembic migration (
episode_summaries+ cursor). - Two new audit event types (
memory.episode.summarised,memory.episode.recalled) plus two error/skip variants. - One new dashboard column on
/dashboard/facts. - The
Embedderprotocol from v0.4 is reused unchanged. When the operator hasCAESAR_SEMANTIC__ENABLED=false, summarisation cannot run (no embedder); documented as a prerequisite.