Welcome to the first devlog. Marketing posts tell you what Eigen Mesh does; this series is about how it's built — the real architecture, the real file names, the decisions we'd defend and the ones we're still side-eyeing. Today: the whole system, end to end.
The one-sentence version
Eigen Mesh is a SvelteKit app with a Postgres that does three jobs (relational, vector, graph), a three-tier memory pipeline (hot capture → background enrich → nightly consolidation), and an MCP server that lets your AI tools talk to your memories — all deployable as two Docker containers.
The stack, honestly
SvelteKit 2 with Svelte 5 runes forced project-wide. Drizzle ORM against PostgreSQL 16 in a custom Docker image with pgvector, Apache AGE, pg_cron, and pg_net. Better Auth for sessions. Paraglide for i18n. A PWA service worker for offline capture and push. adapter-node behind docker-compose: one app container, one db container. Self-hosting is the primary story — install.sh, a compose file, done. Managed hosting runs the exact same codebase.
No Redis. No queue service. No separate vector DB. No separate graph DB. This is deliberate, and most of the architecture falls out of it.
One Postgres, three jobs
The storage layer is the bet the rest of the system is built on:
- Relational — users, sessions, API keys, thoughts, activity logs, billing. Normal Drizzle-managed tables.
- Vector — 1536-dim embeddings in pgvector columns with HNSW indexes, on thoughts, entities, temporal events, and community summaries.
- Graph — entities, relations, communities in Apache AGE (OpenCypher inside Postgres), graph name
eigen_graph.
The reason is transactional coherence and operational simplicity: the same transaction that inserts a thought can carry its embedding; pg_dump backs up everything; and hybrid retrieval is one SQL query instead of a distributed systems project. We wrote a whole post about the storage layer trade-offs separately — this devlog is about what happens on top of it.
Tier 1: Capture is fast because capture does almost nothing
The hot path of a memory product has one job: don't lose the thought and don't make the user wait.
On the client, submitting a thought on /capture doesn't do a blocking fetch. It goes into an IndexedDB queue (eigen-capture-queue) that drains serially — so you can capture on a train, offline, and the service worker's Background Sync tag flushes the queue when you're back. Progress streams back over NDJSON as tier-1 stages complete.
On the server, captureThought() in src/lib/server/capture/service.ts does the minimum in one transaction: insert the thought row, build lexical_text and the generated FTS column, anchor a best-effort Thought node in AGE, schedule enrichment. Tens of milliseconds. No embedding yet — that's the next tier's problem.
One rule we don't break: the verbatim invariant. There's an interpret/confirm step where the LLM can propose a cleaned-up version of what you said, and you get a confirmation modal if it deviates — but raw_text is never overwritten. What you said is what's stored; interpretation is a layer on top, not a rewrite.
Tier 2: Enrichment is where the thought becomes memory
A background FIFO worker picks up queued thoughts and runs the enrich pipeline (capture-enrich-worker.ts → enrich.ts): classify against your personal ontology, embed the text, resolve entities, extract relations, sync nodes and edges into AGE, update the materialized thought_entity / thought_neighbor tables, extract temporal events, and — if the "thought" was actually three notes in a trench coat — split it into linked text files.
Two details worth a devlog mention:
- Embeddings are compressed first. Before text hits the embedding endpoint, a deterministic compression pass (configurable intensity:
lite,full,ultra) strips filler. Cheaper, and empirically better retrieval on casual phrasing. - The embeddings DB-only boundary. Vectors are computed via the LLM gateway, stored in Postgres, and used in SQL distance queries — full stop. They never appear in MCP responses, chat payloads, LLM prompts, or logs. There are literal sanitizer functions (
sanitizeMcpToolResult,sanitizeChatMessages, a strip-embeddings module) enforcing this. The one intentional exception: the embedding-snapshot endpoint that feeds the 2D/3D memory map visualization.
Retrieval: hybrid, weighted, reranked
retrieveEvidence() is the canonical entry, and the query path is: embed the query once → run three channels in parallel (pgvector ANN, Postgres FTS, and ANN over community summary embeddings for domain routing) → fetch bundles → merge with explicit weights → LLM rerank.
Worth being precise about two things, because architecture docs lie about this stuff all the time:
- It's weighted score fusion, not RRF. Thought similarity dominates (0.42), community similarity routes (0.25), entity similarity nudges (0.10), plus smaller signals. An RRF helper exists in the codebase — it's legacy and unused. We keep it around as a museum piece and a warning.
- No live graph traversal on the hot path. Graph structure enters retrieval through materialized tables —
thought_entity,thought_neighbor,entity_top_thoughts,community_bundle,community_summary— all precomputed during enrichment and consolidation. The exception is temporal-intent queries ("what was I doing last Tuesday"), which do run live AGE traversals over Event/INVOLVES edges, because that's genuinely a graph problem.
The rerank is a listwise LLM pass over the top 15 candidates plus 5 lexical reserve slots — and it's skipped entirely when there are ≤1 candidates or the top-2 score gap is already ≥ 0.15. Reranking is the expensive part of retrieval, so the system only pays for it when ranking is actually ambiguous.
And one subtle behavior: retrieval feeds back into memory. Every hit bumps access_count and salience_score — memories you actually recall get stronger, like the real thing.
Tier 3: Sleep is a feature, not a cron job
Nightly consolidation is what turns a pile of enriched thoughts into a structured memory. The job plan runs in a fixed order:
salience_compute → ontology_prune → repair_canonical_entity_types →
dedup_canonical_entities → repair_entity_relations → community_detection →
community_summaries → community_bundles → retrieval_links_backfill →
thought_retrieval_features
Two phases, conceptually. DeepSleep: salience decays at 0.97/day after a 7-day grace period (unresolved open loops rise at 0.15/day instead — the Zeigarnik effect as a database job), unused ontology entity kinds get pruned, duplicate entities get merged. REM: community detection over entity-relation edges, then L1 summaries and bundles per community. The community hierarchy is L2 leaf → L1 domain → L0 worldview root, and it's what lets retrieval route "stuff about work" before it ever looks at individual thoughts.
Scheduling is dual-layer, and this is the part people get wrong: pg_cron fires a nightly HTTP POST to /api/admin/consolidate (registered on container boot), and separately an in-app ticker wakes every 60 seconds to enqueue and drain per-user jobs from user_job_queue. Nightly batches plus incremental dirty-community refreshes after each enrichment — the graph never gets more than minutes stale.
MCP: your AI tools get the same memory you do
The MCP server lives at /api/mcp — streamable HTTP transport, stateless per request, authenticated by session or Bearer eigen_<hex> API key. Ten tools are exposed to external clients: capture_thought, retrieve_thoughts, edit_thought, delete_thought, and a GTD project suite (list_projects, get_project_timeline, order_task_in_project, set_project_milestone, set_project_deadline, generate_project_plan).
The in-app chat agent gets a bigger toolbox — grounded Q&A plus the full text-file CRUD set — because some operations should stay inside the trust boundary of the app. The principle: MCP tools run the same pipeline as the UI. A thought captured from Claude hits tier 1, tier 2, and tier 3 identically to one typed into /capture. Your AI tools aren't a side door; they're first-class users of your memory.
Tenancy: RLS where it works, discipline where it doesn't
Every request pins a Postgres connection, sets SET ROLE eigen_app and app.current_user_id, and Row Level Security policies do tenant isolation at the database level — scoped through AsyncLocalStorage so application code can't accidentally escape its user. The honest caveat: Apache AGE graphs can't do RLS, so tenant isolation there is enforced in the application layer — every Cypher query carries user_id, wrapped in runTenantScopedCypher(). That's a discipline boundary, not a database guarantee, and it's documented as such in our conflicts ledger rather than swept under the rug.
On top of that: envelope encryption. A TENANT_MASTER_KEY wraps per-tenant data encryption keys covering thought content, capture sessions, and BYOK provider keys. Hostile-database-read is a threat model we take seriously.
How we know it works: evals, not vibes
There's a DB-backed eval harness with a QA catalog, smoke and full modes, LongMemEval runs, and a graph-scale benchmark that measures gateway cost and latency against corpus size — plus a dev-only /eval UI. Memory products fail quietly; the evals exist so we notice.
What we'd do differently
- The weighted fusion is hand-tuned. The score weights work, but they were calibrated by judgment plus evals, and they're not learned. A feedback-driven weighting pass is on the list.
- App-layer tenancy on the graph works and is audited, but we'd rather it were a database guarantee. We're watching AGE's RLS story.
- The dual scheduler (pg_cron HTTP trigger + in-app ticker) has two failure modes where one would do. It survives app restarts and DB-only contexts, which is why it's still there — but it's the part of the system with the most "why is it like this" in code review.
The shape of the thing
Everything above is one idea applied recursively: fewer moving parts, more guarantees per part. One Postgres instead of three databases. One deploy instead of a fleet. Materialized graph artifacts instead of live traversal. Explicit weights instead of a ranking black box. A memory system should be boring infrastructure that happens to remember everything — that's the bar we build to.
Next devlog: the capture queue in detail — IndexedDB, serial drain, Background Sync, and how a thought survives you closing the laptop mid-sentence.