DEV Community

Cover image for I built an MCP memory server for one user (me, for six weeks)
Heinrich Neb
Heinrich Neb

Posted on Edited on Originally published at cachly.dev

I built an MCP memory server for one user (me, for six weeks)

Real metrics on six weeks of single-user testing

Building in public

You explain your deploy setup to your assistant. It helps. Tomorrow you explain the same setup again. And the day after. You are not training it. You are re-typing.

The tool nobody asked for

I did not set out to build a product. I set out to stop repeating myself.

My setup is four servers with names that mean nothing to anyone else, a tunnel with a numbering scheme I keep getting wrong, and a dozen small traps that only exist because of decisions I made two years ago. Every new session started from zero.

So I gave the assistant a place to write things down, and a way to read them back before it started working. Two calls: one to save what was learned, one to recall it. That was the whole idea.

For six weeks it had exactly one user. Nobody else could have used it, because I had not written a single line of documentation.

Six weeks of being my own only customer

That stretch turned out to be the most valuable part, and not because of what got built. Because of what got measured.

When you are the only user, every rough edge lands on you within a day. A recall that returns the wrong thing costs you the next hour. A save that silently drops a field costs you the next week, when you go looking for it.

I kept a count of the times the memory actually prevented a mistake. Not a feeling, a count. After six weeks it was high enough that I stopped arguing with myself about whether the thing was worth the effort.

The uncomfortable part: several of those saved lessons were about mistakes I had already made twice. The tool did not make me smarter. It made me stop paying for the same lesson.

The moment it stopped being a personal tool

The thought that changed it was not a market analysis. It was smaller and more honest: if I find this useful, and my setup is not special, then somebody else is retyping their own servers right now.

That is a weak argument on its own. Plenty of internal tools are useful precisely because they fit one person. So I looked for the part that was not about me.

What was not about me: the shape of the problem. Every assistant starts each session with no history. Every developer has context that lives in their head and nowhere a machine can read. That is not my setup. That is the default.

So I wrote the documentation I had skipped, then the onboarding I had never needed, then the parts that only matter when the user is not the author: error messages that explain themselves, a health check, a way to see what the thing actually knows.

What building for one user taught me about building for many

Three things carried over, and one did not.

Carried over: every feature had already survived daily use before a stranger saw it. There was no backlog of ideas nobody had tried. The roughest paths had been walked hundreds of times by someone who could not file a ticket and walk away.

Also carried over: the honesty. When your only user is you, a green checkmark that hides a failure costs you personally, so you stop building those. That habit turned out to be the actual product.

Did not carry over: my tolerance for silence. I knew what an empty result meant. A new user reads an empty result as a broken tool. Half the work of turning it into a product was teaching it to say why nothing came back.

Do this before you decide your internal tool is a product

You do not need my stack for any of this. You need a number and a stranger.

First, count the saves. Instrument your internal tool so it records every time it prevented rework. Not usage, prevention. Usage tells you it runs; prevention tells you it earns.

Second, hand it to one person who did not build it, with no explanation, and watch where they stop. That is your documentation backlog, in priority order, for free.

Third, read your own error messages as if you had never seen the code. Every message that says what happened but not what to do next is a support ticket you have already written.

A quick way to get the count without touching your tool's logic:

# Wrap the recall path and log whether it actually returned something useful.
# Two files, no dependencies, works with any tool that shells out.
log=~/.mytool/prevented.log

recall() {
  out=$(mytool recall "$1")
  if [ -n "$out" ]; then
    printf '%s\tHIT\t%s\n' "$(date -u +%FT%TZ)" "$1" >> "$log"
  else
    printf '%s\tMISS\t%s\n' "$(date -u +%FT%TZ)" "$1" >> "$log"
  fi
  printf '%s\n' "$out"
}

# After two weeks, the ratio is your answer:
#   awk -F'\t' '{n[$2]++} END {for (k in n) print k, n[k]}' "$log"
Enter fullscreen mode Exit fullscreen mode

If the hit ratio is low, you do not have a product yet. You have a habit that has not paid off. That is worth knowing before you write the landing page.

What changes for you

Before: you open a session, explain your setup, get help, close the session, and the explanation dies with it. Tomorrow the same explanation, in the same words, because you wrote them once and nobody kept them.

After: the explanation is written down once by the assistant itself, and read back before the next task starts. You notice it not as a feature but as an absence — the absence of that first ten minutes.

Our version of this is cachly: the assistant saves what it learned after a fix and recalls it before the next task, over MCP, so the memory survives restarts, model upgrades and switching editors.

An internal tool becomes a product the day you can prove it earns its keep for somebody who did not build it. Until then it is a habit with a README.


I build cachly — memory for AI coding assistants, over MCP.

ChatGPT and Claude remember your conversations. cachly remembers your system: the bug you fixed, why you chose Postgres, the deploy step that always breaks — and which decision it contradicts. Every assistant you use reads the same memory, and every lesson carries the name of whoever learned it, so nobody from your team has to learn it twice.

Free tier, hosted in the EU: cachly.dev

Try it:

  • 30 seconds, no accountnpx @cachly-dev/mcp-server@latest demo in any git repo. It reads your log locally and prints what an assistant would already know about the project. After npx fetches the package, the command makes no network calls.
  • Claude Code plugin/plugin marketplace add cachly-dev/cachly-mcp, then /plugin install cachly-brain@cachly.
  • 5 minutes, free tiernpx @cachly-dev/mcp-server@latest autopilot writes the MCP configuration for whichever assistant you use.
  • Or from the webcachly.dev · free tier, German servers, no credit card.

Top comments (57)

Collapse
 
eduzsh profile image
Edu Peralta

Counting the saves is the part most people skip. After a few weeks of running agents with any memory layer, the useful metric is not whether it recalled something, but how many times recall stopped a repeated deploy mistake. The silent field drop is worse than an empty result, because the agent keeps going with half a fact and sounds confident about it. Teaching the tool to say why nothing came back is half the product. Empty silence reads as broken to anyone who did not write the server.

Collapse
 
pm25coder profile image
pm25coder

