A personal memory operating system: capture a thought, file it by heat, surface it at
the right moment. This is the Turborepo monorepo that implements the architecture in
architecture/.
Four verbs — capture · remember · inform · ask — over one memory substrate. Every memory carries a recomputed heat score that decides its storage tier and recall rank.
A plain-English tour — no code required. It's modelled on how human memory actually
behaves: you jot things down without thinking, the important stuff stays top-of-mind,
the rest quietly fades, and sleep tidies it all up. Each part below links to its full
write-up in architecture/ if you want to go deeper.
flowchart TD
You([👤 You]) -->|"jot a thought (type or speak)"| Capture
subgraph Brain["🧠 TheBrain"]
Capture["📥 Capture<br/>saves instantly"]
Encoder["🔎 Understanding<br/>what kind of thing is this?<br/>how important? who/what is it about?"]
Memory["🗂️ A memory<br/>your note + what the brain worked out"]
Heat["🌡️ Heat & fading<br/>used → stays close · ignored → drifts back<br/>(nothing is ever truly deleted)"]
Sleep["🌙 Nightly tidy-up<br/>merge duplicates · form bigger-picture knowledge · let stale fade"]
Capture --> Encoder --> Memory --> Heat
Heat --> Sleep --> Heat
end
Memory --> Ask["❓ Ask<br/>answers from your memories,<br/>honestly (or says 'not sure')"]
Heat --> Inform["🔔 Inform<br/>the right nudge at the right time"]
Ask --> You
Inform --> You
Everything is built on four simple actions (§01 — the four verbs):
- Capture — jot a thought (typed or spoken). It's saved the instant you hit send — you never wait for the brain to "think".
- Remember — the brain quietly holds onto it and keeps what matters within reach.
- Inform — it proactively surfaces the right thing at the right moment (a reminder, a heads-up).
- Ask — you ask a question in plain language and it answers from what you told it.
-
📥 Capture — writing is instant, thinking happens later. The moment you save a thought, you're done. All the heavy understanding happens quietly in the background, so the app never makes you wait. (§04 — the write path)
-
🔎 Understanding (the encoder) — making sense of each thought. Behind the scenes the brain reads your note and works out what kind of thing it is (a fact, an event, a to-do, a feeling, a how-to), how important it seems, and who or what it's about. That's what later lets it connect related memories. (the encoder)
-
🗂️ A memory — your note, enriched. Each memory is your original words plus everything the brain figured out about them. Your raw words are always kept as the source of truth. (§11 — the memory object)
-
🌡️ Heat & fading — what stays close, what drifts back. Every memory has a "heat". Things you actually use stay warm and near the front; things you never touch slowly cool and drift toward the back — just like real forgetting. Crucially, fading only makes a memory harder to bump into, it's never actually deleted. (§06 — the tiering engine)
-
❓ Ask — answering honestly. When you ask something, the brain looks through your memories several different ways — by wording, by time, and by association ("what connects to this?"). If the first look comes up short it follows the threads a little further — the "it'll come to me" feeling. It answers only from what you actually saved, shows you which memory each answer came from, and would rather say "I'm not sure" than make something up. (§05 — the read path)
-
🔔 Inform — nudges, not spam. Reminders and heads-ups are scored so only the ones that genuinely matter interrupt you; the rest wait for a quiet moment or a morning summary. It stays silent overnight, and it learns: if you keep dismissing a kind of nudge, it backs off. (§07 — scheduling & notification)
-
🌙 The nightly tidy-up (consolidation) — the "sleep" window. Once a day, when nothing's going on, the brain consolidates: it merges fragments of the same moment ("call mom", "mom's birthday Friday", "buy flowers" all become one richer memory), forms bigger-picture knowledge from clusters of related notes, and lets the truly unused fade — the way sleep strengthens what mattered and clears the rest. Every merge is reversible. (§08 — consolidation)
-
🔒 Privacy — your memories are yours. One person's memories are strictly walled off from everyone else's, anything you mark private never leaves your device, and deleting is reversible (a grace period, not an instant shredder). (privacy foundations)
You say "lunch with Priya at Blue Bottle on Friday" → it's saved instantly → in the background the brain notes it's an event, about Priya and Blue Bottle, this Friday → because it's fresh and useful it stays warm and close → on Friday morning it nudges you → weeks later you ask "where did I meet Priya recently?" and it answers "Blue Bottle, on Friday the 5th" and points at that exact memory → that night, if you jotted three things about the same lunch, they get merged into one tidy memory.
Want the deep version? Start at the architecture deep-dive index (13 illustrated sections) or the one-page overview. The technical breakdown of this same system is in Architecture below.
Phase 0 ("prove the loop") is complete — the exit gate
#73 closed on 2026-06-30 with all 22
Phase 0 issues done. The five §13.2 dogfood criteria (daily unprompted use, capture →
recallable < 10s, a week of on-time reminders, ACK p95 < 400ms, one correct "forgotten"
recall) gated it. ACK p95 stays measured, not asserted: every POST /capture records
its latency into a bounded in-memory reservoir
(apps/api/src/modules/memory/ack-latency.ts), exposed at GET /admin/stats →
captureAckLatency.p95.
Phase 1 ("make it a brain") is in progress. All eight design ADRs are accepted and
ticketed (see the ADR index); most of the substrate
has landed on main:
| Plane | State |
|---|---|
| Encoder pipeline (ADR-0001) | Partial — summary + content-hash re-embed, entity/tag extraction, real salience/confidence, attention gate shipped. Open: real 5-type classifier #96, per-type typeAttrs #100, multi-object split #102, encoder_version backfill #103. |
| Model router + cost guards (ADR-0002) | Done — fast/strong class routing, per-user & per-route budget caps, per-call cost telemetry at /admin/stats. |
| Privacy foundations (ADR-0003) | Done — type-enforced userId scoping on every read, soft-delete tombstone + audited purge, encoder privacy fence (design doc). |
| Notification policy (ADR-0004) | Partial — unified delivery + idempotent key, quiet-hours hold, token-bucket backpressure, morning digest shipped. Open: triage scorer #115, deadline-decay ramp #119, dismissal feedback loop #120. |
| Heat / tiering engine (ADR-0005) | Done — per-type half-lives, on-access re-warm to HOT, scheduled per-user recompute + re-tier, hysteresis + dwell, TTL-accelerated forgetting GC (never deletes). |
| Retrieval doorways (ADR-0006) | Done — keyword (FTS + pg_trgm), temporal (B-tree range), and graph (entity/edge tables + recursive-CTE walk) retrievers, all owner-scoped, all in Postgres. |
| Read-path state machine (ADR-0007) | In progress — query planner shipped; ask still answers from the vector doorway alone until fusion lands: RRF #141 → rerank #142 → grounded reconstruction + citation verifier #143/#144 → confidence bands #145 → spreading activation #146 → recall-is-rewrite #147 → held-out eval #148. |
| Nightly consolidation (ADR-0008) | Not started, fully ticketed — epic #15: harness #197 → J1 #198 → J2 #199 · J3 #200 · J5 #201 · J6 #202 · reversal #203; waits on the encoder classifier (#96) writing real types to consolidate. |
| Multi-agent system | Design pass filed — #205 (→ ADR-0009) under epic #17. |
Phase 1 exit criteria (§13.2) — tracked on the gate issue #204 (the successor to #73); closing it is the signal to start Phase 2 ("make it a company"):
- Proactive recall fires usefully: ≥ 1 unprompted surfacing/week rated "glad it told me".
- Tiering cuts cost: > 70% of the corpus demoted to WARM/COOL, HOT recall sub-200ms.
- Nightly consolidation runs unattended for 14 nights with zero data-loss incidents.
- Graph-walk recall beats pure-vector on a held-out question set (#148).
- Notification dismissal rate < 20%.
Still deliberately deferred to Phase 2 (epics filed, not started): the vault / per-memory encryption (#21), dedicated Qdrant + Neo4j (#20), the CI-gated recall eval harness (#22), and the edge/product/cognition service split (#19).
Two clients talk to one Express modular monolith. Writes are acknowledged fast and the heavy work (summarize + extract + embed) is handed to durable pg-boss queues backed by Postgres, so a capture never blocks on a model call. Every model call routes through the model router (fast/strong class per call site, per-user budget caps, cost telemetry), and all AI dependencies sit behind provider interfaces that fall back to deterministic fakes when no key is set — dev, CI, and tests run fully offline.
graph TB
subgraph clients["Clients"]
mobile["📱 Mobile · Expo 56<br/>voice-first capture"]
admin["🖥️ Admin · Next.js 16<br/>ask · browse · ops dashboards"]
end
subgraph api["apps/api — Express 5 modular monolith"]
direction TB
auth["auth<br/><i>JWT access+refresh · bcrypt</i>"]
memory["memory<br/><i>capture · remember · ask<br/>heat · tiers · doorways</i>"]
notif["notifications<br/><i>reminders · quiet-hours ·<br/>backpressure · digest</i>"]
adminmod["admin<br/><i>service status · stats ·<br/>ACK p95 · model cost</i>"]
health["health<br/><i>liveness</i>"]
router["platform/model-router<br/><i>fast/strong classes ·<br/>budget caps · cost log</i>"]
end
subgraph shared["Shared packages"]
types["@thebrain/types<br/><i>zod contracts</i>"]
core["@thebrain/core<br/><i>heat fn · hysteresis ·<br/>retrieval scoring</i>"]
end
subgraph data["Stateful infra — one Postgres"]
pg[("Postgres + pgvector<br/><i>HNSW cosine · FTS GIN ·<br/>pg_trgm · entity/edge tables</i>")]
queue{{"pg-boss queues<br/><i>encode · voice-encode · reminders ·<br/>heat-recompute · digest-flush · purge</i>"}}
end
subgraph providers["Provider interfaces — real-when-keyed, else fake"]
embed["Embedding<br/><i>Voyage · OpenAI · Fake</i>"]
llm["LLM<br/><i>Claude · OpenAI · Fake</i>"]
stt["Transcriber<br/><i>Groq · Fake</i>"]
push["Push<br/><i>Expo · Fake</i>"]
end
mobile -->|"REST /api · JWT"| api
admin -->|"REST /api · httpOnly cookie"| api
memory --> pg
auth --> pg
notif --> pg
memory -->|"enqueue encode"| queue
notif -->|"enqueue delayed reminder"| queue
queue -.->|"workers drain"| memory
queue -.->|"worker fires"| notif
memory --> router
router --> embed
router --> llm
memory --> stt
notif --> push
embed --> pg
api -.->|"validate at edge"| types
memory -.-> core
clients -.-> types
classDef store fill:#1f2933,stroke:#9aa5b1,color:#e4e7eb;
classDef ext fill:#243b53,stroke:#829ab1,color:#e4e7eb;
class pg,queue store;
class embed,llm,stt,push ext;
Recall doorways. The memory store is reachable through four owner-scoped retrievers
(ADR-0006), all inside the one Postgres: vector (pgvector HNSW cosine over the
summary embedding), keyword (FTS websearch_to_tsquery + pg_trgm fuzzy fallback),
temporal (B-tree range + last/first occurrence), and graph (entities +
memory_entities edge tables walked by a recursive CTE, depth ≤ 2). The query planner
(ADR-0007) classifies each question into a retrieval plan; fusing the doorway lists
(RRF → rerank → confidence bands) is the in-flight remainder of Phase 1's read path —
until it lands, ask answers from the vector doorway alone.
Heat & tiers. Every memory carries a heat score — exp(−λ·Δt) · log(1+freq) · salience with a per-type half-life (prospective 3d → semantic 365d) — bucketed into
five tiers (hot ≥ 0.60 · warm ≥ 0.30 · cool ≥ 0.12 · cold ≥ 0.04 · frozen). A
scheduled pg-boss job recomputes stale heat per user in 500-row batches and re-tiers
under a hysteresis dead-band + per-tier dwell; any recall re-warms straight to HOT.
Past ttl_at, the forgetting GC accelerates decay — it demotes and de-prioritizes,
but never deletes. COLD/FROZEN are computed bands only until object storage lands
(Phase 2).
The write path returns immediately; encoding happens asynchronously so the memory becomes searchable a moment later. The encode worker runs a staged pipeline — attention gate, summarize, entity/tag extraction, embed-off-summary, graph sync, heat — with every off-box step behind the privacy fence: a private memory's text never reaches a third-party provider (it stays recallable via the in-Postgres keyword doorway instead).
sequenceDiagram
autonumber
participant U as 📱 User
participant API as memory module
participant Q as pg-boss (encode)
participant E as Providers (via model router)
participant DB as Postgres + pgvector
Note over U,DB: capture (write — fast ack)
U->>API: POST /capture (text, or voice→STT transcript)
API->>DB: INSERT memory (status=active, encodeState=pending)
API->>Q: enqueue encode(memoryId, userId)
API-->>U: 202 Accepted (id) — returns before encoding
Q->>Q: attention gate (drop filler / self-cancel / near-dup)
Q->>E: summarize + extract entities/tags (fast class, privacy-fenced)
Q->>E: embed(summary) — skipped on content-hash cache hit or fence
E-->>Q: vector(1024) + summary + entities[] + tags[]
Q->>DB: UPDATE memory · upsert entities + memory_entities edges · heat
Note over U,DB: ask (read — grounded answer)
U->>API: POST /ask (question)
API->>API: query planner → retrieval plan
API->>E: embed(question)
API->>DB: vector doorway (cosine, owner-scoped — fusion of all four doorways is in flight, #141+)
DB-->>API: top-k memories above RECALL_MIN_SCORE
API->>E: answer using only retrieved public context (strong class)
E-->>API: grounded answer (or "I don't know")
API-->>U: answer + citations
API->>DB: re-warm hit memories → HOT (access bump)
TheBrain/
├── apps/
│ ├── admin/ Next.js 16 — operations & "ask" surface
│ ├── mobile/ Expo 56 (expo-router) — the capture surface
│ └── api/ Express 5 — modular monolith (auth + memory + health)
├── packages/
│ ├── types/ @thebrain/types — zod contracts shared across every surface
│ ├── core/ @thebrain/core — heat fn, hysteresis, retrieval scoring
│ ├── typescript-config/ shared tsconfig bases
│ └── eslint-config/ shared flat ESLint configs
└── architecture/
├── mnemo-deep-dive/ the master spec — 13 chapters (§01–§13)
├── decisions/ ADRs 0001–0008 (see the index README there)
└── designs/ implementation design docs for landed changes
All TypeScript. Cross-platform shapes (the memory object, the four verbs, auth DTOs, the
heat model) live once in @thebrain/types and are consumed by the API and both clients —
one source of truth, validated at the edge with zod and inferred everywhere else.
apps/api is a single deployable composed of self-contained modules under src/modules/*.
Each module exposes an AppModule ({ name, basePath, router }) and owns its routes →
controller → service → repository. Adding a feature means writing a module and registering
it in src/app.ts; nothing else changes. Each module is independently extractable into its
own service later (the path to the cognition/edge split in the spec).
Included modules (plus src/platform/model-router, the cross-module chokepoint every
LLM/embedding call routes through):
- auth — register / login / refresh / logout / me, JWT access+refresh, bcrypt hashing,
requireAuth&requireRoleguards reused by other modules. - memory — the four verbs (
captureincl. voice, list/remember,ask, softDELETE), owner-scoped and heat-aware; owns the encode/heat-recompute/purge workers and the four retrieval doorways. - notifications — reminder scheduling and delivery: push-token registration, quiet-hours hold, token-bucket backpressure, morning digest coalescing, idempotent delivery keys.
- admin —
GET /stats(user/memory counts by tier, capture ACK p95, model cost) andGET /health(backing-service probes; redis/nats reportreserved). - health — liveness.
- Node ≥ 22
- pnpm 10 (
corepack enable)
pnpm install
# copy env templates
cp apps/api/.env.example apps/api/.env
cp apps/admin/.env.example apps/admin/.env.local
cp apps/mobile/.env.example apps/mobile/.env.local
# run everything (turbo runs each app's dev task)
pnpm devPer-app:
pnpm --filter @thebrain/api dev # http://localhost:4000/api
pnpm --filter @thebrain/admin dev # http://localhost:3000
pnpm --filter @thebrain/mobile dev # Expo dev serverMobile API URL. The Expo app reads its API base URL from EXPO_PUBLIC_API_URL
(Expo's public-env convention), falling back to http://localhost:4000/api when
unset — so it works out of the box for local dev. To point a device/simulator at
a different API (your machine's LAN IP, or a deployed URL), set it in the
gitignored apps/mobile/.env.local rather than editing source:
# apps/mobile/.env.local
EXPO_PUBLIC_API_URL=http://192.168.1.50:4000/api # e.g. your LAN IP for a physical deviceDev seed (optional). With a database up and migrated, load a demo user plus ~30 realistic memories (every type and tier) so the memory browser has something to show:
pnpm --filter @thebrain/api seed # login: demo@thebrain.dev / demoThe seed is dev-only — it's idempotent (re-running rebuilds the same corpus) and
refuses to run when NODE_ENV=production.
A single command brings up the whole local stack in containers: the backing service
(Postgres + pgvector) plus the app images. The job queue runs on Postgres via pg-boss,
so no separate broker is required — the redis and nats compose entries are reserved for
future use and currently report unconfigured.
Storage is text-only for now (#46). Voice capture still records and transcribes audio, but the audio blob is never persisted — it's read by speech-to-text, written as the transcript
text, then discarded. There is no object-storage service.
cp .env.example .env # connection strings + placeholder secrets for the stackServices only — run the backing services in containers and the apps from source (fastest inner loop, hot reload):
pnpm infra:up # postgres, redis, nats (detached, each with a healthcheck)
pnpm dev # api + admin from source, pointed at the containers via apps/api/.env
pnpm infra:logs # tail service logs
pnpm infra:down # stop & remove the service containersFull stack — build and run the app images too (reproducible, what CI/prod resemble):
pnpm stack:up # backing services + api & admin images (built, detached)api and admin are built from their own multi-stage Dockerfiles (turbo prune --docker
for a lean per-app context) and wait for the backing services to report healthy via
depends_on: condition: service_healthy. The app services live under the compose apps
profile, so pnpm infra:up brings up services only while pnpm stack:up adds the apps.
| Endpoint | URL |
|---|---|
| API | http://localhost:4000/api/health |
| Admin | http://localhost:3000 |
| NATS monitoring | http://localhost:8222 |
| Metabase (BI) | http://localhost:3001 |
Mobile (Expo) is intentionally excluded from the containerised stack — it runs via Expo tooling (
pnpm --filter @thebrain/mobile dev) on the host, not as a server container.
Metabase (open-source edition) is wired in for ad-hoc
analytics over the memory data — heat-tier distributions, capture/recall volume,
encode-queue throughput, and any SQL question you want to graph. It lives under its own
opt-in metabase compose profile, so it never slows down the lean pnpm dev loop.
pnpm metabase:up # postgres (if needed) + Metabase on http://localhost:3001
pnpm metabase:logs # tail Metabase logs
pnpm metabase:down # stop & remove just the Metabase containers (leaves infra running)Metabase stores its own application state (questions, dashboards, users) in a dedicated
metabase database on the same Postgres container — not the eval-only embedded H2.
A one-shot metabase-db-init container creates that database idempotently on every
metabase:up, so it works whether or not your Postgres volume already exists.
On first launch, finish setup in the browser, then connect it to the analytics data:
- Open http://localhost:3001 and create the admin account.
- Add a database → PostgreSQL with:
- Host
postgres· Port5432· Databasethebrain - User
thebrain· Passwordthebrain(match your.env)
- Host
- Metabase syncs the schema and you can start building questions against
memories,entities,memory_entities, and the rest.
Use the compose service hostname
postgres(notlocalhost) — Metabase reaches Postgres over the sharedthebraindocker network. The host port for the UI is configurable viaMETABASE_PORTin.env(default3001, since3000is the admin app).
- Managed Postgres (Neon): AES-256 encryption at rest is on by default — no action needed.
- Local dev (Docker): data is stored unencrypted on the host volume. This is acceptable for development; never run local dev with real personal data.
- Audio (voice capture): text-only storage for now (#46) — uploaded audio is transcribed then discarded, never persisted. The
rawRefcolumn is reserved (always null) for when object storage returns.
Never commit a real secret. .env is gitignored. .env.example holds only placeholder values — copy it and fill in real secrets locally:
cp apps/api/.env.example apps/api/.env
# Generate real JWT secrets:
openssl rand -hex 32 # paste into JWT_ACCESS_SECRET
openssl rand -hex 32 # paste into JWT_REFRESH_SECRETIn production, inject secrets via your secret manager (Doppler, AWS Secrets Manager, etc.) rather than .env files.
A memory marked isPrivate: true is never sent to a third-party model. This is
enforced structurally, not by convention (ADR-0003, design doc):
- Every provider carries an
isThirdPartyflag, and the encoder's three off-box steps (summarize, entity/tag extraction, embedding) each pass through a fenced choke point that no-ops for private memories on third-party providers — so a private memory gets no vector, no third-party summary, and no extracted entities. - It stays findable through the in-Postgres keyword/FTS doorway (owner-scoped, never leaves the database) and is stripped from every LLM answer context on recall.
- Separately, user isolation is compile-time enforced: every repository read requires
userIdand pushes it into the SQL predicate — a cross-user read is neither expressible nor returnable.
The Phase 2 vault (#21) adds on-device embedding so private memories regain semantic recall without any network call.
| Command | What it does |
|---|---|
pnpm build |
Build every package & app (Turbo, dependency-ordered) |
pnpm dev |
Run all dev servers |
pnpm lint |
ESLint across the workspace |
pnpm type-check |
tsc --noEmit across the workspace |
pnpm format |
Prettier write |
Shared packages (types, core) compile to dist/ and are built before the apps via
Turbo's ^build dependency.