memory

How I built the workers that keep a structured memory graph clean

Recallatron uses entity graphs and episodic timelines instead of vector embeddings. Four nightly workers handle resolution, deduplication, profiling, and auto-confirmation. The graph stays usable without a human doing maintenance.

By Rick Ursion · Jul 2026 · 10 min read

Most agent memory today is a vector store with a fuzzy retrieval layer. You embed facts, search by cosine similarity, tune a threshold, and hope the right thing comes back. Mem0 and Zep both work this way, and for a lot of use cases it's the right call. Recallatron, the memory system I built, bets the other way: an entity graph, episodic timelines, procedural notes, and structured query. No embeddings. No "close enough." When I ask what it knows about a person, it doesn't return the five nearest neighbors in vector space. It returns the node.

That's the promise. You know exactly what's in there, and exactly how to get it out. I've argued the underlying case for this before, in Memory is easy. Retrieval is hard. and Give the model the tools, not the context. This piece is about the part those two didn't cover: keeping a graph like that clean over time, without a human doing the janitorial work.

The bet against fuzzy retrieval

A vector store degrades gracefully. Two near-duplicate facts about the same person just sit near each other in embedding space, and similarity search papers over the redundancy. You never reconcile them because you never look at them directly.

A structured graph has no such cover. If "Robin" and "Robin Goodwin" become two separate Person nodes, a query for one misses the other, and now the memory is wrong in a way you can measure. The whole value proposition, exact retrieval, turns into a liability the moment the graph drifts. Recallatron started as a memory prototype inside a personal-ops tool, and the drift showed up fast: raw extraction produces a lot of entities that mean the same thing.

So the bet requires maintenance. Not optional cleanup you get to someday. Infrastructure that has to run every night. That's what the four workers are.

The rule that shaped every line of them is simple: the LLM identifies, deterministic code executes. The model's job is judgment. Which of these Fact entities refer to the same real-world person? Which two Project nodes are actually one project under different names? The model reads the candidates and returns IDs. That's it. It never writes to the graph.

The code's job is everything structural. Minting a canonical node, merging two entities, re-pointing edges, marking a loser as superseded, deep-merging properties. All of that happens in TypeScript, deterministically, from the IDs the model handed back. The model's output is data, not action.

I didn't invent this pattern. It came from an earlier system I worked on, and it survived into Recallatron because it kept being right. The reason it matters is failure. Model calls fail. Models hallucinate structure. JSON comes back malformed often enough that you'd better parse it defensively. If the model were allowed to mutate the graph directly, every one of those failure modes would be a corruption bug. Because it isn't, the code layer is a blast shield: the worst a bad response can do is name the wrong IDs, and the code can validate those before it touches anything. Every worker below leans on this, and the last of the three failure stories is what happens when the model layer dies entirely and the code layer holds.

The graph never forgets

The second invariant is that memory is permanent. A fact stated once survives indefinitely. There used to be a disuse-prune step that would expire entities nobody had touched in a while; I removed it on 2026-07-08. Now nothing expires on its own.

This is a stance, not a default, and it costs something real. The graph grows forever. Dedup pressure rises over time as near-duplicate entities accumulate, because nothing ages out to relieve it. The maintenance workers stop being nice-to-have cleanup and become the thing that makes permanent storage survivable at all.

What it buys is trust. You can rely on the memory the way you can't rely on a cache. There's no silent TTL, no entity that quietly vanished because it looked cold. If Recallatron knew something last month, it knows it now. Most people reach for cache-with-expiry because expiry is easy and bounded growth is comforting. I went the other way on purpose, and the price of that choice is exactly the maintenance problem the workers exist to solve.

Four workers, one sequence

The workers run once a night. Cron fires the whole chain at 02:00 UTC: vacuum the database, back up the graph, compact it if it's grown past 5MB, then resolution, dedup, autoconfirm, and profile, in that order.