Great write-up - the HIT/MISS ratio is a much cleaner instrument than the "did it feel useful" vibes most of us run on. One data point from a different corner of the same problem: instead of a separate recall store, I've been keeping the durable memory inside the repo the assistant works on - every fix commits with the trigger (the user complaint) and the rationale in the message, so "why did we do this" is a git log away and versioning/rollback come free. The trade-off I hit: retrieval is grep/diffs, not semantic - strong for "what did we decide about X", weak for "what did we learn that's similar-but-not-the-same as X". The MCP store has the opposite strength.

Question on the versioning side: when a saved lesson gets superseded (you changed the deployment and the old note is now actively wrong), how does cachly handle that? Can you see what changed and why, or does it just overwrite? That's the one thing a git-based approach gives you for free that I'd miss in a plain save/recall store.

Collapse
 
heinrichneb profile image
Heinrich Neb

Your trade-off is the one I would have written down too, and you named it more precisely than I did in the post.

On the versioning question - three parts, because two have a clean answer and one does not.

Does it overwrite? For the answer, yes. A lesson is keyed by topic, so writing under the same topic replaces the current text. That is deliberate: a superseded note that keeps competing on relevance is worse than no note at all.

Is the old one gone? No. Every write also appends the complete record to a per-topic history list. I counted in my own store rather than guessing: 524 lessons, of which 257 (49%) have been overwritten at least once - 408 overwrites in total. 638 superseded versions are still sitting there, across 334 topics. So the "what changed" half of your question has a real answer: the previous texts are there and they diff.

Can you see why? No. That is the honest gap, and it is exactly the half your approach gets for free. Each record carries an audit entry with a timestamp, whether it was a create or an update, and the previous outcome. There is no rationale field. Your commit message has the trigger and the reasoning in it; my update has neither.

Three more things I would rather say than have you discover:

  • The history expires after 90 days. Git keeps forever; this does not. For a note that turns out wrong two quarters later, the version that was right is already gone.
  • There is no rollback. The history is readable, but there is no "restore version 3" call.
  • No tool surfaces the diff. The data is in the store; the product does not show it to you. So in practice, today, you would miss it precisely as you expect.

One design decision that is in there and belongs in the same family: a record whose outcome is a failure cannot take the slot from a known-good fix. Success and partial always replace; a failure only claims the slot when nothing is there. Otherwise one bad run overwrites the fix with the report that it broke.

On not having to pick a side - we ended up reading your half into ours rather than choosing. brain_from_git parses commit history and turns fixes into lessons, incrementally, so re-running only picks up new commits. And there are two CI templates, GitHub Actions and GitLab CI, that write a lesson per run, so a red-to-green transition gets recorded as a learned fix. The reasoning was the one you describe: the commit message already carries the trigger and the rationale, so not reading it means throwing away the best-labelled data in the repo.

What that still does not do is carry the rationale through into the lesson as a first-class field. It reads your history; it does not keep your "why". That is the piece your approach has and mine does not, and it is now written down as a gap rather than a preference.

Collapse
 
pm25coder profile image
pm25coder

That's the honest answer I was hoping for, especially the parts you'd rather say than have someone discover.

The 90-day expiry is the one I'd push back on. Your own counts show most corrections land within days, but the cases that actually hurt are the ones that take quarters to surface - and "the version that was right is already gone" is exactly when the old text becomes the most valuable record you own. If it's a storage concern, keeping only superseded versions (not every write) gets most of the safety at a fraction of the space.

On the missing rationale - a cheap discipline that worked for us: require a one-line "why" on every update, same as a commit message. The diff says what changed; the one-liner says why it was wrong then. Two months later that's the line that gets read. You already diff the per-topic history, so it's one more field on the update record - and it feeds the "why did this keep changing" view that plain diffs never show.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

Both points land, and the first one lands harder than you may know.

On the 90 days: your sharpening is exactly right, and I can now back it with something that happened here yesterday, not two quarters ago. We removed a retrieval feature whose justifying measurement was less than 24 hours old - and already unreproducible on the frozen benchmark, because the pipeline around it had moved. The question "which version was right, and when" turned out to be the load-bearing one on a one-day-old fact. An expiry that deletes superseded versions is deleting precisely the records whose value peaks late.

Your compromise is the right shape: keep superseded versions only, not every write. Most of the safety, a fraction of the space. That is now the stated target on our board, with your name on the card: superseded versions become exempt from the TTL; the expiry stays for everything else.

On the one-line why: what convinced me is not the discipline argument - it is that we already believe it and act inconsistently. Our git importer exists because commit messages carry the trigger and the rationale; that was the whole pitch. Then our own update path throws exactly that information away. We justified the import with a field we do not keep ourselves.

One thing makes "require it" cheaper for us than for most tools: the writer is almost always a model mid-session. It knows, in that moment, why it is updating - the field costs a human nothing and the model half a sentence. Required fields filled by humans rot into "fix"; required fields filled by an assistant that just diagnosed the problem tend to contain the diagnosis. So: required on update, free-form on first write, and the why travels into the superseded version's history - which is the point where your two suggestions turn out to be one feature. A kept old version without its why is trivia; a why without the version it explains is a slogan. Together they are the git log we claimed to envy.

Thread Thread
 
pm25coder profile image
pm25coder

The under-24-hours example is the strongest possible confirmation - it collapses the "late" failure into a window you actually measure. And the "which version was right, and when" framing is exactly the question an audit log answers that a current-state store can't.

The required-on-update point is the one I'll steal. I'd reflexively assumed "required = humans rot it into 'fix'" and never considered that the writer being a model mid-session changes the economics - it genuinely has the diagnosis in context at write time, so the field costs it half a sentence. That's the same reason our commit messages carry the trigger: the assistant that just hit the problem is the one writing the rationale, and it's the only writer who knows what the rationale is.

One connection that follows from "the why travels into the superseded version's history": it turns your 638 superseded versions from a dump into a navigable topic-log. You already admitted the missing diff-surfacing UI - once each superseded version carries its why, the "show me the history of this topic" view writes itself, and the diff becomes the story rather than a forensic chore. The data was always there; the why is the index that makes it browsable.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

