Skip to content

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 per chat.completed row. The worker is idempotent — re-running on the same audit row is a no-op.
  • embedding is a JSON array of floats; cosine search runs in Python at recall time (same shape as v0.4's semantic_chunks).
  • last_recalled_at + recall_count feed 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:

  1. Embed the new user message via the same Embedder the summariser used.
  2. Run cosine search against episode_summaries.
  3. Take top-K results (default 3) above a similarity threshold (default 0.45). Filter to summaries from the last 90 days.
  4. For each surfaced summary: bump recall_count, update last_recalled_at, audit memory.episode.recalled.
  5. 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 by memory.summarise worker. Carries audit_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_chunks for summaries instead of a new table. Rejected: existing chunks store full-reply embeddings; a kind column or replacement breaks live data. Cleaner to leave the v0.4 semantic store untouched and add episode_summaries alongside.
  • Cluster chats into multi-turn episodes (session ids, time-bounded windows). Rejected for v1.9: every chat.completed row IS already a coherent unit (one operator message + brain response). The summariser handles multi-turn context via the messages field. 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_count signal 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_days to 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.recalled audit 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 Embedder protocol from v0.4 is reused unchanged. When the operator has CAESAR_SEMANTIC__ENABLED=false, summarisation cannot run (no embedder); documented as a prerequisite.

References

  • ADR-0010 — episodic + semantic memory foundations.
  • ADR-0020 — retention sweep; v1.9 doesn't change it. TTL still hard-deletes after 90 days by default.
  • ADR-0026 — task-routing used by memory_summarise.
  • ADR-0033 — v1.8 facts layer; this ADR extends its ranking semantics and reuses its worker shape.