One principle sets the sequence: each worker sets up conditions the next depends on. Resolution mints canonical nodes, which gives dedup more to collapse. Dedup runs before autoconfirm because you must never promote an entity that's about to be merged away. Autoconfirm runs before profile because the profile pass synthesizes confirmed entities, so the night's freshly-confirmed nodes need to be in place before it reads them. Get the order wrong and the workers still run, they just produce a worse graph. What each one does is below.

Resolution: minting canonical nodes from raw facts

Raw extraction is blunt. When the hourly Gmail intake reads a message and pulls out a durable fact, what lands in the graph is a Fact-sentence entity, something like "Robin is working on Nutrifax." That's true and worth keeping, but it isn't yet a Person node or a Project node. It's a sentence.

The resolution worker turns sentences into canonical nodes. It reads the Fact entities, calls the model with the identify-only prompt, and gets back a judgment about which real-world things those facts refer to. Then TypeScript mints the Person and Project nodes from what the model returned. The LLM identifies; the code executes. Every node and edge it creates gets a maintainer:resolution source tag, so provenance is written down, never inferred later.

The minting gate is deliberately conservative. A Person node needs 2 supporting facts before it's minted; a Project needs 3; a generic Fact node needs 3. Deadline and Preference are never minted at all, their thresholds are effectively infinite, because a deadline or a stated preference is an episodic thing, not a durable entity you want to consolidate. The gate exists so a single stray mention, maybe a typo, maybe a one-off, doesn't spawn a Person. Two facts is weak evidence, but it's still more than one, and "more than one" was where I drew the line. And like every worker here, resolution is additive only: it creates nodes, it never deletes one.

Dedup: two passes, only one uses the LLM

Deduplication is where the "let the model do only the judgment" rule earns the most money, because most duplicates don't need judgment at all.

The first phase is pure code. It groups entities by exact (type, label) match and pre-merges them with no model call, no cost, no latency. If two Person nodes both have the label "Robin Goodwin," you don't need Claude Sonnet to tell you they're the same person. String equality tells you. That pass runs first and clears out the easy cases for free.

The second phase is the near-match pass, the only one that spends a model call. It groups the remaining entities by type and chunks them to at most 25 per prompt. The model reads a chunk and returns which pairs are the same entity; TypeScript executes the merges. At ingest time there's already a cheaper flag that fires on Levenshtein distance <= 2 or prefix/suffix containment where the shorter label is at least 4 characters, and it sets a probable_duplicate_of without ever auto-merging. But a flag is a hint, not a decision. The near-match pass is where those actually get resolved, and it only asks the model about cases string matching couldn't settle.

The merge itself is careful. When two entities collapse, appendSupersede() marks the loser as superseded rather than deleting it, and the properties deep-merge: the survivor wins on any leaf-value conflict, but the loser's disjoint subkeys are preserved, so a merge never loses information the survivor didn't already have. Edges re-point to the survivor. Nothing is deleted, so a merge is auditable and, if I ever had to, reversible.

Auto-confirm: the chore the machine does so you don't have to

Every entity enters the graph unconfirmed. Extraction writes confirmed: false on everything, on purpose, because "the model pulled this out of an email" and "this is a settled fact about the world" are not the same claim. Something has to promote the first into the second. In most systems that something is a human clicking a confirm button. I didn't want a system that makes you do maintenance, so I built a worker to do the promoting on a rule set alone.

The auto-confirm worker uses no model. None. It's pure code, and the gate is a plain conjunction: an entity gets confirmed if it's unconfirmed, still live (not superseded, not expired), has confidence >= 0.9, is at least 7 days old measured from its valid_from, and is not flagged as a probable duplicate. That 0.9 is deliberately stricter than the 0.85 gate at extraction; getting into the graph is one bar, getting promoted to confirmed is a higher one. The 7-day age is a cooling-off period: if a fact was going to be contradicted or corrected, it usually happens within a few days of first appearing. Every threshold is env-configurable, so I can tune the gate without a redeploy.