"The why is the index that makes it browsable" is the sentence that reorganised
this for me. I had the two as separate cards - keep superseded versions, add a
rationale field - and you just showed they are one feature with a UI falling
out of it for free. A kept version without its why is trivia; a why without its
version is a slogan.

One thing I found today that belongs next to your TTL argument, because it is
worse than the case we were discussing.

The 90-day expiry does not only sit on the history. It also sits on the
dependency index - the structure that lets a record say "I am true until that
config changes". So the one mechanism we have for invalidating a fact by
event
is itself invalidated by age. Ninety days after the link is written it
is gone, and the fact it was guarding goes back to looking permanently true.

That is the same axis error you called out, one layer down, and I only saw it
because another commenter in this thread pushed on the write path and sent me
into the code. Your "superseded versions exempt from the TTL" now reads to me
as a narrower version of a rule I should state once: anything whose job is to
mark something else as possibly-wrong must not expire on a timer.
The history
and the dependency index are both that.

Card updated with both, and your name is still on it.

Thread Thread
 
pm25coder profile image
pm25coder

The dependency-index discovery is the sharpest find yet, and it generalizes into a principle I'll be stealing: "anything whose job is to mark something else as possibly-wrong must not expire on a timer." The TTL-on-the-invalidation-link is strictly worse than TTL-on-the-record - a stale record is at least visibly old, but a vanished invalidation makes a fact look permanently true. That's the silent-failure family again, one layer deeper.

It also gives the design rule a clean test: any expiry policy should be applied to facts, not to the structures that qualify facts. Records are facts. History is not a fact - it's the evidence about facts. Dependency links are not facts - they're the scaffolding of what-could-invalidate-what. Qualifiers don't age; they either still apply or they don't. If a qualifier stops applying, that's a decision, not a clock.

On the "one feature" consolidation - that's the part I'd want to remember. Two cards that turn out to be one feature with a UI falling out of it is exactly the shape of a good simplification, and it only surfaced because you went and counted (again).

Thread Thread
 
heinrichneb profile image
Heinrich Neb

You turned a principle into a test, so I ran it. It found a third one.

I went through every expiry in the store and sorted them by your rule - is this a fact, or is it something that qualifies a fact. Two were the ones we already discussed. The third I did not know about:

cachly:contradictions: - 180 days.

That is the record of how a contradiction was resolved: the previous outcome, the new one, and which way it went. It exists because a failure report is not allowed to take the slot from a known-good fix, so when the two collide we write down what we did. By your test that is not evidence and not scaffolding - it is the decision itself. And it is on a clock.

There is a second-order part that I like less the longer I look at it. The resolution log keeps 180 days. The per-topic history it refers to keeps 90. So for three months you can see that a contradiction was resolved and which way it went; for the three months after that you can see only that it happened, because both versions it points at are gone. The pointer outlives its referents. Nobody designed that - two numbers were chosen in different places and never compared.

One counter-example, because I think it sharpens your rule rather than dents it. We have a 30-day expiry on the record of silent failures - a per-reason note that something went wrong. That one is renewed on every write, so "present" means "happened in the last 30 days" and nothing else. There the expiry is the semantics, not decay: an incident from half a year ago should not still be in the way, and an ongoing one never disappears. So the rule is not "no timers on qualifiers". It is narrower and better: a timer is legitimate when it defines what the structure means, and wrong when it merely takes it away.

That leaves me with one refinement to offer back, because your three categories have a gap I fell into. Facts, evidence, scaffolding - all three are about what a thing is. The contradiction log shows there is also a question of what it points at. A qualifier should not age, agreed. But it can be orphaned: if the versions it describes are gone, it is no longer a qualifier, it is trivia. So the removal rule I would write after your test is not about time at all - a qualifier goes when its subject goes, and never before. Which means our bug is not "180 days is too short". It is that the number exists.

Three finds now, all from the write path, all found because somebody asked about it from outside. I would not have gone looking.

Thread Thread
 
pm25coder profile image
pm25coder

The contradiction-log find is the best kind of result - the test didn't just confirm the rule, it found a case the rule didn't cover. And the second-order bug is the sharper one: a pointer outliving its referents is reference integrity breaking across a TTL boundary, and it exists precisely because two numbers were chosen in different places and never compared. That's a structural failure mode, not a tuning failure - no amount of choosing the right constant fixes a design where the constant shouldn't exist in two places at once.

Your refinement is better than my rule, and I'm taking it: "a timer is legitimate when it defines what the structure means, and wrong when it merely takes it away." The silent-failure counter-example is what makes it work - "present means happened in the last 30 days" is a semantic definition, not decay. My three categories were about what things are; the contradiction log adds what things point at.

Which leads to the fix I'd propose for the 180/90 mismatch: make the pointer's lifetime derived, not set. A resolution-log entry should live exactly as long as the longest-lived thing it points to - no separate constant to drift. Your "a qualifier goes when its subject goes, and never before" is the rule; the derivation is the implementation that makes it impossible to violate. Same for the silent-failure counter: its "renewed on write" semantics are already a derived lifetime, just expressed as a side effect.

Three finds from one outside question - that's the strongest argument I know for why these systems need reviewers who don't live in the codebase.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

"Derived, not set" is the better fix and I'm taking it - for the same reason your original rule was better than what I had: it removes the thing that can drift instead of choosing a better value for it.

One wrinkle I hit while sketching it, and I'm curious how you'd handle it: the referent set isn't fixed at write time. A pointer can acquire new referents later, so "lives as long as the longest-lived thing it points to" is a moving target rather than a value you compute once. The cheapest thing I can see is re-deriving on every write that touches the entry, which makes the lifetime a side effect of writing - which is exactly the shape your silent-failure counter already has. So maybe that's not a wrinkle but the same pattern showing up twice, and the honest version of the rule is "renewed on write" rather than "computed at creation"?

The part I keep coming back to is your last line. All three finds came from a question asked from outside, and none of them needed knowledge of the code - the contradiction log was found by a rule, not by reading. That's a fairly uncomfortable argument for how little of this a codebase can catch about itself.

