Concepts, formulas, and the retrieval pipeline.
| Layer | Biological analog | What it stores | Lifetime |
|---|---|---|---|
| SensoryStream | Sensory / echoic memory | Raw user + assistant messages | Transient — pruned after extraction |
| CognitiveRecord | Declarative (episodic + semantic) | Classified facts: decisions, preferences, code facts | Long-term, subject to decay + citation boost |
| ContextualFocus | Working memory | Heat-scored "scenes" grouping related records by task | Medium — evicted when heat cools |
| CoreIdentity | Core beliefs / identity schema | User profile + absolute instructions | Permanent — prepended to system prompts |
graph TD
Sensory[SensoryStream] -->|consolidation| Cognitive[(CognitiveRecord)]
Cognitive -->|focus builder| Focus[ContextualFocus]
Cognitive -->|identity distiller| Identity[CoreIdentity]
The same stack read as a retention hierarchy — each level is more distilled, more durable, and cheaper to recall than the one below it. Consolidation pushes content up; decay and eviction pull it down/out.
| Level | Backing store | Holds | Retention |
|---|---|---|---|
| L0 — sensory | SensoryStream | Raw turn-by-turn messages | Transient: dropped once the extractor has consumed the row |
| L1 — cognitive | CognitiveRecord (incl. lesson) |
Classified, deduped facts/decisions/preferences/lessons | Long-term, per-type half-life decay; citations + lesson corroboration push priority back up |
| L2 — working / grouped | ContextualFocus scenes + source/topic trees |
Heat-scored task scenes; per-document and per-topic summary nodes | Medium: scenes evict as heat cools; tree leaves seal into summaries |
| L3 — identity / global | CoreIdentity + global tree |
Distilled user profile + hard rules; cross-topic rollup digests | Permanent: prepended to every system prompt; global is the top of the seal hierarchy |
Recall reads top-down — L3 identity is always present, L2 summaries answer
"what was this about", and the pipeline drills into L1/L0 detail only when a
query needs it (keeping the token budget flat; see BENCHMARKS).
The tree seal hierarchy (source → topic → global) is how L1 detail is
compacted into L2/L3 digests — see Memory tree below.
High-bandwidth dialogue buffer. Stores raw user/assistant messages immediately and flags rows as processed once the cognitive extractor has distilled them, so the same content doesn't get re-injected into prompts.
Each record carries a classification (instruction, architecture_decision,
tool_preference, codebase_fact, task_state, skill_context, etc.),
a priority (0–100), timestamps, and links into the knowledge graph.
Records cluster into scenes when they share entities/topics. Each scene has a heat score that decays when the scene is inactive. A drift detector watches incoming records and triggers focus shifts when the task direction changes — cooling the old scene, pre-warming the new one.
A synthesized Markdown profile of the user (role, preferences, hard rules). Bypasses vector search — it's directly prepended to system prompts so identity stays stable across sessions.
Records decay exponentially on a half-life that's specific to their type:
| Type | Half-life |
|---|---|
instruction |
∞ (never decays) |
architecture_decision / security_policy
|
180 days |
codebase_fact |
60 days |
task_state |
14 days |
skill_context |
7 days |
When the agent finishes a turn, the CLI auto-runs memory_mark_cited with
the records it actually used (detected by content match against the final
answer). Two effects:
Boost cited:
Prune uncited: if a record is surfaced in recall but ignored 10+ times, it gets archived. Keeps the index high-fidelity over time.
graph TD
Query([User query]) --> FTS[FTS5 BM25]
Query --> Vec[Vector cosine]
Query --> Path[Filepath heuristic]
FTS --> RRF{Reciprocal Rank Fusion}
Vec --> RRF
Path --> RRF
RRF --> Blend[Priority blend<br/>decay + freshness]
Blend --> Affinity[Intent affinity]
Affinity --> Rerank[Cross-encoder rerank]
Rerank --> Judge[LLM relevance judge]
Judge --> Graph[2-hop graph expansion]
Graph --> Out([Prompt context])
Three System-1 retrievers run in parallel; RRF merges them. System 2 then blends in the decayed priority, intent affinity, an optional cross-encoder reranker, an optional LLM judge that drops candidates that aren't actually relevant (the reranker only reorders), and a 2-hop graph walk for spreading activation.
The recall stages assume focused records. A long blob (e.g. an imported multi-turn session) breaks all of them — the reranker truncates it, the judge reads only its first 600 chars, and one embedding over thousands of tokens is a blurry average. Four knobs keep long records working:
- Chunk on import —
memory_importsplits records overBRAINROUTER_IMPORT_CHUNK_CHARS(default 1500) into chunk records (child id${parent}::c{i}; recall rolls a chunk back to its parent). - Embed on import — imported records are embedded immediately
(
BRAINROUTER_IMPORT_EMBED, default on) so vector search isn't left waiting on the background re-embed sweep. - Reranker doc cap —
BRAINROUTER_RERANKER_MAX_DOC_CHARS(default 1500) lets the cross-encoder score a whole chunk instead of the old 700-char window. - Judge result floor —
BRAINROUTER_RELEVANCE_JUDGE_MIN_KEEP(default 1) stops the judge collapsing recall to 0 when it can't verify a long candidate.
On the LongMemEval benchmark (local gemma-4-e2b) these lifted the reranker
config from 0.27 → 0.80 recall_any@5 and the judge from 0.10 → 0.50, with no
regression on short-record corpora (MemBench, LoCoMo).
effectivePriority(r) = decayedPriority(r) * (1 + citationBoost) * freshnessBoost
finalScore(r) = rrfScore(r) * 30 * 0.7
+ (effectivePriority(r) / 100) * 0.3
* intentAffinity[type][intent]
* (1.2 if r.skill_tag == activeSkill)
| Knob | Behavior |
|---|---|
| Time decay | Type-specific half-life (see above). |
| Citation boost | +5% per cite, capped at +30%. |
| Freshness boost | 1.15× at age 0 → 1.0× at age 1 day. New captures surface immediately. |
| Intent affinity | detectTaskIntent(query) maps verbs (debug, fix, design) to per-type multipliers. |
| Skill boost | Score ×1.2 when record.skill_tag matches active skill. |
| Neural sparks | 2-hop spreading activation — records firing above threshold join the candidate pool. On by default; set BRAINROUTER_NEURAL_SPARK_ENABLED=false to disable propagation, Hebbian strengthening, and synaptic decay/pruning (recall falls back to the plain scored/reranked set). |
| Reranker | Cross-encoder (Cohere / vLLM /v1/rerank) replaces top-K ordering when configured. Per-doc input capped at BRAINROUTER_RERANKER_MAX_DOC_CHARS (default 1500 — covers a chunk). |
| Relevance judge | LLM-as-judge stage over the reranked finalists. Each candidate gets a binary verdict + reason. By default (BRAINROUTER_RELEVANCE_JUDGE_MODE=reorder, MEM-JUDGE2) approved candidates are promoted to the front and the rest kept — recall-safe, so the top-K slice gains precision without dropping recall below the retriever; set filter for the legacy drop-the-rejects behavior. The judge reads BRAINROUTER_RELEVANCE_JUDGE_DOC_CHARS (default 1200) of each candidate. Off by default — opt in with BRAINROUTER_RELEVANCE_JUDGE_ENABLED=true. Adds one LLM round-trip; on failure the reranker output passes through unchanged. Survives LM Studio's idle-model auto-unload by detecting the "model is unloaded" 400, waiting 1.5s, and retrying once (mirrors ModelLLMRunner). A result floor (BRAINROUTER_RELEVANCE_JUDGE_MIN_KEEP, default 1) backfills top pre-judge results so it never drops recall to 0 on long candidates it can't verify. |
memory_recall and memory_search accept an optional filters object:
filters: {
types?: string[]; // ['instruction', 'feedback']
scenes?: string[]; // ['Mobile App Build']
capturedAfter?: string; // ISO 8601
capturedBefore?: string; // ISO 8601
minPriority?: number; // 0–100
skillTag?: string;
}Filters constrain the candidate pool before ranking, so RRF computes ranks within the relevant scope instead of skewing toward globally top records.
The cognitive extractor parses JSON emitted by the LLM. Two real-world failures showed up frequently enough to warrant guard rails:
- Bad JSON escapes survive.
parseJsonWithEscapeRepair(brainrouter/src/memory/pipeline/cognitive-extractor.ts) doubles any\Xthat isn't a legal JSON escape and retries the parse, so Windows paths (C:\Users\…), Unix path segments like\bin/\target/\release, LaTeX literals, and regex content survive intact. The repair branch only preserves\",\\,\/as-is; everything else doubles. (Tradeoff: legitimate\nin repair-branch content becomes a literal two-char\n. Happy-path\nstill becomes a newline.) - LM Studio model auto-unload. Idle local models get unloaded;
the first request after that comes back as
400 {"error":"Model is unloaded."}. The extractor LLM runner and the relevance judge both detect this and retry once after 1.5s.
These two together mean a noisy upstream (idle local model, malformed JSON escapes) no longer drops a whole extraction batch.
During consolidation, new facts are scanned against existing records.
- Temporal update: "Node version is 18" → "Node version is 20" → the old
record is marked
supersededBy = newIdand itsinvalidAtis set. - Genuine conflict: "Auth uses OAuth2" vs "Auth uses SAML" → logged in the contradictions table for manual arbitration.
Skills (e.g. monorepo-migration, chrome-extensions) have keyword
triggers. When a trigger fires, the skill's memetic potential spikes:
with
Raw records and source chunks don't stay flat — they roll up into a three-domain tree so recall can return a summary instead of dozens of leaves, and so old detail can be sealed without being lost.
| Domain | One tree per… | Leaves | Grown by | Seals into |
|---|---|---|---|---|
| source | source document (file / transcript / imported doc) | that document's chunks | ingestion | topic |
| topic | topic / focus scene | scene-grouped record digests | scene autobuild | global |
| global | (singleton) cross-topic rollup | topic digests | autobuild cap | — (top) |
Lifecycle:
- Ingest — a file/transcript is chunked; a source tree is created with
those chunks as leaves (
reindexCodeSourcekeeps it fresh — see CLI-REINDEX). - Seal — when a bucket of leaves is hot/large enough, the autobuild
scheduler digests them into a summarized parent one domain up
(
source → topic → global).heatScoreand level thresholds drive when. - Recall — the pipeline can expand a matched summary node down to its leaves (drill-down) or stop at the digest, trading detail for tokens.
The domain rules + thresholds live in one place — memory/tree/policy.ts
(parentDomain, treeAutobuildEnabled) — kept deliberately separate from the
recall ranking so routing and autobuild plug in without touching scoring.
The vault (MEM-7) exports memory to a deterministic, redacted markdown
mirror with a content-hash ledger, so re-export is idempotent (same record →
same bytes → same hash → the ledger skips the write). Records land under
records/<id>.md, tree nodes under their own files, each with YAML frontmatter.
Every generated file carries a generated: brainrouter-vault frontmatter label
(TREE-VAULT, 0.4.5) so a future reviewed import/sync — and any human editing
the vault — can tell BrainRouter-authored mirror files apart from hand-written
notes. The redaction boundary (MEM-13) applies before write, so secrets never
reach the mirror. Reviewed write-back (importing edited vault files into memory
behind a review gate) is the planned next step; today the vault is export-only.
These are the six open questions
the federation work surfaced. Documented here so they don't get
re-litigated each cycle; cross-link from
federation.md when relevant.
Decision. Memory is shared by default across every host attached under the same BrainRouter API key. No per-record opt-in flag.
Rationale. The killer scenario (BrainRouter CLI captures a fact, Claude Code recalls it 30 s later in another terminal) only works if the share is implicit. Asking the user to mark each record "shareable" turns the feature into a privacy choreography exercise. Users who want isolation use a second API key — that's the policy boundary, not a per-record flag.
Constraint. Cross-userId visibility stays off forever. The
active_sessions registry, the session_inbox, and the recall
pipeline all scope by user_id; nothing in 0.4.0 leaks across the
multi-tenant boundary.
Decision. Working memory (the .brainrouter/work/<userId>/...
canvas, memory_working_* tools) is per-session and private to
the session that wrote it. A federated peer does NOT see another
session's working memory by default.
Rationale. Working memory is essentially scratch state — partial
plans, in-progress reasoning offloads, mermaid canvases mid-edit.
Cross-reading it would be louder than helpful (two CLIs hammering on
each other's half-written thoughts) and the existing
memory_working_offload / memory_working_context MCP tools already
scope by sessionKey.
Escape hatch. A peer that wants to read another session's
working memory can call memory_working_context with the explicit
target sessionKey — same MCP tool, scoped read. The default is just
"my session's view"; cross-reads are an explicit opt-in.
Decision. The default auth model is one user, many CLIs. The HTTP MCP server is single-tenant from the perspective of any given API key; multi-tenancy is achieved by running multiple users with distinct keys.
Rationale. Federation Stage 2's active_sessions table scopes
every row by user_id; the broadcast / inbox / heartbeat surfaces all
inherit that scope. This shape composes cleanly with the cloud
deployment model: each tenant gets their own brain instance, and
within that brain the federation surface is fully shared. Trying to
serve multiple tenants from one process would force per-row
authentication on every query — a larger surface than 0.4.0 should
carry.
Future. A multi-tenant MCP variant (one process, many tenants,
fine-grained ACLs) is intentionally deferred. When it ships it'll
ride the existing user_id foreign keys, not a new schema.
Decision. When two federated CLIs write to the same record
nearly simultaneously, the later updatedTime wins. No
locking, no merge attempt, no CRDT machinery.
Rationale. Memory records are append-mostly. Updates are rare,
and the dominant update pattern is "mark superseded" (one record
replacing another) rather than "edit in place". For the few in-place
update paths (memory_update, memory_evidence_add), the
last-write-wins semantics are the store's natural ordering and don't
require any client-side coordination. (The store is Postgres + pgvector
since ADR-007; SQLite has been removed. The engine opens its pool and
runs migrations lazily on first use, and closes it on shutdown.)
Cost. The losing write's content is lost. We accept this for
0.4.0 — the cost of building a real merge layer (CRDTs, vector
clocks, manual conflict resolution UI) is much higher than the
expected frequency of conflicts. The operation_log keeps an audit
trail so a clobbered update is recoverable manually.
Future. A Phase-5 memory-blackboard pipeline naturally tames
this — concurrent writes both land as kind=candidate_record
items on the blackboard and the dedup / contradiction agents
arbitrate before either commits. See
brain-agents.md.
Decision. session_heartbeat and session_inbox heartbeat-like
operations do not write to operation_log. The audit table only
captures cognitive writes + governance actions.
Rationale. 30 s × N peers × 24 h = 2,880 N rows/day just for "I'm still here" pings. With N=5 federated CLIs that's 14k rows/day of pure noise; over a month the audit table grows by 400k rows that carry zero forensic value. The active-session sweeper handles absence detection; explicit audit rows would be redundant.
Asserted by test. The brain integration suite includes
active-sessions.node-test.ts → "heartbeat does NOT write to memory_operations (audit volume guard)" — 10 heartbeats fired
against a fresh database leave the operation_log row count unchanged.
Decision. Federation works over both stdio and HTTP MCP transports. HTTP is the recommended path; stdio works with two caveats.
Caveats for stdio:
- No live-watch latency benefit.
/agents --remote --watchpolls every 2 s on stdio (no SSE — that's HTTP-only). Same UX, slightly higher floor on heartbeat-to-render latency. - One peer per stdio process. Each stdio MCP child is a single user, single brain instance — you can't multiplex stdio sessions the way you can with the HTTP server. Two terminals using stdio each spawn their own brain child.
Rationale. HTTP is the natural transport for federation (one
brain process, N clients), but stdio is the right default for
embedded / sandboxed use cases (Claude Code, single-shot
brainrouter run). The federation surface is identical on both;
only the connection-fan-out story differs.