Living Memory — From Event Streams to Memories with NATS · Mnemo Series 4/5
For the first three weeks Mnemo only had what I typed in manually. A few notes, a few decisions, a few deployments. Then I noticed it — Vault's events, Foreman's PR reviews, they were already there. I was writing them down myself instead of catching them. That week, Mnemo started to live.
Fourth in the series. The first three covered Mnemo's "why," its "core architecture," and its "mind." Now we get to what makes it come alive: event streams.
Mnemo's dead feeling
When Phase 2 wrapped up, Mnemo was technically working — I'd hooked it into Claude over MCP, and remember, recall, stats all functioned. Everything was green. But after a week of using it, an uncomfortable feeling crept in.
There was very little inside Mnemo.
Because every single thing that needed remembering, I was writing in myself. I'd tell Claude, "I uploaded this file to Vault today, remember it." I'd say, "Foreman left this comment on a PR, jot it down." In other words, Mnemo felt like a manually-fed journal.
That was wrong. Because:
- That file was already in Vault, already producing events.
- Foreman was already commenting on PRs, already producing events.
- I wasn't the reporter of these events — I could be the observer.
So for Mnemo to become real memory, it had to listen to the events inside the system — not wait for me to type them in.
That was the motivation behind Phase 4: make Mnemo not chatty, but attentive.
The philosophy of being event-driven
"Event-driven architecture" is often presented as a complex engineering problem — Kafka, schema registries, the saga pattern, eventual consistency. All of that is true in places. But underneath them is a very simple idea:
"Separate the one doing the work from the one listening."
Vault uploads a file. While it's doing that, it may not care whether anyone notices. But still — let it say so. Let it publish. Who's listening doesn't concern it.
Let Mnemo be the one who can listen. Let it choose which events to subscribe to. Vault shouldn't have to shout "trigger me too" at it. Mnemo just pulls messages waiting in NATS.
That separation — publishers and subscribers being unaware of each other — is what lets the system grow when a new service shows up, without changing the old ones. If my voice assistant wants to listen to Vault's events tomorrow, no Vault code has to change.
That's why I chose NATS, not REST callbacks. A webhook-based integration would have meant changing Vault's config every time a new consumer arrived. With pub/sub, it doesn't.
Two-stage processing: write fast, enrich later
When an event arrives, Mnemo has two things to do:
- Write the event to the database — fast.
- Generate an embedding and write that to the database too — slow (because it's a Voyage API call, 200–500ms).
The first instinct, of course, was to do both in a single stage: receive event → compute embedding → write to DB → ack. A straight line. But after five minutes of thinking I saw the problems:
- What if Voyage is temporarily down? The event gets NAK'd, NATS redelivers, Voyage is still down, infinite backpressure.
- What if Voyage returns 401 (wrong API key)? Forever redelivery, the whole consumer stalls.
- What if 30 events arrive at once? 30 sequential Voyage calls — 6–15 seconds total.
Splitting it into two stages solved all of them:
Stage 1 (consumer):
- Parse, format, write to DB with
embedding=NULL, ack. - No call to Voyage at all. Done in seconds.
Stage 2 (embedding worker):
- Every 5 seconds, fetch a batch (32 rows) where
embedding IS NULL. - One call to Voyage's batch endpoint — 32 texts embedded in a single HTTP request.
- Write the results back via
UPDATE.
Two nice side effects of the split:
- Batch efficiency: instead of 32 separate Voyage calls, one call — a 32:1 reduction in API requests. A serious cost and latency win.
- Resilience: even if Voyage is down for hours, the events stay in the DB with
embedding=NULL. When Voyage comes back, the worker drains the backlog. NATS never feels pressure.
And an important nuance: the MCP remember call still embeds synchronously. When a user/LLM consciously says "save this," we want the embedding ready right then. But the embedding for automatic events coming from Vault/Foreman can lag a little without harm. Two entry paths, two different SLAs. A conscious distinction.
The lesson this taught me: make every external-service-dependent job async, but don't be afraid to keep the "I want an answer now" path synchronous. Two paths can live in the same system under different disciplines.
Pull vs push consumer — who should hold backpressure?
In NATS a consumer can work two ways: push (NATS sends you messages and you process them in a callback) or pull (you call fetch(N) and NATS gives you N messages).
Push looks more "modern" — it matches the spirit of event-driven culture. But it has a blind spot: it hands backpressure control over to NATS.
If Mnemo's embedding worker is slow or hitting errors, my pull consumer's fetch(10) call just blocks. Messages pile up in NATS, but Mnemo doesn't drown. It sets its own pace.
In push, NATS keeps firing. If Mnemo can't keep up, the in-memory buffer swells, memory use grows. You can write defenses (max in-flight, ack timeout), but you're always a step behind.
Pull = "take as much as I need" = consumer-controlled pace. For a consumer that depends on external services — like Mnemo — it's the natural choice.
Dedup: a PostgreSQL unique constraint instead of Redis
In NATS JetStream a pull consumer has this property: if you don't ack a message, it gets redelivered. So if Mnemo crashes mid-event, NATS will resend the same event on restart. That means idempotency is mandatory.
First thought: "Put a Redis SET in front, keep the event IDs I've already seen there."
Then I stopped. Because that means Redis. Which means:
- A new stateful system (the rule from the previous post: every stateful system = a midnight page).
- Restart Redis itself and the in-memory state evaporates (unless you turn on persistence).
- You'd need to figure out a TTL/expiry policy after restart.
- A small consistency problem between Redis and PostgreSQL: what if Redis was written but Postgres failed?
I rejected Redis. Instead, I used what PostgreSQL already does: a UNIQUE constraint.
CREATE UNIQUE INDEX idx_memories_source_event
ON memories(source, source_event_id)
WHERE source_event_id IS NOT NULL;
Every time an event arrives, Mnemo just tries to INSERT. If the same (source, source_event_id) already exists, PostgreSQL returns a 23505 unique_violation. Mnemo silently treats that as an ack — "already processed, move on."
Wins:
- No new stateful system.
- Restart-safe (Postgres is durable).
- No race condition (atomic INSERT is guaranteed by Postgres).
- Zero new operational load.
Cost: literally zero. The UNIQUE index was needed anyway (for consolidation); dedup came for free.
The lesson I took: don't recreate, by adding new infrastructure, what your existing infrastructure already does. A PostgreSQL UNIQUE constraint is a thousand times simpler than a Redis dedup cache.
Formatter registry: code-as-config
When an event arrives, there's a question: "How do I turn this event into a human-readable sentence?" A file.uploaded event from Vault should turn into something like:
"monthly-report.pdf uploaded to Vault (1.2 MB, application/pdf, by yunus)."
How do we produce that sentence? Two paths:
- A template engine (YAML, Jinja, Handlebars).
- A code function (a Go function).
My first impulse was templates — it feels like config. But then I noticed: in each event I need to format different fields differently. A helper to make file_size human-readable ("1.2 MB"), another helper to turn uploaded_at into a relative time. In a template engine I'd have to register those helpers and call them with parameters — the result is no different from writing code, just in uglier syntax.
So I wrote a Go function per event type. A registry map holds them:
"vault.file.uploaded" → formatFileUploaded
"foreman.pr.review.completed" → formatPRReviewCompleted
...
Adding a new event type = writing one function + one registry line. For the PR review work, ten minutes.
The lesson I took: on small projects, a "config file" can be as complex as code, or more so. Don't push something that's expressible as a pure function into config.
Importance map: a deliberate prioritization
Every formatter returns a (content, tags, importance) triple. The importance piece matters because it goes into the decay formula — i.e. how long this event should be remembered.
Intuitively, I settled on a scale:
- 0.7 → Errors (
pr.review.failed,digest.failed). Errors should be remembered for a long time so they don't recur. - 0.6 → Positive completions (
file.uploaded,pr.review.completed). A real piece of work happened. - 0.5 → Routine actions (
file.deleted,link.created,digest.generated). Ordinary. - 0.4 → Secondary events (
link.revoked,pr.review.started). Pieces of a process. - 0.3 → Noise (
link.accessed). Frequent, individually low-importance.
These numbers aren't sacred — they're deliberately tweakable. But the intuition "errors should live longer" is something the design feels. Because a forgotten error is a recurring error.
Graceful shutdown: a small bit of care
This is the section that usually gets a quick "let's add a bit of that" and a careless implementation. With Mnemo I did it deliberately:
- SIGTERM arrives → HTTP server closes (5s timeout, no new requests).
- Background context cancel → NATS consumer goroutines exit.
- Subscriber
wg.Wait()→ let in-flight messages finish. - NATS client
Drain(5s)→ flush pending messages. - Embedding worker context cancel (already done in 2) → let the current batch finish.
- DB pool
Close().
This order minimizes data loss. New work is refused first, in-flight work is finished next, and persistent connections close last. During a container restart, the window of "acked but not processed" shrinks to as close to zero as possible.
It sounds small, but with Phase 4 it actually became necessary — because now there's an async pipeline, the question "what would I lose if I died right at this moment" matters.
In the next post
The fifth and final post will look backward. What Mnemo taught me as I wrote it — 8 lessons, each portable to another project. I'll also share where Mnemo is right now and where it might go.
But I want to end this post with this: turning the side effects of one system into the inputs of another is a simple but transformative discipline. The events Vault produces as a side effect became Mnemo's primary food. That lets your ecosystem grow while you write less code. Because the moment a new service plugs into the existing event stream, it automatically benefits from the intelligence accumulated there.
And that intelligence — a Mnemo that can answer "what was uploaded to Vault last week" — is something far deeper than what you ever get from feeding it by hand.