Collapse
 
reidmarlow profile image
Reid Marlow

I like the one-user phase here because it gives you a clean failure log. The next metric I’d want is harsher than recall count, though. How often did the memory change the action the assistant took, and how often did you have to correct that memory afterward? That separates useful context from confident clutter.

Collapse
 
heinrichneb profile image
Heinrich Neb

Correction to my own comment above/below, and it is the worst kind of one.

I wrote: "Retrieval quality itself is benchmarked and defended in CI - +33% Precision@1 over raw BM25, 98.2% Recall@3, reproducible with one command."

I ran the command again. This is what it prints:

  metric       flatfile baseline   cachly    vs flat
  Precision@1     76.9%    69.2%    69.2%     -10.0%
  MRR             87.2%    83.3%    83.3%      -4.4%
  ---------------------------------------------------
  vs BM25 baseline : MRR +0.0% - Precision@1 +0.0%
  vs flat-file mem : MRR -4.4% - Precision@1 -10.0%
Enter fullscreen mode Exit fullscreen mode

Not +33%. +0.0% - and ten points behind a flat file on the first metric.

How the wrong number survived: it was a hardcoded string in the server's own metrics output, with a comment above it reading "CI-defended", and a unit test asserting that the string was present. So there was a green check. The check was guarding the sentence, not the measurement. A number the server does not compute cannot fall, which means it is not measuring anything.

The instrument is worse than the string, and that matters more. That benchmark has 17 lessons and 13 queries. On 498 real lessons with 20 questions asked in plain language, the current ranking formula scores 30% Precision@1 and the one it replaced scores 15% - while the 17-lesson benchmark ranks them the other way round, 69% against 92%. On the one comparison I can check against reality, it moves in the opposite direction.

So the honest state: the only retrieval number I can stand behind is 30% Precision@1 on 498 real lessons, up from 15%, measured once, on one store, by me. Everything I said about CI-defended benchmarking was describing a test that defended a sentence.

The string is out of the product, and the test now asserts the opposite - that no fixed ranking number appears in that output at all. Replacing the benchmark with something larger than 17 lessons is the next job, and it has to happen before any number goes back in.

Your question was whether the memory changed the action. I answered it and then attached a caveat that turned out to be the less honest half of the comment. The metric you asked for is what sent me to run the command, so: thank you, twice over.

Collapse
 
heinrichneb profile image
Heinrich Neb

"Confident clutter" goes into my vocabulary, with attribution.

Your second question sent me to count instead of guess, and the counting corrected me twice.

First pass: I searched all 521 records for ones that explicitly correct an earlier record - phrases like "corrects a previously stored lesson". 48 of them. I was about to write that number down as a defect.

Second pass, splitting them properly: 39 of the 48 correct their own record. The store updates in place and bumps a version, so the wrong text is gone rather than sitting next to its replacement. Of the remaining 9, only 2 name another record that still exists - and reading those two, they are citing examples, not superseding anything.

So the honest answer to "how often did you have to correct the memory": roughly one record in ten has been corrected at some point, and the correction almost always replaced the thing it corrected rather than competing with it. That is a considerably better answer than the one I was about to give, and I only have it because you asked for a metric instead of an impression.

What is genuinely missing is narrower than I first thought: a record cannot say "I supersede that one" across topics, and there is no valid-from date, so retrieval cannot prefer the currently applicable fact over an older one that still looks true. I have not yet found a case where that bit us. I would rather say that than dress up a gap I cannot demonstrate.

Your first question is the harder one, and you phrased it better than I had. "Did it change the action" is not "was it used". Used is self-reported and will flatter. Changed-the-action is a counterfactual, and counterfactuals do not come from asking.

The only honest route I see is to withhold. Hold back the top record on a random half of eligible turns; if the outcomes differ, the record changed the action. Not per turn - in aggregate, which is the level the claim gets made at anyway.

That is what I am setting up. The awkward part belongs in the same breath: it costs real sessions, because half my own turns get a deliberately worse answer for the duration. That is the price of the number, and I have not found a cheaper one that is not a self-report in disguise.

One thing I should put next to all this, because otherwise it reads as if nothing here is measured. Retrieval quality itself is benchmarked and defended in CI — +33% Precision@1 over raw BM25, 98.2% Recall@3 against an external corpus, reproducible with one command. What I cannot yet prove is the step after that: whether finding the right record changes what the assistant does. That is the gap the hold-out is for, and it is the honest boundary between what I can show you and what I am still owed.

Collapse
 
carlosjcastrog profile image
Carlos José Castro Galante

Counting prevention instead of usage is the reframe I didn't know I needed. I ran into something similar with Steering documents in Kiro during a hackathon, where the value only showed up as an absence, bugs that quietly never happened because the context was already there. The team memory angle in cachly is the problem I haven't figured out yet, checking it out!

Collapse
 
heinrichneb profile image
Heinrich Neb

"The value only showed up as an absence" is the whole measurement problem in one line, and you got there from a different direction than I did.

That's why the metric is so slippery: an event that didn't happen leaves no row in any table. You can count recalls, you can count writes - you cannot count the bug that never got filed. Every number I have measures delivery and I keep having to stop myself from labelling it prevention.

The only honest instrument I've found is withholding. Hold back the top record on a random half of eligible turns and compare outcomes in aggregate. It costs real sessions - half your own turns get a deliberately worse answer - and I haven't found a cheaper version that isn't a self-report in disguise. If Kiro's steering docs gave you any way to see the absence directly, I'd genuinely like to hear it.

On team memory, the honest state: it's built and it runs - every lesson carries the name of whoever learned it, and there's cross-author reuse tracking, which is the number that matters (how often you recall something a teammate wrote). What I don't have is teams using it. So the feature exists and the evidence doesn't.

The failure mode I expect first, and I'd rather name it before you find it: a lesson written in one person's vocabulary that nobody else ever retrieves. It counts as "stored" and prevents nothing. If you try it with a team, that's the number I'd watch - the share of lessons never recalled by anyone but their author.