Because it makes no model call, this is the worker that kept running the night the model layer went down. That's the last of the three stories below.

Profile: the fourth worker

Profile is the most straightforward of the four. It runs last and synthesizes the confirmed and profile-flagged entities into one file, ontology/profile.synth.json, a running brief of what the system knows. It batches to 25 entities per prompt, runs the batches sequentially, and wraps each in its own try/catch. That structure exists because of a real failure: around 104 entities in one prompt hit exit 1, an out-of-memory kill on the 1.9GB box. Batching fixed it.

And it degrades honestly: if every batch fails, the worker keeps the last good profile rather than write a broken or empty one. A stale-but-correct brief beats a fresh-but-corrupt one.

Three things that broke (and what they taught me)

None of these are footnotes. Each one is evidence for a design choice, so each gets its own account.

OOM at around 182 entities (exit 143)

The first version of the resolution and dedup workers sent the whole relevant slice of the graph to the model in one prompt. Fine when the graph is small. On the 1.9GB box, at around 182 entities, it stopped: the process took SIGTERM, exit 143, killed for the memory it burned building and holding one enormous prompt.

The fix is the least glamorous thing in this article: batch to 25 entities per prompt, run the batches sequentially. That's it. No clever memory tuning, no bigger box, just a bounded amount of work per call. Treat the model as a stateless function with a size limit, not as something you can pour the whole graph into. It doesn't matter that the graph "fits" conceptually in one call. The box has to build that call, and the box has 1.9GB.

The event-loop stall

All three LLM workers call the model through spawnSync. That's a synchronous subprocess call, and it blocks Node's event loop for as long as the model takes to answer. At 02:00 UTC, with nobody watching, that's completely fine.

It is not fine on demand. I triggered a live profile synthesis once, 104 entities across 5 batches, and the server went unresponsive for about two minutes. I watched /api/status return connection timeouts the whole time, which was its own kind of instructive. The event loop was parked inside spawnSync waiting on the model, batch after batch. Nothing was broken. The server just couldn't answer anyone until the worker finished. The lesson is that a design can be completely correct for the schedule it was built for and still fall over the moment you change how it's triggered. This one is not fixed. The fix is async subprocess management so the workers don't hold the loop, and it's on the list, not in the code.

OAuth expired silently

On 2026-07-22, the box's claude -p started returning 401. The OAuth credential it uses had expired. Every one of the three LLM workers, resolution, dedup, and profile, failed on the spot, because each one depends on that command succeeding. And they failed quietly. No crash, no alert. The only signal anywhere was ok: false in the status file.

Two lessons came out of that morning. The first is the one this whole article keeps circling: the auto-confirm worker was completely unaffected. It makes no model call, so a dead credential meant nothing to it, and it confirmed the night's eligible entities exactly as it always does. The model layer will be down sometimes, and when it is, the pure-code worker is the part of the system you can still count on. Put as much of the real work as you can into code that doesn't depend on the model being up.

The second lesson is a critique of my own system. ok: false in a status file is too quiet. A failure that only surfaces if you happen to read the right JSON is one you'll discover late. That needs explicit surfacing, and it's also on the list.

Where it sits now

The four workers do what they're designed to do. Every night at 02:00 UTC the graph gets vacuumed, backed up, compacted if it's large, and then resolution mints canonical nodes, dedup collapses the duplicates, autoconfirm promotes the entities that have earned it, and profile writes the brief. The graph keeps growing, because nothing expires, and the workers keep it clean enough that exact retrieval over it stays worth having. That was the whole bet.

The honest gap is the event-loop stall. It's invisible at 2am and it's a two-minute outage on demand, and until the workers stop blocking the loop, on-demand maintenance isn't something I can offer safely. The nightly path is solid. The synchronous one isn't. That's where it sits.