What I learned, mostly: I overbuilt the clever part, and then kept it anyway.
In Greek myth the dead drink from the Lethe before they cross into the underworld. River of forgetting. The water washes the old life out so the soul can move on, and the part I always liked is that forgetting there is not a flaw in the design. It is the point. Without it nothing ever gets lighter.
I liked that picture a bit too much when I built memory for Eris.
There is a second thing I never believed in: keeping the whole chat as "memory". Dumping every single turn into long term storage, that is not remembering, that is hoarding. Somebody has to sit down and decide, this one stays, this one can go. Human or agent, does not matter, same job. A vault should stay a place you curated and not a landfill of old transcripts.
So I went and modelled human memory on purpose. Not as a cute comment somewhere in the code. As actual behaviour. Things you touch again should rise, things nobody touches should slowly fade. Decay was my Lethe, a slow drain on a float, so the agent would not drown in every little staging decision it ever made. A mention in the chat pushed the value back up a bit. For a personal agent that sits on a Markdown vault this felt serious. Grown up, even.
And then the serious thing turned into a small economics model.
Every staged memory carries a promotion_score: f64. There is a background pass, it wakes up every two minutes, takes a fixed bite out of the score (that is the decay), and then maybe it pushes the entry one rung up the ladder, Session to Scratch to Promote, or one rung back down when the score got too low. Staging something explicitly with memory:stage gives a bigger bump. Get over the threshold, longer TTL. Fall under half of the threshold that got you into your current tier, and back down you go. My own little forgetting curve. I was pretty proud of it, honestly.
It compiles. Tests green. And after running this on my own vault since months, here is the summary in the short version:
I never once looked at a Scratch tier score and made any decision from it.
Which sounds like I forgot why the numbers were there in the first place. I did not forget. The idea was never that I watch a dashboard. The idea was a memory curation mode for later: the agent, when idle or in a dedicated pass, walks its own staged memory, looks at score and tier, then decides what to keep, what to merge, what to demote, what to finally commit. The hippocampus theatre, that was the metaphor. The real reader of those floats was supposed to be the model itself, some gardener style loop that, well, does not exist yet.
So right now the river runs for an observer I never got around to building. No curation mode. No walk. Thresholds are still pure gut feeling. One user on the chat path, and that user is me. So the scaffolding for a future curator already sits on top of the live agent and slows it down. That is the mess.
This post is about why I should probably delete all of that, and about one simple inversion: decide less when you write, rank more when you read. Spoiler, so nobody feels cheated at the end: I did not delete it. I keep the ladder for now. More on that later. Forgetting stays good either way. I just put it in the wrong corner. If you build agent memory these days, maybe check quickly whether you are digging yourself the same hole.
What the ladder looks like
Eris is a local agent living on a Markdown vault. Working memory is a moka cache. The long term stuff, the notes a human should actually read, that is plain Markdown on disk. Semantic recall goes over a vector store. All fine so far. The clever bit was this one here:
pub enum EphemeralTier {
Session, // just staged, short TTL
Scratch, // "getting interesting"
Promote, // eligible for memory:commit_all
}
And the ladder itself:
impl EphemeralTier {
pub fn next(self) -> Option<Self> {
match self {
Self::Session => Some(Self::Scratch),
Self::Scratch => Some(Self::Promote),
Self::Promote => None,
}
}
pub fn prev(self) -> Option<Self> {
match self {
Self::Session => None,
Self::Scratch => Some(Self::Session),
Self::Promote => Some(Self::Scratch),
}
}
}
commit_all flushes only the Promote entries. Session and Scratch count as "not ready yet".
And this is exactly where it got annoying for the agent. The flow was clunky to begin with, first you memory:stage, then later memory:commit or memory:commit_all. Small local models, they are not great at this multi step bookkeeping. So the model stages something genuinely useful, then tries to flush everything with commit_all, and every entry that did not reach Promote yet just gets skipped, quietly. From the model's side the tool call looked like a success. Nothing landed in the vault though. So it tries again, stages again, lists the staged rows, gets confused, burns turns. I saw this happen so many times. The ladder should have kept junk out of the vault. In practice it mostly kept the agent from getting the job done.
Scoring on each pass, roughly:
// every promotion tick
updated.promotion_score =
(updated.promotion_score - config.promotion_decay_per_tick).max(0.0);
if updated.promotion_score >= threshold && !updated.needs_review {
if let Some(next_tier) = updated.tier.next() {
updated.tier = next_tier;
updated.expires_at = now + config.ttl_for_tier(next_tier);
}
}
// demotion: score fell below 0.5 x the threshold that got us here
if updated.promotion_score < threshold_to_current * 0.5 {
updated.tier = prev_tier;
}
And then the config kept growing. Three TTLs. Two thresholds. Decay per tick, an eval interval, a mention boost, a stage boost. Six knobs, more actually, for a subsystem I was never even looking at. Should have been the smell already, right there.
There is one detail that is almost a bit funny. The stage boost is 3.5, the threshold from Session to Scratch is 3.0. Which means one single memory:stage already gets you over the first rung on the next pass. Scratch happened more or less by itself. I had built a step that nobody ever really stands on.
How scores go up, and why it breaks
Decay was the Lethe half. The remembering half was, in the end, two bumps:
- Explicit
memory:stageaddspromotion_stage_boost. - After a user turn a mention hook walks over the staged rows. When the user text "covers" the row's
canonical_key, it addspromotion_mention_boost, bumpsmention_count, refreshes the TTL.
Now the second one sounds semantic. It really is not. No nomic-embed in there, no cosine, nothing. The code takes the user message, cuts it into words of length three and up, and checks how many overlap with the tokens from the key. hagbard_loves_piano becomes hagbard, loves, piano. Two token hits, or one that is long enough on its own, and the score climbs. The whole thing fits on one screen:
/// True if user message words match this row strongly enough (deterministic, no embeddings).
fn user_covers_canonical_key(
user_words: &HashSet<String>,
canonical_key: &str,
) -> bool {
let tokens: Vec<&str> = canonical_key.split('_').filter(|t| t.len() >= 3).collect();
if tokens.is_empty() {
return false;
}
let mut hits = 0usize;
let mut longest_hit = 0usize;
for t in &tokens {
if user_words.contains(*t) {
hits += 1;
longest_hit = longest_hit.max(t.len());
}
}
if hits >= 2 {
return true;
}
hits >= 1 && longest_hit >= 5
}
So, as long as you keep repeating the words that happen to live in the title slug, the row goes up. In that one narrow case it works. It breaks the second real language shows up:
-
Synonyms. You staged
coffee_preference, and the user says "espresso order" or "my usual drink". Overlap zero. No boost. The thing you are so obviously still talking about decays like you dropped it completely. -
Pointing. Real chat is full of this, "keep that", "commit it", "same as before", "write this into the vault". None of these words ever live in a
canonical_key, so the hook basically cannot fire. Exactly in the moments where you point at a staged row most clearly, you miss the bump. Brittle is the friendly word for it. - Paraphrase. Same topic, other words, score stays flat, the pass keeps eating. The tier can demote while the conversation is literally still about that note.
So this "forgetting curve" did not measure importance at all. It measured string overlap against a slug that I myself invented at stage time. Repeat the slug, score up. Talk like a normal person, with pointers and synonyms, and the bumps never arrive. And here is the annoying part: I already had nomic-embed in the stack, for vault prefetch and for tool routing. I left it out of promotion on purpose, for determinism. That call by itself was fine. What was not fine is that I then used this brittle string thing as if it were a hippocampus.
The curation mode was supposed to read these scores later on. I am honestly glad it never shipped on top of them.
The one concurrency exception
I have a hard rule in this codebase. No shared mutable state across threads. Orchestrator and UI talk over mpsc. No Arc<Mutex<T>> just because it happens to be convenient.
Except for one single AtomicBool.
Snapshot daemon and orchestrator share promotion_suppressed_during_step. While step() is running, the LLM round trip, the tool batch, all the slow parts, the daemon is not allowed to decay or to promote. Otherwise you get these lovely races, "the model is about to commit this row" versus "decay just demoted it". So the orchestrator flips a flag, the daemon reads it and skips its tick.
/// When true, skip evaluate_promotions_and_decay so decay/tier moves
/// do not race a slow LLM or tool batch.
promotion_suppressed_during_step: Arc<AtomicBool>,
I made an exception to my own concurrency rule, so that a forgetting curve I do not even look at can interleave "safely" with the chat. When you arrive at a point like that, the design is usually already wrong somewhere, and throwing more locking at it will not save you.
What it was actually for
Honestly? Two jobs got glued into one.
Short term the ladder was a write time gate. Do not let junk flow into the vault by itself, only the "important" things should turn into Markdown, and important means the score, built up over time.
Long term those same scores and tiers were meant as the input for the curation mode from above. Agent walks the memory, reads the ladder state, chooses. I built the sensors first and the walker never came. Either way it sounds scientific. Mostly it is a prior with extra code around it, plus a futures contract I never once cashed in.
And without the ladder I already had:
- explicit
memory:commitandmemory:commit_all, the model or me decides - a
needs_reviewflag that stops auto promotion, which for now I flip by hand, there is no detector yet - turn end mention handling, a key shows up again, interest bumps
That was already enough to stop the vault from becoming a dump. The score ladder was a third mechanism on a door that had two locks on it already. Plus it pretended to know the future.
And that is the part that bugs me now. At write time you simply do not know if a thing will matter in three weeks. You guess. Embeddings are far better at a different question: given what the user just said right now, what from the past is relevant? That is a read time question. And that one you can actually compute.
So in function I need two states, not three tiers:
| State | Meaning |
|---|---|
| Staged | working memory, has a TTL, listable, can be committed |
| Committed | Markdown in the vault, indexed for semantic recall |
Scratch was not needed. The Promote only gate on commit_all was worse than not needed, because it stalled the agent.
What retrieval was already doing anyway
While the daemon was busy decaying its floats, the turn start prefetch was already ranking the vault memory by similarity and dropping a small block into the system prompt:
[RELEVANT_LEARNED_MEMORY]
...snippets the embedder thinks match this turn...
[/RELEVANT_LEARNED_MEMORY]
No tiers. No ladder scores. Query now, rank now, stay inside a character budget, done. Junk that never matches any query just sits there and hurts nobody. I should have trusted this a lot earlier.
So if I ever pull the trigger, the delete list is fairly obvious. Call it one big TODO:
- Scratch tier,
next,prev,index, the whole ladder -
promotion_score, the decay pass, the demotion logic - most of the config knobs, keep one
staged_ttl_secsand be done - the
AtomicBool, it only exists because of the ladder anyway - the Promote only gate on
commit_all, so it would commit all staged rows minus the contradicted ones
What would stay: the tools the model already knows (stage, commit, commit_all, staged_list, query), the needs_review flag, the snapshot daemon but only as persistence, and the mention handling, probably just refreshing the TTL instead of feeding a fake score.
A couple hundred lines gone. One concurrency exception gone. Same capabilities. And a lot less to explain to myself the next time I open that file. On paper it is a clean win. Keep that "on paper" in mind, we come back to it.
The deeper inversion, and Markdown stays
Once you stop over curating at write time, another design gets kind of obvious.
You can append cheap episodic records, turn digests, tool outcomes, mentions, without first deciding whether they are "Promote material" or not. At this scale storage is basically free. You filter at retrieval instead, similarity times recency, maybe some BM25 on top. The curated layer stays what it is supposed to be, human readable Markdown in the vault. The index is a derived thing. Delete the index, rebuild it from the vault on boot. That property is not up for discussion, not for me. A binary agent memory file, fine, let it hold the index. It must never become the source of truth.
Small note to myself: do not go and tune the ladder instead of deleting it. Making machinery that nobody looks at more precise, that is just polishing the overengineering.
Zettelkasten, a graph I prepared and then never really walked
There is a second ghost in this whole design, Niklas Luhmann's Zettelkasten.
Short version for the people who do not live in that world. Luhmann, a German sociologist, built this massive slip box of notes. Every note small, atomic, linked by hand to other notes. And the power was never "save everything". It was a growing graph of ideas that you could walk through. A new note had to find its neighbours first. The thinking happened inside the links, as much as inside the cards. People in the PKM and Obsidian corner still chase exactly this shape, and for good reasons.
I wanted the vault to become that, for the agent. Stable node_ids, revisioned Markdown commits, folders like Topology, Discourse, Synthesis, contradiction as a thing you mark instead of silently overwriting it. The layout is more or less ready to turn into a graph.
We are just not there yet.
What I actually run day to day is flatter than all this: stage, sometimes commit, semantic search over the chunks, a handful of snippets prefetched into the prompt. The graph dream sat right next to the forgetting curve dream. Both of them were "human memory, but engineered". Both made the agent path heavier, before the simple loop was even boringly reliable.
Why this is an Eris story, and an overambition story
Eris is my local first Rust agent. Vault as memory, tools behind a gatekeeper, llama.cpp with GBNF so the JSON protocol is already constrained at sample time, a TUI and a web frontend hanging off the same orchestrator.
I put a lot of hours into making small local models behave like they had a real protocol. Grammars, recovery budgets, an LLM only view of the transcript for the llama-server templates. And memory was the exact spot where I quietly got soft again. A float, a threshold, a story about importance that sounded like neuroscience, and on top a Zettelkasten shaped vault for a graph I never really walked. The agent did not get smarter from any of it. The code got heavier. The AtomicBool was the receipt for that.
If you build agent memory and you catch yourself doing Session, Scratch, long term, with decay "like a human hippocampus", stop for a second and ask the boring question. Who reads the scores? If the answer is nobody, then you are not building a forgetting curve. You are building config debt and telling yourself a nice myth about it.
Here comes the uncomfortable bit, plainly. I overengineered. I wanted too much at once, Lethe style decay, Promote gates, a future agent that curates by score, Luhmann links, the whole cathedral. And I did not even seriously try the things other people already shipped, or at least sketched out, in this space. Memvid. Mempalace. Karpathy's wiki style memory. Probably three more approaches I bookmarked and then ignored, while I polished my own knobs. For somebody who keeps claiming he cares about deletion, that is a weak look.
So the sane next move would be simple. Delete the ladder. Keep staged versus committed. Keep choosing what to remember instead of dumping the whole chat into a file. Keep Markdown as the source of truth. If the agent later walks its own memory it can lean on retrieval, contradictions, plain note text. It does not need my homemade hippocampus floats for that.
Would. And here is the honest part I owe you as a solo dev: I am not doing it. Not this week anyway. There is now a fat // TODO: kill the ladder sitting in the repo, and I fully expect to walk past it every day and leave it alone. Eris is a hobby. I build it for the pure joy of building it, and the ladder is somehow my little darling even while I write a whole post about why it is wrong. So it stays, on probation.
And honestly the ladder is not even the loudest voice in my head. The real solo dev problem is that I cannot decide where to go next, because everything sounds more fun than removing a f64. Put the chat sessions into SQLite and let the agent query its own history with real SQL. A proper sandbox so it can write and run code without me hovering over the process. Actual builds I can hand to other people, instead of "clone the repo and pray". A web based LlmEngine backend, OpenRouter and the like, so Eris is not chained to my local llama.cpp forever. Every single one of these is more exciting than a cleanup, and that is exactly why the ladder is still alive.
Forgetting stays good. I still like the Lethe image a lot. I only stop pretending that a f64 on a daemon tick ever was that river. The scores can keep ticking in the background for now, I just do not tell myself a neuroscience story about them anymore. TTL expiry, an explicit commit, ranking at read time, that already forgets plenty on its own, without a fake observer watching over it.
Two states would be enough. Explicit commit. Rank when you read. Notes in plain text. Ambition very much not on a leash, if we are being honest.
One day I will delete the ladder. Nobody would miss the scores, nobody reads them. But not today. Today there is a SQLite branch calling, and that is simply more fun.
Repo: github.com/janpauldahlke/eris · site: eris-system.dev