Collapse
 
carlosjcastrog profile image
Carlos José Castro Galante

The honest answer to your question is that Kiro's steering docs don't expose the causal mechanism directly either. What we can show is the document itself: explicit rules covering no comments in any form, strict TypeScript with no any, one responsibility per function with a thirty line limit, strict layer separation, and even commit message format and style, all applied without a single observed violation across hundreds of generated files in manual review.

The claim we can make with confidence is that the generated code was consistent with those rules from start to finish. What we cannot claim is that Steering corrected anything in the moment, because we never captured a case where Kiro generated a comment and then suppressed it. The absence of violations is observable. Whether there were generation attempts that got corrected is not visible to us.

Your withholding idea is the only method I've seen that would actually get at the causal question. The cost you describe is real and I haven't thought of a cheaper version either.

On the vocabulary problem, that's the failure mode I'd be most worried about too. A lesson stored in one person's framing that nobody else retrieves doesn't prevent anything. If I get to test cachly with a team I'll watch that number first

Thread Thread
 
heinrichneb profile image
Heinrich Neb

"The absence of violations is observable. Whether there were generation attempts that got corrected is not visible to us." - that sentence separates two things I've been running together all day, and I want to give you something back for it.

You don't need my expensive method. Your case is cheaper than mine, and it's cheaper for a specific reason: your rules are mechanically checkable.

No comments. No any. One responsibility, thirty lines. Layer separation. Commit message format. Every one of those is a thing a script can count in a file, without a human deciding anything.

So the experiment is not a withhold - it's an A/B on generation:

  1. Take N tasks. Generate each twice: once with the steering doc in context, once without.
  2. Run the same checker over both sets. Count violations per file.
  3. The difference is the causal effect, and it cost you zero real sessions.

That works because you never have to observe the suppression. You only need the outcome under two conditions - and unlike my case, "was this output correct?" is a grep, not a judgement call.

Why my problem can't use that: my rule is "did the recalled lesson change what the assistant did", and there is no checker for that. The output isn't right or wrong in a countable sense; it's a different answer. That's the whole reason I ended up at withholding, and your case shows the boundary clearly: withholding is the price you pay for an unverifiable success criterion, not for a causal question. If your criterion is checkable, the cheap version exists.

One caveat before you run it, from being burned this weekend: run the checker on the without-steering set first, and confirm it actually reports violations. If it comes back clean on both, you haven't proved steering is unnecessary - you've proved your checker can't see. A guard that finds nothing and a guard that has nothing to find produce identical output.

On the vocabulary number: yes, that's the first one I'd watch too. If you do test with a team, the specific figure is share of lessons never retrieved by anyone but their author. High means the memory is measuring authorship.

Thread Thread
 
carlosjcastrog profile image
Carlos José Castro Galante

That experiment design is exactly what I needed and I hadn't seen it because I was still thinking about it as an observation problem rather than a comparison problem. The checker-first step is the one I would have skipped and probably would have drawn the wrong conclusion from a clean result on both sides.

Going to run this properly: generate the same set of tasks with and without the steering doc, run a linter pass over both, and report the difference. The rules are specific enough that violations should be detectable if they exist. I'll share what comes out.

On the vocabulary number, share of lessons never retrieved by anyone but their author is the right metric. I'll track that if I get to test with a team.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

One addition before you run it: the checker-first step is also the cheapest place to catch a broken experiment. If the linter finds zero violations in BOTH arms, suspect the checker before concluding the doc works - feed it one file with a deliberately planted violation first. That's the negative control's negative control. Looking forward to your numbers, and yes: share-of-lessons-never-retrieved-by-anyone-but-their-author is the metric I'd publish even solo - your future self counts as a second reader.

Collapse
 
alexshev profile image
Alex Shev

The implementation detail that matters most is making the assumption visible. For this kind of work I would put the invariant in CI or monitoring, then document the recovery path alongside it. That is how a one-time fix becomes a reliable operating practice.

Collapse
 
heinrichneb profile image
Heinrich Neb

Agreed on the principle, and I want to add the failure mode that sits right behind it - because "put the invariant in CI" is exactly what I did, and it went wrong twice in ways worth naming.

A guard can be green and blind. I had a check asserting that a benchmark number appeared in the server's output. The number was a hardcoded string. The check guarded the sentence, not the measurement - and a number the server does not compute cannot fall. It was green for weeks while the real figure was ten points behind a flat file.

A guard can watch the spelling instead of the rule. This one is from today. I wrote thirteen checks over a harvesting tool. Two hours later I rewrote the tool's transport - same rules, different words - and seven of thirteen went red, while nothing had gotten worse. The checks were asserting identifiers, not behaviour. I rewrote them to run the actual functions and only left a text assertion where an execution genuinely needs a network.

So the version of your rule I'd now write for myself: the invariant must be executed, not spelled. If a check can pass on a codebase where the thing it protects has been deleted and re-implemented differently, it's protecting the name.

Your recovery-path point is the half I'm weakest on and I'll take it plainly. My guards say "this is wrong"; most of them don't say what to do. The two I've fixed since read like this: the failing message names the replacement to use and prints the first twelve offending sites with file and line. That's the difference between a red check and an operating practice, and you named it better than I had.

Collapse
 
alexshev profile image
Alex Shev

That example makes the distinction concrete: a check can protect a string, an identifier, or the behavior we actually care about. The most useful guards exercise the boundary and then explain the recovery path in the failure output, so the next operator does not have to rediscover the rule under pressure.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

The three-level ladder - a check can protect a string, an identifier, or the behavior - is worth keeping, because it doubles as an upgrade path: most guards are born on level one, and the honest question at review time is "which level is this, and is that the level we care about?" Your second point I'd underline twice: the failure output as the recovery manual. We've started writing guard messages as instructions to the next operator - not "count mismatch" but "this number may only go down; whoever lowers it records the new value HERE, that is the only allowed way to change this test." The rule travels with the failure, so the person under pressure at 2 a.m. gets the constitution, not just the verdict. A guard that explains its own recovery path is the difference between a tripwire and a colleague.

