Episodic Memory
Auto-indexing is wired at boot. When an embedder is configured (
KEEPER_OLLAMA_URL pointing at an Ollama host serving nomic-embed-text), the server starts the background indexer sweeper automatically at boot — new embeddable journal entries become vector-searchable within one 30-second sweep, no manual indexing required.Without an embedder, episodic recall runs in sparse-only mode (keyword/FTS, no vector similarity). That degraded state is never silent: the server logs a WARN at boot, GET /healthz reports "episodic": "sparse-only" (vs "vector"), and crewship doctor surfaces it as a WARN with the fix.A configured but broken embedder is reported separately as "episodic": "vector-degraded", with the underlying error in episodic_error, and crewship doctor FAILs on it. The health surfaces answer from what the embedder actually did, not from the fact that one was constructed — being constructible only proves KEEPER_OLLAMA_URL was set. The server also makes one embed probe shortly after boot, so a host that is reachable but not serving nomic-embed-text shows up immediately rather than waiting for the first sweep that finds work. Recovery is reported just as promptly: pull the missing model and the next successful embed clears the state, no restart needed.Roadmap (v0.2): the synchronous embed-on-write hook (Indexer.IndexOne directly from the journal writer hot path) is not yet wired — until then a freshly written entry can lag recall by up to one sweep interval.exec.output_chunk, container.metrics, network.*) drown the embedding space and dilute recall. Episodic refuses to embed those types and ingests only escalations, summaries, terminal mission status, denied keeper calls, eval regressions, and operator-tagged entries.
Embedding criteria
Storage
journal_embeddings (added in migration 52 alongside journal_entries):
pgvector, so recall is a brute-force cosine scan over the scope-filtered rows. For the expected scale (~1% of entries embedded, low thousands per agent) the scan finishes in low milliseconds. If this outgrows SQLite the right move is an external vector store, not a SQLite extension — so storage sits behind an interface.
Migration 55 additions
Added in PR #212 alongside the memory uplift.Migration 55 schema — FTS5 mirror, archive sink, relation graph, health snapshots
Migration 55 schema — FTS5 mirror, archive sink, relation graph, health snapshots
Scopes
ScopeForRole(role) maps role strings to scopes:
Workspace isolation is always enforced at the query boundary — a cross-workspace recall is impossible even with a misconfigured scope.
Hybrid retrieval
Pure cosine recall misses entries that are lexically obvious — searching for"OOM in checkout" does not necessarily match an entry whose summary literally says OOM, because nomic-embed-text clusters by semantics, not surface form. PR #212 adds hybrid retrieval: dense (cosine) and sparse (BM25 over the FTS5 index) results fused via Reciprocal Rank Fusion.
k_RRF = 60 (the value used in the original RRF paper; further tuning had marginal returns). Each ranking contributes the inverse of the entry’s position; entries ranked highly in either leg float to the top, while entries that appear in both pump up most. There is no weighting parameter — both legs are treated equal, and the constant 60 dominates ranking noise at low ranks where it matters most.
What the sparse leg does with your query text
QueryText is free-form; the BM25 leg rewrites it before it reaches FTS5. Every
word becomes a term and the terms are OR-ed, so any one of them is enough to
match. A word of three characters or more additionally matches by prefix
(deploy finds deployment); anything shorter is matched exactly. The scan is
Unicode-aware, so a Czech or German word survives its diacritics whole — drží
is one term, not dr plus a fragment.
The three-character floor exists because a short prefix is not a search term.
Measured against this repository’s own docs tree,
se* expanded to 160 index
terms and matched 48.5% of all chunks — OR-ed in beside the words that
actually carried the question. Anything shorter than three characters is
therefore searched exactly, never as a prefix.porter ascii, which stems English
but does not fold non-ASCII case or accents: a Czech word typed without its
diacritics (rozhodnuti) will not find the entry that spells it rozhodnutí.
That is a property of the index, not of the query builder.
HybridRecall is the recommended entry point for new code. The pure-cosine Recall is preserved for the orchestrator’s prompt-injection path (where rank stability across small query perturbations matters more than recall on lexical hits) and for tests that want a deterministic similarity score.
Memory relations (embedding relation graph)
Embeddings live in isolation by default;memory_relations lets us treat them as a graph. Two relation types are populated automatically:
similar—LinkSimilarOnIndexruns at insert time: for the freshly-indexed entry, query the existing embedding pool, take the top-3 cosine matches above 0.80, and writesimilarrows. Edges are symmetric — both(entry_id=new, related_entry_id=match)and the reverse(entry_id=match, related_entry_id=new)are inserted withrelation_kind='similar', score=cosine, so a cosine relation reads the same from either end.supports—LinkSupportsis called by the consolidator when it derives a rule from one or more journal entries. The rule entry points (directionally) to every supporting evidence entry:relation_kind='supports', score=1.0. This is what makes consolidated rules traceable back to their evidence.
relation_kind CHECK also reserves refutes and duplicates; neither is populated automatically today.
Read-side use: RelationsFor(entryID) returns an entry’s outbound edges and is consumed by the health computer (Reachability metric) and debug tooling. Prompt-time relation-walking to deepen recalled context is not wired into the orchestrator’s injection path today.
Recall API
WorkspaceIDis required.ScopeOwnrequiresAgentID;ScopeCrewSharedrequiresCrewID.Kcaps results (1-50, default 5).
[]Hit:
episodic.RenderInjection(hits, maxChars) does this and returns a prompt-ready string, truncated to fit a char budget — used by the orchestrator’s episodicRecallAdapter before every agent run.
Importance scoring
Eachjournal_embeddings row carries three additional fields that feed
recall ranking and a nightly decay job:
Baseline comes from
BaseImportance(entry_type, severity, priority)
(see internal/episodic/importance.go): peer escalations and eval
regressions seed high; info-level routine events seed 0.5; the
operator-applied priority marker floors the value at 0.80 (pin) /
0.85 (high) / 0.95 (permanent).
Nightly decay — episodic.DecayAndReinforce recomputes every
row as:
RecencyFactor(indexed_at, now) = max(0.1, 1 - days/180)— a 0.1 floor keeps old-but-critical memories from going to literal zeroReferenceBoost(refs) = log₂(refs + 1)— frequently-recalled entries lift, but the/8divisor prevents runaway loops from dominating over rare-but-critical ones
Recall sorts candidates by
cosine × importance (not cosine alone), then top-K is returned.
Every returned hit gets MarkReferenced which increments the
reference counter so the next DecayAndReinforce lifts frequently-
hit memories.
The formula combines base value, recency decay, and reference-count
reinforcement to keep durable signal ranking well even as it ages.
Effect: a six-month-old peer.escalation keeps ranking well after
one recall per week, while a stale low-value info entry falls off.
Untrusted-hints wrapper
RenderInjection wraps its output in a <recalled-memory>…</recalled-memory>
block with an explicit “UNTRUSTED HINTS” preamble. The wrapper is
load-bearing: recalled entries may contain text authored by peers,
tools, or agent output — a past peer.escalation could carry an
"IGNORE PREVIOUS INSTRUCTIONS" payload without anyone realising.
The wrapper instructs the model to treat everything inside as hints
the current task can override, not as authoritative instructions.
Same treatment applies to the orchestrator’s buildMemoryContext
blocks (AGENT.md / CREW.md) — both surfaces are agent-authored and
therefore both should be read by the model as hints.
The wrapper follows the “treat all recalled content as untrusted
hints” pattern: anything inside a <recalled-memory> block is
guidance the current task can override, never authoritative
instruction.
Embedder
TheEmbedder interface is provider-neutral:
embedder.go) against nomic-embed-text (768-dimensional vectors). The embedder shares the Keeper’s Ollama base URL — KEEPER_OLLAMA_URL, which defaults to http://localhost:11434 (the config falls back to that value when the env var is unset, so the embedder is configured by default). The embedder ends up nil only when that Ollama endpoint is unreachable at startup, which disables episodic recall — see the next paragraph.
If the embedder is nil (Ollama unreachable at startup), Recall returns an empty slice silently — agent runs don’t fail on embedding outages. This is configured in server/orchestrator_adapters.go:newEpisodicRecallAdapter.
Health scoring
memory_health_snapshots captures one row per workspace per day (and optionally per crew). The score is a weighted sum of five metrics, each in [0, 100]:
The composite
overall score (exposed as overall in the API/DB row) is reported with three colour bands:
Health is exposed by
internal/api/memory_health_handler.go over scores derived in internal/episodic/. The daily consolidator job recomputes it; operators can call it on demand via:
- HTTP —
GET /api/v1/memory/health[?crew_id=<slug>]. See Memory API. - CLI —
crewship memory health [--crew <slug>]. Seecrewship memory health.
crewship memory health accepts only --crew). To force a fresh score sooner, trigger consolidation (crewship consolidate run).
In-session memory nudge
When an agent run accumulates 60 or more new journal entries since its lastmemory.updated entry, the orchestrator injects a short reminder into the next system prompt:
You have 72 new journal entries since your last memory update. Consider appending any recurring pattern you’ve noticed to ~/.memory/AGENT.md before the session ends — the consolidator won’t replace your personal observations.
The threshold (nudgeThreshold in internal/orchestrator/memory.go) was raised from 30 to 60 because at 30 the nudge fired on essentially every session after a memory write. The nudge only fires once per session — once the agent emits a new memory.updated, the counter resets.
The nudge is advisory — agents can ignore it, and it is stripped from the context before the LLM call records its response payload.
Indexer
TheIndexer (internal/episodic/indexer.go) runs a background sweeper loop on a 30-second poll (NewIndexer(..., poll)), processing up to 64 unindexed embeddable entries per sweepOnce.
The server starts this sweeper automatically at boot when an embedder is configured (startEpisodicIndexer in internal/server/server_lifecycle.go, gated on KEEPER_OLLAMA_URL). When no embedder is configured the server instead logs one WARN at boot and reports "episodic": "sparse-only" on /healthz; crewship doctor reads that field and warns with the enable hint.
Alongside the sweeper it fires one bounded embed probe (probeEpisodicEmbedder, 10s timeout). A failing probe logs a WARN naming the model and flips /healthz to "vector-degraded" — it does not stop the indexer or fail the boot, because BM25 recall still works and the server is still useful. Every subsequent embed updates the same state, so the sweeper’s own failures keep it current and a recovery clears it.
Hot-path callers that want an embedding ready before the next recall call Indexer.IndexOne(ctx, entry) directly — typically right after writing a summary.generated entry:
EmbeddableEntryTypes slice) are candidates; the Go-side shouldEmbed then applies the severity-aware refinement.
Gotchas
- Ollama dependency. No Ollama = sparse-only recall (no vector similarity). Set
KEEPER_OLLAMA_URL, plusOLLAMA_MODELS="/Volumes/SSD 990 PRO/ollama-models"(external SSD) andollama servebefore./dev.sh startwhen testing locally. Check the active mode viaGET /healthz(episodicfield) orcrewship doctor. - Embedding is best-effort. An embedder error during a
sweepOncepass logs and skips; the entry is retried next sweep. - Cosine on small sets is fine. Don’t pre-optimise for a vector DB until the per-agent scan latency exceeds 50ms.
Related
- Crew Journal — source of entries.
- Consolidate — the nightly workers that produce many of the embeddable entry types (
summary.generated,memory.consolidated).