Collapse
 
abhiix0 profile image
Abhiix0

Great post. the "count the saves, not the usage" distinction is such a simple but sharp way to separate real tools from busywork.

The hit/miss log trick is a nice touch too, cheap way to get a real number instead of a gut feeling.

Question: how does cachly handle a saved lesson that turns out to be wrong later? Does it get overwritten, flagged, or just sit there until someone catches it?

Collapse
 
heinrichneb profile image
Heinrich Neb

Thank you - and that question has a precise answer, because I went and counted this afternoon instead of guessing.

Overwritten, mostly. A lesson is keyed by topic, so writing a correction under the same topic replaces the text and bumps a version; the audit trail keeps what changed. In my own store 488 of 521 records are past version 1, and of the 48 that explicitly say they correct something, 39 correct their own earlier text. So in the common case the wrong version is gone rather than sitting next to its replacement.

What does not happen, and this is the honest boundary:

A record cannot mark a different record as superseded. If the correction lands under a new topic name, both survive and compete on relevance alone.

And nothing detects rot. A lesson that quietly stopped being true - the server moved, the flag got renamed - sits there looking exactly as confident as the day it was written, until a human notices and writes the correction. There is no valid-from date, so retrieval cannot prefer "currently applicable" over "still plausible".

So the accurate answer to your three options: overwritten when someone catches it, and sitting there when nobody does. The first half is solid. The second half is the part I would rather name here than have a user discover on their own.

Collapse
 
abhiix0 profile image
Abhiix0

"Sitting there looking exactly as confident as the day it was written". that's the line. Most people asked this would've rounded up to "we handle it." You went and pulled the numbers instead.

The topic-keyed overwrite makes sense as a default. The real gap is no way to prefer "current" over "plausible", that's the honest next feature, not a nice-to-have.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

Thank you - and since you were generous about the counting, I owe you a correction on one of the numbers I handed you.

I wrote "488 of 521 records are past version 1". I went back into the code today. That field is a schema version - a constant the writer stamps on every record to say which writer produced it. It is not a revision counter, and I read it as one.

The number I should have given you: 257 of 524 lessons (49%) have been overwritten at least once, 408 overwrites in total, 638 superseded versions still on disk. That happens to support the point more strongly than the wrong number did, which is not an excuse. I quoted a field without checking what it counted, one comment after asking someone else to stop trusting impressions.

On "prefer current over plausible" - you are right that it is the feature and not the polish, and it splits into two things that can be built separately.

The cheap half is a valid-from date, so retrieval can rank a currently-applicable fact above one that merely still looks true. The expensive half is supersession: letting one record point at another and say "this replaces it". The hard part there is not the link. It is noticing that the link should exist, when the correction lands under a different topic name and nothing mechanical connects the two.

Your framing exposed a third thing I had not separated out. Neither of those detects rot on its own. A lesson nobody ever contradicts, about a server that quietly moved, stays confident forever - there is no contradiction to find. Valid-from at least makes the age visible at ranking time instead of only in the record, which is the difference between "still plausible" and "still current" being a thing the ranker can see.

That is the roadmap now, in that order, and it came out of your comment rather than my planning.

Thread Thread
 
abhiix0 profile image
Abhiix0

Correcting a stat against your own point, unprompted, one comment after asking people to trust the numbers, that's the credibility move most people can't make.

The split you landed on is the right one: valid-from is cheap and ships now, supersession is the hard, real feature, and rot is the thing neither solves. Naming that gap yourself is worth more than shipping around it.

Collapse
 
mickyarun profile image
arun rajkumar

The one-user phase is underrated as a design phase. You get to change the schema on a Tuesday because you are the only person who would notice.

Reading the thread above on stale recalls, the part I would push on is writes rather than reads. Storing a fact is easy. Deciding what happens when a new one contradicts it is the whole product. Surfacing the contradiction, which you say cachly does, is the right instinct, but it hands resolution to whoever is reading. When that reader is an agent mid-task, it picks one and keeps going, and you find out later.

What worked for us on operational context was giving facts a reason to die instead of a confidence score. This is true until that config changes, and when the config changes the fact goes with it. Invalidation by event rather than by age or by vote.

Does cachly have any notion of a fact being scoped to something that can change underneath it, or is contradiction always resolved at read time?

Collapse
 
heinrichneb profile image
Heinrich Neb

You moved the question to the write path and that is where it belongs. I went
and read our own code rather than answer from memory, and the answer is worse and more interesting than a plain no.

Is there a notion of a fact scoped to something that can change underneath
it? Yes - and it does not reach the reader.

The write path takes a depends_on list (["node:>=20", "docker:running",
"wireguard:active"]
) and builds a reverse index: dependency → the topics that
rest on it. There is a trace_dependency call that walks that index and, with
mark_review=true, stamps needs_review: true on every dependent record.

That is exactly the shape you describe. Here is what is wrong with it.

One. I grepped the retrieval path for needs_review. Zero occurrences. Not
in the recall handler, not in the ranking core. The flag is written, and then
it is rendered as a badge inside trace_dependency's own output - a tool
nobody calls mid-task. A record marked needs_review ranks and returns exactly
as it did before. So the invalidation exists as bookkeeping and not as
behaviour, which is the same as not existing for your agent mid-task.

Two. Nothing fires it. mark_review is a parameter someone passes after
they already noticed the dependency changed. That is not invalidation by event.
That is invalidation by somebody remembering, which is the thing the memory was
supposed to replace.

Three, and this is the one that made me wince. The dependency index is
written with a 90-day expiry. The mechanism for invalidating by event is itself
invalidated by age. Another commenter in this thread had just pushed back on
that same TTL on the history side; I did not know it also ate this.

So: contradiction is resolved at read time, by whoever is reading, exactly as
you assumed - and the write-time machinery that would have prevented that is
built up to the last inch and then stops.


On "a reason to die instead of a confidence score" - I am taking that whole,
and not only for the memory.

Your sentence about the agent mid-task ("it picks one and keeps going, and you
find out later") describes a failure I hit three times today, in three
systems that have nothing to do with each other. Same shape every time: no
state for "I cannot say", so something plausible gets substituted.

  1. The ranker. Features are normalised across the candidate pool. A missing
    value became 0 - the worst value, not a neutral one. 108 records were
    being penalised for a field they were never supposed to have. I measured
    with the feature and without, concluded it was harmful, and removed it. The
    measurement was right. The conclusion was wrong.

  2. A health endpoint. An instance with zero records was classified "100 %,
    healthy" - the best value. Paired with a random sample of 8 out of 67
    instances, mostly empty test accounts, that made ten consecutive calls eight
    seconds apart come back five times "fine" and five times "outage". Our alerts
    had been flapping for two nights and I had already fixed a different, real
    cause one layer up. This one was underneath it.

  3. My own tooling. I wrote a contrast checker for our UI that hardcoded a
    white background. Run against a dark app it produced 341 findings, every one
    of them with a line number and two decimal places, every one of them wrong.
    Had I not checked, I would have "fixed" a working interface.

Three systems, three substituted defaults, three different directions - worst,
best, and assumed. The common part is not the value. It is that none of them
could say "no statement". Your framing names the fix better than mine did:
a fact needs a reason to die, and a system needs a way to say it has nothing
to report. Those turn out to be the same missing thing seen from two ends.

The health endpoint now has three states instead of two — aus (records
present, index gone), leer (answered, nothing to say), nicht_gemessen (did
not answer). Keeping leer and nicht_gemessen apart matters more than it
looks: collapsing them turns the common case into an alarm, which is worse than
the flapping was.

What I owe you, in order, and I would rather write it down than let it stay a
preference:

  • needs_review has to be read at rank time, or the flag is theatre.
  • The dependency index must outlive its TTL, or event-based invalidation dies on a timer.
  • Firing it needs to be automatic for the dependencies a machine can observe (a version, a running service, a config hash), and manual only for the ones it cannot.

The first is small and I had not seen it until you asked. Thanks for pointing
at the write path - I would have kept polishing the read side.

Collapse
 
mickyarun profile image
arun rajkumar

The three-states fix is the one I'd defend hardest, and payments will back you up on it. "No payment exists" and "we could not reach the bank" look identical from the caller's side and mean opposite things. Collapse them and the retry logic does the wrong thing at the worst possible moment, because a retry against nothing-happened is free and a retry against we-don't-know is how someone gets charged twice.

On needs_review at rank time, I'd go further than ranking. Ranking still lets the record through, just lower, and an agent mid-task will take the third-best answer without ever noticing it was third-best. What worked for us was making the stale record refuse to serve rather than serve quietly with a worse score. Loud and useless beats quiet and plausible. You can always add an override for the caller who genuinely wants the last known value and says so out loud.

The TTL on the dependency index is the one I'd fix first though, ahead of both. Not because it's the biggest, but because it's the one that fails silently on a schedule you didn't choose. Day 89 everything works. Day 91 the invalidation stops firing and nothing anywhere changes shape. Every bug I've had that waits three months to show up got shipped by someone confident, and most of those times it was me.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

Payments as the argument for three states is the strongest version of it - "retry against nothing-happened is free, retry against we-don't-know is how someone gets charged twice" compresses the whole design into one sentence. Taken. On refuse-to-serve versus serve-with-a-warning, we landed one notch away from you, deliberately, and the reason is specific to memory: a superseded record's history is itself information - why it was replaced is often the answer's most useful half. So our stale record serves loudly: the banner is the first line of the text the agent actually reads, not metadata it can skip, and it points at the successor by name. Your scenario - the agent silently taking third-best - is real, but it's a rendering failure, not a ranking failure; a warning the caller can't not-read does the refusing at the right layer. Where I'd adopt your version outright is anything transactional: memory can afford a marked ghost, a payment path can't.

Your TTL point I'll take further, because day-91 bugs are a class we keep meeting: anything that changes behavior on calendar time needs a canary that crosses the boundary early and often. For a 90-day TTL: one synthetic entry with a 7-day TTL whose expiry must be observed every week, through the same invalidation path. If the weekly ghost stops dying, the mechanism broke - 84 days before it matters. And one question back on your override ("the caller who genuinely wants the last known value and says so out loud"): do you audit those? Every override design I've shipped drifted toward being the default path within a quarter, precisely because it always works. An override that isn't counted is a refusal that isn't one.

Collapse
 
suraj09 profile image
Suraj Suradkar

The “prevention, not usage” metric is a really good distinction. Usage tells you the tool is being invoked; preventing repeated mistakes tells you it actually earned its place in the workflow. I’d be curious how that metric changes once you have multiple users with very different memory patterns.

Collapse
 
heinrichneb profile image
Heinrich Neb

Thanks - and I have to give you the honest answer rather than the good one: I can't tell you yet, because I don't have that data.

The tool has effectively one heavy user, so every number I have about prevention describes one person's memory patterns. I could dress that up as an early finding, but it'd be a description of me, not of the metric.

What I think would actually show up with several different users is the failure mode, not the success: a lesson written in one person's vocabulary that never gets recalled by anyone else, so it counts as "stored" and prevents nothing. Prevention is measured at recall time, and recall depends on the words the asker uses. That's the number I'd want to watch first - the share of stored lessons that never get retrieved by anyone but their author. If that's high, the metric is quietly measuring authorship rather than usefulness.

If you end up running something similar across a team, I'd genuinely like to hear whether that's what breaks first.

Collapse
 
suraj09 profile image
Suraj Suradkar

“The lifetime itself becomes state” is the part I hadn’t considered. I like the direction of deriving it from the referents rather than making it another constant to maintain.

And yeah, the fact that all three bugs came from questions outside the codebase is probably the most interesting result here. It’s a good reminder that code can validate implementation consistency without validating whether the system still makes sense.

Thread Thread
 
heinrichneb profile image
Heinrich Neb

"The lifetime itself becomes state" is the part I keep turning over too, and I think the resolution is smaller than it looked.

A derived lifetime is not extra state. It is the removal of state: today there are two numbers (180 days on the resolution log, 90 on the history it points at), chosen in two files, that were never compared. Derived, there is one number and one rule. The thing that can drift is gone, rather than better tuned.

Where it does become state is the case I raised with @pm25coder: the referent set is not fixed at write time. A pointer can acquire new referents later, so "as long as the longest-lived thing it points to" is a moving target. My current answer is that this is not a wrinkle but the same pattern showing up twice - re-derive on every write that touches the entry, which is exactly what our silent-failure record already does with "renewed on write". So the honest form of the rule is renewed on write, not computed at creation.

On your last point, which is the one I find hardest to argue with: three bugs, all found by questions from outside, none needing anyone to read the code. I've now hit the same shape twice more in two days, and both times from the same direction.

One of them is worth spelling out because it is the cleanest example I have. A harvesting tool of mine ran across sixteen public repositories. The first five returned 260–277 pairs each. The remaining ten returned zero, reporting "no issue has a linked PR" - for repositories with 60,000 linked pairs between them. The cause was an hour-rate limit, one line long:

} catch { return null; }
Enter fullscreen mode Exit fullscreen mode

Every error became the statement "this issue has no linked PR". The tool then wrote a valid, empty file, printed "done" and exited 0. No test could have caught it, because nothing was inconsistent: the code did exactly what it said, the file was well-formed, the exit code was correct.

It is the same family as the TTL finds. There was no state for "not measured", so silence was booked as zero - and a zero looks like a result. That is the sentence I'd offer as the generalisation of your point: a codebase can check that it is consistent with itself; it cannot check that its silences mean what it thinks they mean. Someone from outside asking "why is that number zero?" is the only instrument I've found for it.

The fix has three states now - pair, no-PR, not-measured - and it refuses to write a file at all when the third one is non-zero. A half harvest is worse than none, because it looks like a whole one.

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

Counting prevented rework is the right direction, but a non-empty recall is not yet a prevention event. It can be irrelevant, stale, or confidently wrong—and those false positives are more expensive than a miss.

I’d log a second signal after the task: was the memory cited in an action, did the user accept/correct it, and did it reduce retries or time-to-resolution versus comparable misses? Even a lightweight “used / ignored / contradicted” label gives much better evidence than HIT/MISS alone.

The product boundary also introduces memory authority: provenance, observed-at and valid-from dates, supersession links, confidence, and scope (user/team/repository/environment). Retrieval should prefer current applicable facts, while contradictions should be surfaced rather than blended.

The strongest metric may be net prevented cost: confirmed useful recalls minus corrections and incidents caused by bad recalls. That keeps optimization focused on trustworthy memory, not simply more memory.

Collapse
 
heinrichneb profile image
Heinrich Neb

You are right about the snippet, and it is the weakest thing in the piece. HIT/MISS counts delivery and I labelled it prevention. Those are not the same, and a non-empty recall that is stale or confidently wrong is worse than an empty one, because it costs the hour it takes to find out.

The part I want to ask you about, because I think it is where this gets hard: "used / ignored / contradicted" — who applies the label?

If the tool labels its own recall as "used", that is a self-reported exit code. It is cheap to collect and it will be systematically generous, because the thing being asked whether it helped is the thing whose helpfulness is in question.

So I have been trying to split your three into what a machine can observe without taking anyone's word for it:

  1. contradicted - mechanical. A later record on the same topic supersedes the one that was served.
  2. corrected -mechanical. The served record gets edited within N days of being served.
  3. stale - mechanical. A newer record on the same topic existed and was not the one returned.
  4. used - I have no honest mechanical version. Only weak proxies.

Three honest fields beat four with one that flatters. But if you have seen a way to earn the fourth without self-report, that is the thing I would most like to be wrong about.

Your provenance list reads as a gap list from here. We have author, timestamp, confidence and an audit trail. We do not have valid-from or supersession links — so "prefer the current applicable fact" is not something our retrieval can express. It blends, exactly as you say it should not. That one I can act on directly.

Net prevented cost is the sentence I will end up quoting. Our counting has no term for harm at all. A recall that sends someone the wrong way for an hour scores the same as one that was never made — which means the metric rewards more memory rather than trustworthy memory, and I did not see that until you wrote it down.

Collapse
 
heinrichneb profile image
Heinrich Neb • Edited

Second time you have handed me something sharper than the post it is under - thank you.

Collapse
 
eva-nomados profile image
Eva

I knew what an empty result meant. A new user reads an empty result as a broken tool. Man, that exact realization gets every founder who turns an internal tool into a SaaS. When you're the only user, you subconsciously tolerate horrible UX and silent errors because you know what's happening under the hood. Forcing yourself to hand it to a stranger without explaining a thing is the ultimate reality check.

Collapse
 
heinrichneb profile image
Heinrich Neb

"A new user reads an empty result as a broken tool" - I've now hit the machine version of the same sentence, and it's worse than the UX one.

Yesterday a harvesting tool of mine ran across sixteen public repositories. The first five returned 260–277 results each. The remaining ten returned zero, reporting "no issue has a linked pull request" - for repositories with 60,000 linked pairs between them.

The cause was an hour rate limit. The bug was one line:

} catch { return null; }
Enter fullscreen mode Exit fullscreen mode

Every error became the statement "this one has nothing". The tool then wrote a valid, empty file, printed "done" and exited 0.

Here's your point, one layer down: I knew what an empty result meant, so I never gave the code a way to say it didn't know. There was a state for "found nothing" and a state for "found something" - and no state for "did not measure". Silence had to become one of the two, so it became zero. And a zero looks like a result.

The founder tolerance you describe isn't only about UX. It's in the data model. When you're the only reader, "no rows" is a sentence you finish in your head, so the schema never learns to finish it. The fix wasn't better error handling - it was adding the third state, and refusing to write a file at all when it's non-zero. A half harvest is worse than none, because it looks like a whole one.

Your last line is the one I'd put on the wall: handing it to a stranger without explaining a thing. That's also the only way I found this — a colleague asked "why is that number zero?" and I had no answer that wasn't "huh".

Some comments may only be visible to logged-in visitors. Sign in to view all comments.