<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Swapnanil Saha</title>
    <description>The latest articles on DEV Community by Swapnanil Saha (@swapnanilsaha).</description>
    <link>https://dev.to/swapnanilsaha</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3939906%2F9f37b94e-be6e-42b9-a63e-34b65dca3522.jpeg</url>
      <title>DEV Community: Swapnanil Saha</title>
      <link>https://dev.to/swapnanilsaha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/swapnanilsaha"/>
    <language>en</language>
    <item>
      <title>The Bugs Only Dogfooding Finds: A Month Living Inside My Own Memory Daemon</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Sat, 08 Aug 2026 17:11:39 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/the-bugs-only-dogfooding-finds-a-month-living-inside-my-own-memory-daemon-5521</link>
      <guid>https://dev.to/swapnanilsaha/the-bugs-only-dogfooding-finds-a-month-living-inside-my-own-memory-daemon-5521</guid>
      <description>&lt;p&gt;There is a kind of bug you cannot find by testing. You find it by having to live with the thing: relying on it at two in the morning, following its advice while tired, restarting it in the middle of something that matters.&lt;/p&gt;

&lt;p&gt;I build a tool called vectr. It is a small server that runs on my laptop and gives coding agents two things: search over a codebase, and a memory that survives past the end of a conversation. The part that makes this post possible is that the sessions which develop vectr use vectr as their memory. Findings about the daemon get written into the daemon. When it misbehaves, it misbehaves inside the work of fixing it.&lt;/p&gt;

&lt;p&gt;Six defects came out of the last month that way. The test suite was green for every one of them, and it is not a small suite: roughly 3,900 tests, run in full before every push rather than sampled against the diff. I also run review agents over every branch before merge, deliberately pointed at the whole product rather than the change, and they did not flag any of these either. What found them was residency, by which I mean nothing more sophisticated than depending on the thing daily. Me, mid-task on something else, being annoyed.&lt;/p&gt;

&lt;p&gt;This post is six war stories with their root causes and their fixes. It is also an argument about &lt;em&gt;why&lt;/em&gt; a test suite cannot reach this class of defect, which turns out to be a more precise claim than "real usage is messy."&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 1: The Setup
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. Living Inside the Thing You Are Building
&lt;/h2&gt;

&lt;p&gt;Some vocabulary first, because the bugs are unintelligible without it, and none of it is hard.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;daemon&lt;/strong&gt; is a program that runs in the background and answers requests. Vectr's daemon holds an index of my code and a store of notes, and it answers two kinds of caller. One is the &lt;strong&gt;CLI&lt;/strong&gt;, the commands I type in a terminal. The other is my editor's AI agent, which talks to the daemon over &lt;strong&gt;MCP&lt;/strong&gt; (Model Context Protocol), a standard for exposing tools to an AI assistant. Both callers reach identical logic underneath through separate thin translation layers, one per protocol, and those layers are separate code with separate bugs. That will matter in episode four.&lt;/p&gt;

&lt;p&gt;The instance I actually live in serves nine project folders at once and runs in &lt;strong&gt;memory-only mode&lt;/strong&gt;: notes and recall, no code index, no file watching. That mode exists because indexing is expensive and memory is not, a distinction episode six pays for in full. As I write this the instance holds 606 notes and has been up long enough that I no longer remember the exact command that started it. That last detail is the entire first bug.&lt;/p&gt;

&lt;p&gt;Living inside a tool is different from testing it in three specific ways, and each one shows up below:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The state is old.&lt;/strong&gt; A test starts from nothing. My daemon started weeks ago with flags I have since forgotten, on a machine that has been asleep, woken, filled, and cleaned since.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I am busy.&lt;/strong&gt; Nobody dogfooding is dogfooding. They are doing something else, and the tool is in the way of it. That is when you follow instructions literally instead of thinking them through, which is exactly how a product's own advice gets to hurt you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing is staged.&lt;/strong&gt; No fixture built the workspace, no fixture chose the port, no fixture decided where on disk the checkout sits. The environment is whatever accumulated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five of the six bugs below are vectr's. One is arguably mine, about my laptop rather than my code, and I have included it because the boundary it exposes is the same shape as the others and because pretending your hardware is not part of your system is how you spend three days blaming an API.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why a Green Test Suite Proves Less Than It Looks
&lt;/h2&gt;

&lt;p&gt;Here is the mechanical reason these bugs survived a suite that catches plenty of others.&lt;/p&gt;

&lt;p&gt;Every test builds its own world before it asserts anything. A temporary directory with four files in it. A fake service object standing in for the real one. A port the harness picked. A clock that does not move unless the test moves it. This construction is not sloppiness, it is the whole point: a test is repeatable precisely because it controls everything the code touches.&lt;/p&gt;

&lt;p&gt;The control is also the ceiling. A defect whose cause lives outside the constructed world cannot be caught by a test that does not construct it, and constructing it means having thought of it first.&lt;/p&gt;

&lt;p&gt;I want to be careful here, because there is a cheap version of this claim that is wrong. Nothing stops you from writing a test that starts a daemon with one set of flags, shells out to &lt;code&gt;restart&lt;/code&gt;, and asserts the mode survived. I wrote roughly that test after the fact, and it now guards the fix. So the barrier is not expressive power. The barrier is that the test encodes a hypothesis, and every one of the six below was invisible precisely because I did not hold the hypothesis. Residency does not give you tests you could not have written. It tells you which ones were worth writing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The analogy that made this click for me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tests are in vitro. A compound behaves beautifully in a dish and then fails in a body, because the body has a liver, a bloodstream, a kidney, and a schedule of meals. The dish has none of those, so the dish cannot possibly tell you about them.&lt;/p&gt;

&lt;p&gt;A test suite has no liver. No operating system deciding to suspend your process, no second process holding a socket open from ninety seconds ago, no editor with a live connection it will not re-open, no memory of the flags you typed last month. Residency is in vivo: same compound, actual body.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the useful question is not "did we test enough" but "what is on the other side of the wall the tests are built inside." For the six that follow, the answer is a specific boundary each time: process lifetime, packaging metadata, live client connections, transport adapters, the filesystem path the code happens to sit at, and the power manager. I will come back and lay them out side by side in section nine, once the stories have earned it.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 2: Six Bugs
&lt;/h1&gt;

&lt;h2&gt;
  
  
  3. The Remediation That Was the Hazard
&lt;/h2&gt;

&lt;p&gt;I ran a routine command and the tool printed a warning at me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vectr: daemon on port 8765 is running older code
(1.7.0+&amp;lt;sha&amp;gt; vs 1.6.0+&amp;lt;sha&amp;gt;) - run 'vectr restart &amp;lt;workspace&amp;gt;'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set aside that the warning was wrong, which is the next section. I did what it said. I ran &lt;code&gt;vectr restart &amp;lt;workspace&amp;gt;&lt;/code&gt;, exactly as printed, because that is what you do with a suggested command when you are in the middle of something else.&lt;/p&gt;

&lt;p&gt;The daemon came back in &lt;strong&gt;full mode&lt;/strong&gt;. It had been running memory-only for weeks. &lt;code&gt;restart&lt;/code&gt; reads its mode from the flags you typed on that line and nowhere else, so a restart that omits &lt;code&gt;--memory-only&lt;/code&gt; is a restart into the default, which is full indexing. Full mode on a nine-root workspace means walking every folder, cutting every source file into chunks, and computing an embedding for every one of them. On this laptop that is not a background task, it is an event. Section eight has the numbers.&lt;/p&gt;

&lt;p&gt;I noticed within a couple of minutes because the machine got loud. There was measurable collateral too: the persisted chunk store went from 12,092 chunks down to 9,069 across that unintended full-mode interlude. My best guess is that the re-index rewrote the store under a different exclusion state than the one that had originally built it, but I have not chased that to ground, and I would rather leave it as an open loose end than dress a guess up as a finding. The notes were untouched, all 604 of them at the time, which is the only reason this was an annoyance rather than a disaster.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning: remediation text is an interface&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Whatever your error message tells the user to run, they will run verbatim. Not a corrected version, not with the flags they used originally. Verbatim, while distracted, because that is the condition under which people read error messages.&lt;/p&gt;

&lt;p&gt;Which means a suggested command is part of your API surface and inherits every obligation of one. If the safe invocation depends on state the user is expected to remember, you have shipped a trap and labeled it "help."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The interesting part is not that restart drops the mode. It is the shape of the failure: &lt;strong&gt;the product's own remediation advice was the mechanism of harm&lt;/strong&gt;. Two independently defensible decisions composed into something neither of them was. The staleness banner suggests a restart. Restart takes its mode from what you typed. Nobody wrote a bad line of code, and the bug lives in the gap between two files.&lt;/p&gt;

&lt;p&gt;What makes it slightly embarrassing is that the CLI already knows the answer. There is a helper in it that asks a running daemon for its current mode over the status endpoint, used elsewhere for exactly this kind of question. Restart could call it before killing anything, and does not. The state was one HTTP request away the whole time.&lt;/p&gt;

&lt;p&gt;The durable fix has a precedent in the same codebase. When you decline editor config writes, that choice persists in a small marker file in the workspace so the tool stops asking. Mode, port, and host should persist the same way, and restart should reuse the persisted config unless you explicitly override it. Until that lands, the banner should print the mode-complete command rather than the bare one, since it just talked to the daemon in order to produce the warning at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What survives a restart, as shipped:&lt;/strong&gt; port (yes, since the port fix in episode three), roots served (yes, from the workspace file), mode (no). The one flag that decides how much CPU the next hour costs is the one that does not carry across.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The Version Check That Cried Wolf Backwards
&lt;/h2&gt;

&lt;p&gt;Now the warning itself.&lt;/p&gt;

&lt;p&gt;That check exists for a genuine problem. Most of what you see from vectr is rendered by the daemon, not the CLI, and the CLI imports the working tree fresh on every invocation. Upgrade the source while a daemon from last week is still running and you get a silent split: new CLI, old rendering, no error anywhere. I hit that in early July and could not see any of my own UX changes. The fix was a shared stamp, computed identically on both sides: the package version plus the short git sha when running from a checkout, stamped into the daemon at startup and exposed in its status response.&lt;/p&gt;

&lt;p&gt;Then it started lying to me. The banner said the daemon was running &lt;em&gt;older&lt;/em&gt; code, and printed &lt;code&gt;1.7.0+&amp;lt;sha&amp;gt;&lt;/code&gt; for the daemon against &lt;code&gt;1.6.0+&amp;lt;sha&amp;gt;&lt;/code&gt; for the CLI. Same seven characters of sha on both sides. The daemon's version was the &lt;em&gt;higher&lt;/em&gt; one. Every part of that sentence contradicts the other parts.&lt;/p&gt;

&lt;p&gt;Here is what happened. The version component comes from installed package metadata, and vectr is installed in editable mode, which means the metadata was written once when I ran the install and has been sitting there ever since. The project config had moved to 1.7.0. The metadata still said 1.6.0. The daemon, started after a reinstall, had picked up the newer metadata. So the CLI compared its own stale label against a fresher one and reported the difference as the daemon being behind.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Insight: inequality is not order&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two strings that differ tell you they differ. They do not tell you which came first, and no amount of string comparison will make them. The check had one component carrying genuine code identity, the git sha, and one component that is a label a human types into a file and a packaging tool caches at install time. It compared the whole thing, then described the result with a word, "older," that only an ordering could justify.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Write the predicate out and the error is obvious:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What the three questions actually require&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is_different = sha_daemon != sha_cli&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;That is the only one you can answer from the stamps alone, and it is the one the feature exists for. "Is the daemon running my code" means "is it running my commit," so the version prefix has no business in the comparison at all.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is_older = git merge-base --is-ancestor sha_daemon sha_cli&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Ordering is a question about history, not about text, so answering it costs a git call and can fail outright when the two commits sit on diverging branches. Which is a real state, and one where no honest answer to "which is older" exists.&lt;/p&gt;

&lt;p&gt;The shipped check computed the first question over the wrong operand and then reported the second one without ever asking it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the fix is to compare the sha and to word the message as a version mismatch whenever only the label differs. Never claim "older" from a lexical difference.&lt;/p&gt;

&lt;p&gt;What makes this worth more than a paragraph is the second-order damage. A false alarm does not cost you one wasted minute. It costs you the alarm. I now have a check that fires on a condition I know to be benign, which trains me to skim past it, which means it will not work the day it is right. And it caused real harm through a path nobody designed: it told me to restart, and the restart was the first bug. A false positive in a warning is not a cosmetic defect when the remediation attached to it is expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The Restart That Orphaned the Session
&lt;/h2&gt;

&lt;p&gt;Same restart, different casualty. The editor session I was working in lost every vectr tool. Not erroring when called, which would at least have been a clue: simply absent from the session's tool registry, as though the server had never offered them. They never came back. A new session in the same editor had all of them.&lt;/p&gt;

&lt;p&gt;Meanwhile the daemon was completely healthy. I could hit its HTTP API by hand and get correct answers to the same questions the vanished tools would have answered. The server was fine, and what had broken was the relationship between it and a client that no longer existed to be told about it.&lt;/p&gt;

&lt;p&gt;One mechanism behind this got caught and fixed on 29 July, and it is a good example of how ordinary the cause of a total outage can be. Vectr picks its port through a small registry: when a workspace has a previous port recorded, reuse it, specifically so that already-written editor config files stay valid. The reuse check probes whether the port is free by trying to bind it. A socket that was just closed sits in TIME_WAIT for a while afterwards, and a plain bind attempt against a TIME_WAIT socket fails. So the probe reported the previous port as busy, in exactly the window that every restart creates, and the port walk moved the daemon to 8766 while every config file on disk still said 8765.&lt;/p&gt;

&lt;p&gt;Reproduced live: stop, start one second later, daemon bound to 8766. The editor hooks kept working the whole time, because they look the port up in a registry file instead of trusting a config value, which is why the outage looked partial and confusing rather than total. The tool surface broke with no error anywhere naming the cause. A second defect compounded it: the config writer reported that it had updated the settings file for every folder, and the file still read &lt;code&gt;localhost:8765&lt;/code&gt;, because the port handed to the writer was not the port that ended up bound.&lt;/p&gt;

&lt;p&gt;The fix landed as four changes, all deterministic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free-port probe sets &lt;code&gt;SO_REUSEADDR&lt;/code&gt;, matching what the actual server bind does, so TIME_WAIT reads as free.&lt;/li&gt;
&lt;li&gt;Port selection retries the previous port a few times with a short delay before it ever walks upward.&lt;/li&gt;
&lt;li&gt;Vectr's own entry in an editor config file is always rewritten to the port that was actually bound, while every other key in that file stays merge-only-add.&lt;/li&gt;
&lt;li&gt;Start and restart compare each known config file against the real bind and print a warning naming the stale files.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And now the honest part, because the port fix does not close this. The client I use does not re-handshake after it loses a connection. Nothing in the transport obliges it to, and other clients may well retry, but from mine the tools stay gone for the rest of that session even with the port preserved, because the process it completed its handshake with no longer exists. Restarting a server is cheap for the server and expensive for whoever was mid-sentence with it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning: restart is a client-facing operation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A daemon's restart story usually gets designed from the daemon's point of view: come back up, reload config, re-bind, resume. That is the easy half. The half that decides whether a user curses at you is what happens to the callers that were mid-conversation with the old process.&lt;/p&gt;

&lt;p&gt;A server cannot force a client to reconnect. What it can do is need restarting less often, say out loud what a restart will cost before doing it, and document a fallback surface instead of leaving each user to discover their own.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The detail I keep coming back to is that I did not know the HTTP fallback was usable until the moment I needed it, and I wrote the thing. An undocumented escape hatch is an escape hatch for exactly one person.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The Mock That Lied Politely
&lt;/h2&gt;

&lt;p&gt;This one is older than the others, from mid-June, and it belongs here because it is the purest example of a test suite validating a fiction.&lt;/p&gt;

&lt;p&gt;Symptom: the HTTP endpoint that answers "where is this symbol defined" returned a 500 for every symbol, in every language, every time. Not a rare path or an unlucky input, the whole endpoint, dead on arrival for as long as it had existed. The test suite was green.&lt;/p&gt;

&lt;p&gt;Cause, in one line: the service method returns a &lt;code&gt;LocateResult&lt;/code&gt; wrapper object, and the route iterated it as if it were a list.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/routes.py
&lt;/span&gt;
&lt;span class="c1"&gt;# before
&lt;/span&gt;&lt;span class="n"&gt;symbols&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locate_with_snippets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;symbols&lt;/span&gt;        &lt;span class="c1"&gt;# TypeError: 'LocateResult' object is not iterable
&lt;/span&gt;
&lt;span class="c1"&gt;# after
&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locate_with_snippets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;symbols&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the part that matters. Why did nothing catch it?&lt;/p&gt;

&lt;p&gt;Two reasons, and they reinforce each other. First, there were no tests for that route at all. The MCP path to the same feature was tested and happens to be unaffected, because it hands the whole result object to a formatter instead of iterating it. Somewhere along the way "the feature is tested" quietly became "every way of reaching the feature is tested," which are different claims once two translation layers sit over one core.&lt;/p&gt;

&lt;p&gt;Second, the shared service mock that every API test uses was configured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/test_api.py, the fixture
&lt;/span&gt;
&lt;span class="c1"&gt;# before: a type the real service never returns
&lt;/span&gt;&lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locate_with_snippets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format_locate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;No results.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# after: the real wrapper, with a real symbol inside it
&lt;/span&gt;&lt;span class="n"&gt;svc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locate_with_snippets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LocateResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;symbols&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Symbol&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PyDict_New&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Objects/dictobject.c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;start_line&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;812&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end_line&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;824&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)],&lt;/span&gt;
    &lt;span class="n"&gt;resolution_strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PyDict_New&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bare list. Convenient, empty, and a shape the real function has never returned in its life. Any test that had touched the route would have iterated that list happily and passed, because iterating a list is exactly what the buggy code does correctly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning: a mock is a claim about someone else's code&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you write &lt;code&gt;return_value = []&lt;/code&gt; you are not simplifying. You are asserting, silently and with nothing in the language or the tooling able to check it, that the real function returns a list. If that assertion is wrong, every test depending on it exercises a program that does not exist. That is worse than no coverage, because it reports as coverage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The obvious objection is that Python's mock library will build a mock from the real object for you, and that specced mocks are the standard answer to exactly this. Worth being precise about what that buys: auto-speccing constrains which attributes exist and what &lt;em&gt;call signatures&lt;/em&gt; are legal, so it catches you calling a method that was renamed or passing an argument that does not exist. It says nothing whatsoever about return types. &lt;code&gt;return_value&lt;/code&gt; remains whatever you assign, and no framework I know of will tell you that the real function has never once returned that shape. Which leaves one reliable discipline: capture a real payload once, and build the fixture from it.&lt;/p&gt;

&lt;p&gt;It happened again three days later in a different layer, which is what convinced me this was a class rather than an incident. A benchmark harness reported that every one of its hook injections had delivered zero content. That reads as a serious product failure, so I spent an afternoon investigating a product that was working fine. The metric was the broken part. The real event emitted by the agent CLI carries the hook's output as a JSON string inside a field, and the parser only walked nested dictionaries, so it never looked inside the string and therefore always found nothing. It had been green the entire time because the test mock fabricated a nested-dictionary shape the CLI does not emit. Injection had in fact worked: 7,404 characters delivered live. What it cost was a full paid benchmark run whose headline number was fiction.&lt;/p&gt;

&lt;p&gt;Three rules came out of that pair, and they have held up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mirror the real return type, not a convenient stand-in.&lt;/strong&gt; If you are mocking a wire format, capture one real payload first and build the mock from it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every externally reachable route gets its own test.&lt;/strong&gt; Green on one transport proves nothing about another when the adapters are separate code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a metric reads zero on a path you believe works, suspect the measurement before the product.&lt;/strong&gt; A measurement bug looks exactly like the failure it is measuring, and it is cheaper to check.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The sequence that fixed it is worth spelling out, because the ordering is the lesson. Step one: fix the fixture so the mock returns the real type, and add the route test that never existed. The suite goes &lt;strong&gt;red&lt;/strong&gt; against completely unchanged production code. Step two: fix the route to unwrap &lt;code&gt;result.symbols&lt;/code&gt;. Green again, and now pinned. The faithful mock is what made the bug visible; the code fix was the easy part that followed.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. The Test That Was Really Testing My Filesystem
&lt;/h2&gt;

&lt;p&gt;I run coding subagents in isolated git worktrees. Two integration tests failed in those worktrees and passed on the main checkout. The failures looked like a regression from whatever the agent had just changed. They were not.&lt;/p&gt;

&lt;p&gt;The tell was the clock. The failing test reported recall of 0.00 in about a third of a second, where a passing run takes fifteen. Nothing that searches a real index comes back in 0.34s. Nothing had been indexed at all, so precision and recall were trivially zero, and the test dutifully reported a search-quality score instead of the far more useful fact that its input was empty.&lt;/p&gt;

&lt;p&gt;My first explanation was a good one, which is exactly how it became lore. Those worktrees live under a dot-prefixed directory, and the indexer prunes hidden directories during its walk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent/indexer/_core.py
&lt;/span&gt;&lt;span class="n"&gt;dirnames&lt;/span&gt;&lt;span class="p"&gt;[:]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dirnames&lt;/span&gt;
               &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all_excluded&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skipping hidden directories is correct: it keeps version control internals, virtual environments, and caches out of a code index. And it does explain that particular case. As the general rule it got treated as, it turned out to be wrong, which I only found out by bothering to run a control.&lt;/p&gt;

&lt;p&gt;A three-way control run knocked out the hiddenness story as the general cause. The replacement hypothesis, "worktrees are the problem," was confounded in a way I find genuinely funny in retrospect: both non-hidden control worktrees had been created under &lt;code&gt;/tmp&lt;/code&gt;, which macOS resolves to &lt;code&gt;/private/tmp&lt;/code&gt;, and &lt;code&gt;tmp&lt;/code&gt; is an unanchored entry in vectr's own ignore file. Every supposedly clean control was excluded too, by a different mechanism, producing an identical symptom. The decisive probe was a worktree at a path with no &lt;code&gt;tmp&lt;/code&gt; component and no dot-directory: three tests passed in 16.23 seconds.&lt;/p&gt;

&lt;p&gt;Underneath the folklore was a real product bug, nastier than the test failure that led me to it. The function deciding whether to index a file was passing the &lt;em&gt;absolute&lt;/em&gt; path to the ignore-pattern matcher, while the two checks directly above it correctly used the workspace-relative path.&lt;/p&gt;

&lt;p&gt;That matters because of what an ignore pattern means. A pattern in an ignore file is defined relative to the directory containing that file. An unanchored entry like &lt;code&gt;tmp/&lt;/code&gt; matches a directory named &lt;code&gt;tmp&lt;/code&gt; at any depth &lt;em&gt;below&lt;/em&gt; that root, and by definition it can never say anything about what sits above the root, because the file that declares it has no jurisdiction there. Hand the matcher an absolute path and you have quietly extended its jurisdiction to your entire filesystem, so the pattern starts matching ancestors that the repo owner never had an opinion about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Insight: what this meant for anyone who is not me&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your repo lived under a directory named &lt;code&gt;tmp&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;env&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;cache&lt;/code&gt;, or &lt;code&gt;node_modules&lt;/code&gt;, and your own ignore file listed that name, vectr indexed zero files. Silently. The index call returned zero files and zero chunks, and zero is what success looks like when there was nothing to do.&lt;/p&gt;

&lt;p&gt;Nobody would have reported this as "the ignore matcher uses the wrong string." They would have reported "search finds nothing" and I would have asked for their query.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Fixed on 27 July at both call sites, the bulk walk and the file watcher, which now pass the relative path to the shared predicate. There was an intended side effect: path-scoped patterns like &lt;code&gt;docs/*&lt;/code&gt; now match workspace-relative paths the way ignore-file semantics say they should. Before the fix they could never match anything.&lt;/p&gt;

&lt;p&gt;The remaining work is not in the product, it is in the test. A test whose precondition is "the index is not empty" should assert that precondition and skip with a reason that names the matched pattern and the offending path component, rather than reporting recall 0.00 and inviting the next person to spend a control run rediscovering this. Environmental preconditions deserve the same care as assertions. If they stay implicit, the first plausible story about a failure becomes team knowledge, and team knowledge is very hard to unlearn.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. The Laptop That Ate the Fleet
&lt;/h2&gt;

&lt;p&gt;Long agent runs kept dying mid-stream. The error said the stream had stalled or the connection had failed, so I filed it under network flakiness on the API side and re-ran things. Roughly forty kills accumulated across four days that way.&lt;/p&gt;

&lt;p&gt;Then I did the thing I should have done on kill number two: lined the timestamps up against the machine's own power log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pmset &lt;span class="nt"&gt;-g&lt;/span&gt; log | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'Wake|DarkWake'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every kill matched a wake event &lt;em&gt;to the second&lt;/em&gt;. Five or more exact matches in a row, and every one of the forty consistent with the pattern. That is not correlation you argue with.&lt;/p&gt;

&lt;p&gt;The mechanism is unglamorous. Idle sleep on battery, or closing the lid, suspends the entire process tree. The connection to the API dies while everything is frozen. On wake, the ten-minute stream watchdog notices immediately that nothing has arrived and records a kill. The stack was never broken. It was asleep, and it got blamed for being unreachable while it was.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Insight: "the run failed" is not a diagnosis&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An error message describes the symptom at the layer that noticed. A watchdog that fires on a dead stream cannot tell you whether the stream died from a network partition, a server fault, or the operating system putting your process to sleep. Those need different fixes, and only one of them is in your code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Practical resolution: &lt;code&gt;caffeinate -is&lt;/code&gt; for the duration of any long run. The &lt;code&gt;-i&lt;/code&gt; is the flag doing the work here, since it blocks idle sleep, which is the one that fires while you are away from the machine; &lt;code&gt;-s&lt;/code&gt; blocks system sleep and only has effect on AC power. Neither can override closing the lid on battery, so it comes down to lid open or plugged in. On AC there is a system setting that prevents automatic sleeping when the display is off, and that is the durable version of the fix. One useful non-symptom: display sleep alone is harmless, so "the screen went dark and the run died" is a misleading intuition. Only system sleep kills.&lt;/p&gt;

&lt;p&gt;The second half of this episode is about load rather than sleep, and it is why full-mode indexing counts as a hazard back in episode one. During one corpus re-index I watched the daemon hold 329% CPU for about two and a half hours, with system load at 21.24 and swap at 19.0 of 20.0 GB on a machine with 16 GB of RAM. Nothing else on the laptop was usable for the duration. The cost sits entirely in the search-index lane: roughly 350,000 embeddings for that corpus, split between one vector per chunk of code and a second pass producing a vector per symbol from its signature and docstring. Memory mode costs close to nothing by comparison, which is why the always-on instance runs unnoticed for weeks.&lt;/p&gt;

&lt;p&gt;One aside that cost me an hour of confusion: processes launched from the editor's terminal are children of the editor's process tree, so the activity monitor attributes their memory to the editor. A 32 GB reading next to the editor's name was my own Python stack, not the editor being a memory hog.&lt;/p&gt;

&lt;p&gt;Before the obvious suggestion: simply running the indexer at a lower scheduling priority does not fix this. Priority governs who gets the CPU, and by the time swap is at 19 of 20 GB the contended resource is memory, where being polite about CPU buys you nothing. A governor has to pace the work and cap concurrent batches, not just ask nicely for fewer cycles.&lt;/p&gt;

&lt;p&gt;None of this is a vectr bug in the narrow sense. It became product work anyway, because the version a user hits is strictly worse than the version I hit: they install the tool, it starts indexing, their machine becomes unusable, and they uninstall. Three items came out of it. An index resource governor with a CPU budget, batch pacing, and pause-resume. Deferring the second embedding pass on large repositories. And the strategic one, that memory-only should be the default install, with search indexing an explicit per-workspace opt-in that shows a cost estimate before it starts. The entry price of a memory tool should be close to zero.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning: the OS is part of your system&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;On a server you can assume the machine stays awake and the scheduler is roughly fair. On developer hardware neither holds. The power manager will suspend you, thermal limits will throttle you, and a second heavy process will starve you. If your software takes hours and your users run it on laptops, the power model is a component you depend on, whether or not you have modeled it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Part 3: The Pattern
&lt;/h1&gt;

&lt;h2&gt;
  
  
  9. Six Boundaries a Test Suite Cannot Cross
&lt;/h2&gt;

&lt;p&gt;Laid out together, these are not six unrelated mistakes. Each one sits on a specific boundary between the program and something a test harness constructs, replaces, or ignores.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Episode&lt;/th&gt;
&lt;th&gt;The boundary&lt;/th&gt;
&lt;th&gt;Why a test cannot represent it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Restart drops the mode&lt;/td&gt;
&lt;td&gt;One process lifetime to the next&lt;/td&gt;
&lt;td&gt;The test starts the daemon it tests. It can never be surprised by a flag typed weeks ago, because it typed the flag.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Version check reversed&lt;/td&gt;
&lt;td&gt;Packaging metadata vs code identity&lt;/td&gt;
&lt;td&gt;In a test both stamps are computed in one process from one checkout. Metadata staleness needs an install that happened at a different time.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Orphaned MCP session&lt;/td&gt;
&lt;td&gt;Daemon vs its live clients&lt;/td&gt;
&lt;td&gt;The harness is the only client, and it is created after the server. There is no already-connected editor holding a session that outlives a restart.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock returned the wrong type&lt;/td&gt;
&lt;td&gt;Two transports over one core&lt;/td&gt;
&lt;td&gt;The mock is the boundary, so it cannot check itself. Coverage on one adapter says nothing about the other.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ignore matched the absolute path&lt;/td&gt;
&lt;td&gt;Code vs where it sits on disk&lt;/td&gt;
&lt;td&gt;Fixtures live in a temp directory whose absolute path nobody chose or examined. The bug needs a specific ancestor name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sleep killed long runs&lt;/td&gt;
&lt;td&gt;Process vs the OS power model&lt;/td&gt;
&lt;td&gt;Tests finish in milliseconds and the harness never sleeps. Suspension is not an event the suite has any way to produce.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these are not "the code is wrong" in the usual sense. The restart does what its arguments say. The ignore matcher matches the string it is given. The daemon binds a port that is genuinely free. In each case the defect is a broken contract with something outside the process, and the code inside the process is a faithful implementation of the wrong agreement.&lt;/p&gt;

&lt;p&gt;Which suggests a practical filter for where to look. Ask what your tests replace with a stand-in, and what they construct fresh every run. Both lists are lists of blind spots. Mine were: the environment the daemon was started in, the identity of the code, the client on the other end, the service behind the mock, the absolute path of the workspace, and the operating system. Every bug in this post is on that list, and I could have written the list before finding a single one of them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Insight: the cheapest audit I know&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Take one integration test and write down every value it invents: the directory, the port, the clock, the fake service, the flags, the machine state. That inventory is the set of assumptions your suite is structurally unable to question. You do not need residency to produce that list. You need residency to find out which items on it are actually wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  10. What Dogfooding Is Bad At
&lt;/h2&gt;

&lt;p&gt;Six good catches makes a persuasive post, so here is the other column, because a method whose limits you cannot state is not a method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I am the worst possible user.&lt;/strong&gt; I know every workaround before I reach the wall that needs it, and I never read the documentation because I wrote it. More to the point, I have not experienced a first install in months, and the first ten minutes is where most people decide whether a tool is worth keeping. The famous version of this bias is teams on fast machines missing the performance problems ordinary users hit daily. Mine runs the other way: my laptop is underpowered relative to the corpora I index, so I feel resource pain acutely and am completely blind to the fresh-start experience I never repeat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One machine, one operating system, one workflow.&lt;/strong&gt; All of the above is macOS, on a single multi-folder workspace with one particular ignore file in it. Residency samples very deeply from one point in the space and says nothing at all about the shape of the distribution around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It finds classes, not rates.&lt;/strong&gt; I can tell you the mock-fidelity failure exists and reproduces trivially. I cannot tell you how often users hit any of this, and any number I offered you would be made up. Rates need telemetry or a user base, and I have neither at a scale where a rate would mean anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The suite is not the villain here.&lt;/strong&gt; It was green through all six of these and it is still the only reason I can change any of this without fear. The three instruments answer different questions. A test pins behavior you already understand so it stops moving. A reviewer interrogates the code you wrote. Residency is the only one of them that samples the assumptions nobody ever wrote down, which is also why it cannot be scheduled or assigned. Lean on residency alone and you land in the same place as leaning on tests alone: excellent coverage of one region, confident silence about everything else.&lt;/p&gt;

&lt;p&gt;There is also a trap specific to this setup, which is that vectr is the memory of the sessions that build vectr. When the memory layer misbehaves, it degrades the context of the work fixing it. That is a real coupling and it is not all upside. It does mean I notice immediately. It also means my judgment about severity is made by someone whose current session state is affected by the bug, which is not a neutral vantage point. I have not found a good answer to that beyond writing findings down before acting on them.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. The Rule I Needed Telling Three Times
&lt;/h2&gt;

&lt;p&gt;None of the above matters if you route around it.&lt;/p&gt;

&lt;p&gt;The rule is simple to state: &lt;strong&gt;a bug found while dogfooding is a product task.&lt;/strong&gt; Fix it, or write it down with the end-user impact named. Never silently work around it. I was told this three times before it stuck, and I want to be precise about why it took three.&lt;/p&gt;

&lt;p&gt;The first two times I agreed with it in principle and kept doing what everyone does, which is notice a rough edge mid-task, work around it in two seconds, and carry on with the thing I was actually doing. The workaround is always cheaper right now. That is the whole problem: the cost is real but deferred and lands on someone else. The third time it came with the sentence that made it stick. Roughly: it is not only about you, users are going to hit this too.&lt;/p&gt;

&lt;p&gt;Which reframed the workaround as a decision rather than an omission. If I know the tool tells you to run a command that will melt your laptop, and I quietly stop running that command, I have not avoided a bug. I have decided that everyone who does not know what I know should hit it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Insight: friction is the measurement&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The moment your own tool annoys you is the only free signal you will ever get about that defect. You are already in the failing state, with full context, with the cause fresh. Working around it spends that signal on getting your task done two minutes sooner.&lt;/p&gt;

&lt;p&gt;Dogfooding as a source of anecdotes is worth very little. Dogfooding with a contract that converts every surprise into a fix or a written item is a QA instrument. The difference is entirely in what happens in the sixty seconds after the surprise.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Mechanically, what makes it stick for me is three things. A place to put findings that is not my memory, with the user impact spelled out rather than a one-line reminder I will not understand next week. A default of fix-first for anything on the surface a new user meets in their first ten minutes. And a release rule that the tail does not begin while the discovered-issue list has items on it, which stops "we will get to it" from being a decision made by silence.&lt;/p&gt;

&lt;p&gt;Two of the six bugs here are still open items rather than merged fixes, and I would rather say that than imply a clean six-for-six. They are written down with their impact, which is the deal. The distinction that matters is not fixed versus unfixed. It is filed versus forgotten.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;I opened by saying there is a kind of bug you find by living with software rather than testing it. The six here say something sharper than that. They are not random messy-reality bugs. Every one of them sits precisely where my tests construct their own world and the real world differs: the process that started last month, the metadata that lagged the code, the client that was already connected, the transport nobody tested, the path on disk, the power manager.&lt;/p&gt;

&lt;p&gt;That list is derivable. You can write it for your own system this afternoon, from an inventory of what your fixtures invent and what your mocks replace, without finding a single bug first. Residency is what tells you which entries on the list are currently costing you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Any command your error message suggests must be safe verbatim.&lt;/strong&gt; If the safe form depends on state the user is supposed to remember, persist that state yourself. Following the product's own advice should not be the hazard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare identity, not labels.&lt;/strong&gt; A git sha is what the code is; a version string is what someone typed and a packaging tool cached. And never infer "older" from two strings merely differing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A daemon with live clients needs a restart story for the clients.&lt;/strong&gt; Coming back up cleanly is the easy half; the callers mid-conversation with the old process are the half users feel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mock that returns the wrong type is worse than no test.&lt;/strong&gt; It reports as coverage while validating a program that does not exist. Capture a real payload before you fabricate one, and give every externally reachable route its own test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An empty result that reports success is the worst failure mode there is.&lt;/strong&gt; Zero indexed files looked exactly like nothing to do. Make pipelines that can produce zero justify it out loud.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your users' hardware is part of your system.&lt;/strong&gt; Sleep, thermal limits, and one heavy process starving another are all inside your failure domain if your software runs for hours on a laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert friction into fixes or the whole exercise is just anecdotes.&lt;/strong&gt; The workaround is always cheaper in the moment, and it silently decides that your users should hit what you just dodged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The parts of vectr I trust most are not the parts with the best test coverage. They are the parts I have been unable to break while depending on them for months, which is a weaker guarantee in theory and a much more convincing one in practice. If you build tools, the cheapest QA available to you is to need your own tool badly enough that its failures cost you something, and then to be honest about what it costs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;Vectr v1.0.0: The Release Gate and the Honest Numbers&lt;/a&gt;: the same instrument applied deliberately as a release gate, and the three bugs it flushed out before the tag.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Building Vectr 1: Semantic Code Search That Actually Works&lt;/a&gt;: what the indexer and symbol graph in these stories actually do, from first principles.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/mcp-tool-adoption-agents/" rel="noopener noreferrer"&gt;Why AI Agents Ignore Your MCP Tools&lt;/a&gt;: the adoption side of the same tool surface: why a working tool still goes uncalled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://swapnanilsaha.com/blog/dogfooding-bugs-ai-memory-daemon/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>devtools</category>
      <category>debugging</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Agent Memory Needs a Trust Ladder: Provenance, Revocation, and Notes That Lie</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Fri, 07 Aug 2026 14:00:19 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/agent-memory-needs-a-trust-ladder-provenance-revocation-and-notes-that-lie-3ig1</link>
      <guid>https://dev.to/swapnanilsaha/agent-memory-needs-a-trust-ladder-provenance-revocation-and-notes-that-lie-3ig1</guid>
      <description>&lt;p&gt;Somewhere in a project I work on, an agent once recorded that a particular lock was released when a function returned. It was not. The lock was released when the surrounding scope exited, which in that code path was several frames later. The note was wrong the second it was written, and it was written in the crisp declarative voice that every good note is written in, so it read like fact for weeks.&lt;/p&gt;

&lt;p&gt;Two words are about to do a lot of work, so let me pin them down. A &lt;strong&gt;session&lt;/strong&gt; is one continuous run of an AI coding agent: you open it, you work, it ends, and everything it figured out evaporates unless something wrote it down. &lt;strong&gt;Memory&lt;/strong&gt; is whatever writes it down, so tomorrow's session starts where yesterday's finished rather than at zero.&lt;/p&gt;

&lt;p&gt;Failure in that second thing is what nobody builds for. We have spent two years getting good at storing and fetching: embed the note, rank it by similarity, inject it at the right moment. All of that assumes the note is true. But a note is a claim, made by a fallible process, at a particular moment, about a world that keeps moving, and a memory system whose entire interface is &lt;em&gt;save&lt;/em&gt; and &lt;em&gt;search&lt;/em&gt; has quietly declared every claim equally true, forever.&lt;/p&gt;

&lt;p&gt;This post is about the third verb. Not how to store a note or how to find it, but how to say &lt;em&gt;how much this note is worth&lt;/em&gt; when it comes back. I will walk through the trust model I shipped in &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, my working-memory and code-search layer for AI coding agents: provenance classes, promotion, revocation that leaves a body behind, and staleness that flags without accusing. Then I will spend the last third of the post on the parts I got wrong or have not solved, because those are more useful to you than the parts that work.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 1: The Problem With Two Verbs
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. Save and Search Is Not a Memory System
&lt;/h2&gt;

&lt;p&gt;Take the minimal agent memory store. It has two operations. &lt;code&gt;save(text)&lt;/code&gt; writes a string somewhere durable. &lt;code&gt;search(query)&lt;/code&gt; returns the strings most similar to the query. Almost every memory layer shipping today is this, plus engineering: better chunking, better ranking, a graph instead of a flat list, a summarizer that compacts old entries.&lt;/p&gt;

&lt;p&gt;Now watch what happens over six months of real use. The store accumulates a few hundred notes. Some are excellent. Some were written by a model that misread a stack trace. Some were true in March and false by June because someone refactored the module. A handful are the user's own words, typed in frustration, and worth more than everything else in the store combined.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;search&lt;/code&gt; returns five of them, all five arrive as the same kind of object: a paragraph of confident text. The reading agent has no way to tell the user's standing instruction from a machine's half-formed guess about a stack trace. It will read them in ranked order and treat them as equally authoritative, because nothing in the payload says otherwise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The core asymmetry.&lt;/strong&gt; Retrieval quality and trust quality are independent. A perfect retriever that surfaces exactly the right note still hands the agent a lie if that note was wrong when written. Improving similarity ranking does nothing for this failure. It just delivers the wrong belief faster and more reliably.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The standard answer in the field is conflict resolution at write time: when a new fact contradicts an old one, the newer wins. Mem0 runs a model-driven pass at ingest that decides whether an incoming fact adds, updates, or deletes an existing one. Temporal knowledge graphs like Graphiti do a more structured version with validity windows on edges. Recency is a decent heuristic and I use a form of it, but look at what it cannot reach. It says nothing about a note that was wrong at birth, because nothing later contradicts it. It says nothing about whether a note is the user's own directive or a model's paraphrase, because both are just text. And when the newer fact is the wrong one, recency actively hurts: the store overwrites a correct belief with a fresh mistake and reports no conflict, because from its point of view nothing went wrong.&lt;/p&gt;

&lt;p&gt;There is a sharper version of that last problem. The arbitration itself is usually done by a model, which means the decision to overwrite is produced by the same kind of process that produces wrong notes in the first place. You have not removed the failure. You have moved it one layer down, where it is harder to see, because now the loss is silent: the old note is gone and nothing records that a judgment was made.&lt;/p&gt;

&lt;p&gt;What is missing is not a better arbiter of which claim wins. It is a record of &lt;strong&gt;provenance&lt;/strong&gt; (the recorded origin and chain of custody of a piece of data) and lifecycle attached to the claim itself, so the agent reading it can weigh it instead of assuming it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Three Ways a Stored Note Lies
&lt;/h2&gt;

&lt;p&gt;Once I started auditing notes rather than just counting them, the failures sorted themselves into three groups. They need different machinery, which is why lumping them together as "hallucination" or "drift" gets you nowhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Wrong when written
&lt;/h3&gt;

&lt;p&gt;The agent misunderstood the code and stored the misunderstanding. This is the lock example from the intro. There is no contradiction to detect, no timestamp that helps, and no later event that flags it. The note is internally consistent, well-formatted, and false. The only thing that catches it is a later session doing the work again and noticing the mismatch, and by then the note has been read a dozen times.&lt;/p&gt;

&lt;p&gt;What makes this one nasty is that the note's confidence is a property of the writing style, not the evidence. Models write notes in the same register whether they traced the call graph carefully or skimmed one file. There is no linguistic signal to key on. Any system that tries to infer confidence from the note's wording is reading tea leaves.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Right then stale
&lt;/h3&gt;

&lt;p&gt;The note was accurate. Then someone moved the function, changed the default, renamed the config key, and the note kept its confident present tense. This is the failure everyone already knows about, and it is the easiest of the three to detect, because the world leaves fingerprints: file hashes change, symbols move, mtimes advance.&lt;/p&gt;

&lt;p&gt;It is also the one most commonly over-corrected. A changed file does not mean the note is wrong. Over a busy month I watched one file take dozens of commits without invalidating a single note attached to it, because every one of those commits landed in a function the notes never mentioned. If your staleness detector turns "this file changed" into "this fact is now false," you have swapped one lie for another.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Misattributed authority
&lt;/h3&gt;

&lt;p&gt;This is the one nobody designs for, and the reason I wrote this post. A note says: &lt;em&gt;always run the test suite with the virtual environment's Python, never the global one.&lt;/em&gt; Who said that? If the user said it, it is a standing rule and the agent should follow it without deliberation. If a model inferred it from one flaky test run, it is a plausible guess that deserves a check. Same words. Completely different weight.&lt;/p&gt;

&lt;p&gt;The failure runs in both directions, which is what makes it hard. An agent's paraphrase can end up wearing the user's voice, in which case the system over-trusts a guess. Or a genuine user instruction gets stored through an agent-mediated path and comes back hedged, in which case the system under-trusts the one thing in the store that was never in doubt. I come back to both directions in section 8, because I have not fixed them.&lt;/p&gt;

&lt;p&gt;Laid out side by side, the reason these need separate machinery is that they leave completely different amounts of evidence behind:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure&lt;/th&gt;
&lt;th&gt;Evidence available to the system&lt;/th&gt;
&lt;th&gt;Usual answer&lt;/th&gt;
&lt;th&gt;What it actually needs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong when written&lt;/td&gt;
&lt;td&gt;None. Nothing about the note or the world indicates it.&lt;/td&gt;
&lt;td&gt;Nothing, or a self-reported confidence score.&lt;/td&gt;
&lt;td&gt;A later reader to catch it, and a way to record the catch so it sticks.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right then stale&lt;/td&gt;
&lt;td&gt;Plenty. Hashes, mtimes, moved symbols.&lt;/td&gt;
&lt;td&gt;Recency ordering, or expiry after a fixed age.&lt;/td&gt;
&lt;td&gt;A screening signal that says verify, not one that says false.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Misattributed authority&lt;/td&gt;
&lt;td&gt;Only at write time, and only if you capture it then.&lt;/td&gt;
&lt;td&gt;Nothing. Every note is the same kind of text.&lt;/td&gt;
&lt;td&gt;A provenance class stamped at write and rendered at read.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle row is the one the field has mostly solved. The other two are open, and the third is not even widely recognised as a problem.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The lab notebook analogy.&lt;/strong&gt; A research lab does not keep one undifferentiated pile of statements. It keeps a notebook where every entry is dated and initialed, results get countersigned by someone who reproduced them, and a retracted result stays in the notebook with a line through it and a note about why. Nobody tears the page out. The struck-through entry is doing work: it stops the next person from running the same doomed experiment.&lt;/p&gt;

&lt;p&gt;That is the whole design. Dated, initialed, countersignable, struck through rather than erased. The rest of this post is what those four things look like when the reader is a model rather than a graduate student.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a confidence score does not work here.&lt;/strong&gt; The obvious move is to have the writing agent attach a confidence number. It fails for the same reason the writing style fails: the number is produced by the same process that produced the possibly wrong note, and models are poorly calibrated about their own reasoning. A self-reported 0.9 on a misread stack trace is worse than no number, because it launders a guess into a measurement. Provenance is different: it records a structural fact about how the note came to exist, which the system knows independently of what the agent believes.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Part 2: What I Shipped
&lt;/h1&gt;

&lt;h2&gt;
  
  
  3. The Trust Ladder: Three Provenance Classes
&lt;/h2&gt;

&lt;p&gt;Every note carries one value from a three-element ordered vocabulary. Not a score, not a float, not anything a model computes. A closed enum (a field that accepts only values from a fixed, predeclared list), checked at write time, answering one question: how much reviewing judgment stands behind this?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;human&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A person recorded or endorsed this. The only class that ever renders as an unhedged instruction. Not settable at write time by any agent-facing call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;agent&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;An AI session deliberately recorded this after doing some work. The default. Real judgment was applied, by a fallible judge.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;auto&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Captured by a mechanism with no reviewing judgment at all. A git hook that notices a commit, for example. Nobody looked at it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ordering is &lt;code&gt;auto&lt;/code&gt; below &lt;code&gt;agent&lt;/code&gt; below &lt;code&gt;human&lt;/code&gt;, and the ladder is not decorative. It decides three things: how the note is framed when it comes back, whether it is allowed to be a standing rule at all, and how far it can be raised later.&lt;/p&gt;

&lt;h3&gt;
  
  
  The framing is the interface
&lt;/h3&gt;

&lt;p&gt;Provenance would be useless as a database column alone. The agent reading a recalled note never sees your schema. It sees a block of text in its context window, so the class has to be spent on the wording of that text. Each class maps to a fixed prefix, prepended to the note's content on the recall path. These are the literal strings, copied out of the shipped code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;human + directive  →  "DIRECTIVE (standing rule from the user — follow it): "
human + any kind   →  "Recorded by the user: "
agent  (default)   →  "Memory to verify (recorded by an AI session, not human-endorsed): "
auto               →  "Auto-captured (weakest confidence, no reviewing judgment
                        applied — verify before relying on this): "
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the top of the ladder gets an imperative. Everything else is framed as something to check. That asymmetry is deliberate and it is the entire point: the default posture toward a stored note is skepticism, and confidence is something a note has to earn its way up to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Immutable at write, and a pure function.&lt;/strong&gt; The prefix is computed from the note's stored &lt;code&gt;(provenance, kind)&lt;/code&gt; pair and nothing else. Not from the query, not from how the note is phrased, not from a model's read of it. That means the framing is reproducible: the same note renders identically on every recall, on every machine, forever, until someone explicitly promotes it. A trust signal that varies by context is not a trust signal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The write-time enum is where the security lives
&lt;/h3&gt;

&lt;p&gt;One detail turns the ladder from labelling into something with teeth. The provenance parameter exposed on the agent-facing tool accepts exactly two values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"provenance"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How much to trust this note when it resurfaces (default
    'agent'): 'agent' = self-recorded, framed as memory to verify; 'auto' =
    no reviewing judgment, weakest framing, incompatible with
    kind='directive'. 'human' is only reached via explicit promotion,
    not settable here."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auto"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent can declare itself weaker than the default. It cannot declare itself stronger. &lt;code&gt;human&lt;/code&gt; is not in the enum, so the strongest class on the ladder is unreachable from inside the agent's own control loop, by construction rather than by policy. There is no prompt that talks an agent into marking its guess as human-endorsed, because the field will not accept the value.&lt;/p&gt;

&lt;p&gt;The same principle produces one more restriction. A note with &lt;code&gt;kind="directive"&lt;/code&gt;, meaning a standing rule that gets injected at the start of every future session, is rejected at write time if its provenance is &lt;code&gt;auto&lt;/code&gt;. Nothing looked at it, and now it would sit in front of every session forever. The store raises rather than storing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: provenance='auto' is not allowed on kind='directive' — an
unreviewed standing rule is a contradiction in terms; use
provenance='agent' (or have a human endorse it) instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That rejection is a backstop, and in practice a second constraint does most of the work. The one mechanism that actually writes auto notes in my setup is a git hook that captures what a commit touched. It writes them at the lowest priority under the kind reserved for ordinary learnings, and that kind has no unsolicited delivery path at all. An auto note therefore cannot reach a session unless the session went looking for it. Two independent reasons an unreviewed capture never crowds out a rule you set: one enforced at the schema, one falling out of how the writer was configured. I would not want to rely on either alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What provenance is not.&lt;/strong&gt; It is a caller-declared field, not a verified identity claim. Nothing cryptographically proves that the process which wrote &lt;code&gt;provenance="agent"&lt;/code&gt; was an agent. What the design buys is narrower and still worth having: an honest caller cannot accidentally overstate its authority, and the one class that carries real weight requires an action on a surface a person operates. If your threat model includes a hostile writer with direct store access, you need signed writes, and that is a different post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Interactive version:&lt;/strong&gt; the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-trust-ladder/#demo-framing" rel="noopener noreferrer"&gt;post on my site&lt;/a&gt; has a demo where you change the class and the kind and watch the identical sentence arrive with a completely different weight, including the combination that is refused outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Promotion: Trust Is Earned, Never Asserted
&lt;/h2&gt;

&lt;p&gt;A ladder you can only be placed on is not much use. Notes get reviewed. An auto-captured note about a commit turns out to be exactly the context a later session needed, and a session verifies it against the code. That review is real information and the store should keep it.&lt;/p&gt;

&lt;p&gt;So there is a promotion operation, with three rules that matter more than the operation itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One step at a time.&lt;/strong&gt; &lt;code&gt;auto&lt;/code&gt; to &lt;code&gt;agent&lt;/code&gt;, or &lt;code&gt;agent&lt;/code&gt; to &lt;code&gt;human&lt;/code&gt;. Never &lt;code&gt;auto&lt;/code&gt; straight to &lt;code&gt;human&lt;/code&gt;. The implementation computes the note's current rank and rejects any target that is not exactly one above it. Skipping a rung would mean a single review had substituted for two independent ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No demotion.&lt;/strong&gt; There is no operation that lowers a note's class. If a note turns out to be wrong, that is not a trust-level problem, it is a revocation, which is the next section. Demotion would let a bad actor or a confused session quietly strip authority from a rule the user set, and the recovery path for that is worse than the problem it solves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The top rung is not the agent's to hand out.&lt;/strong&gt; The promotion tool exposed to agents has its target parameter constrained to a single value: &lt;code&gt;agent&lt;/code&gt;. Its own description says so plainly, in the text the model reads when deciding whether to call it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raise an auto-captured note's trust class to 'agent' — e.g. after this
session has reviewed an auto-captured note and confirmed it still holds.
This tool only takes that one step (auto -&amp;gt; agent); it never promotes a
note to 'human', because deciding that a person has endorsed something is
not the agent's call to make. Human endorsement happens on a user-side
surface instead (a CLI/UI a person operates), not through this tool.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The underlying store function does support the &lt;code&gt;agent&lt;/code&gt; to &lt;code&gt;human&lt;/code&gt; step. It has to, or human endorsement would be impossible. What differs is which surface can reach it. The tool layer an agent talks to caps out one rung early, and the final step happens somewhere a person is actually present. Two layers, two different ceilings, one boundary that a prompt cannot argue its way across.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The generalizable rule.&lt;/strong&gt; Any trust system where the subject can self-certify is not a trust system, it is a formality. If your memory store lets the agent write its own authority level, delete the field. It is costing you schema space and giving you nothing. The value comes entirely from the class that requires an act the agent cannot perform.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An honest note on how much this gets used: promotion is the least-exercised operation in the system. Reviewing old auto-captured notes is nobody's idea of a good time, and mostly it happens as a side effect, when a session pulls up an auto note for some other reason, confirms it against the code, and promotes it on the way past. I would not build the promotion path first. I would build the framing first, notice that some notes deserve better, and add promotion when that starts to annoy you.&lt;/p&gt;

&lt;p&gt;Both promotions and the lifecycle events below are appended to a per-note event log, with an actor recorded on each. A promotion to &lt;code&gt;human&lt;/code&gt; logs actor &lt;code&gt;human&lt;/code&gt;, and a promotion to &lt;code&gt;agent&lt;/code&gt; logs actor &lt;code&gt;agent&lt;/code&gt;. The current class is a fold over that log rather than a value someone overwrote, which means a note's trust history is auditable after the fact: you can see that it started as an auto-capture, was reviewed in July, and endorsed in August.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Revocation Is Not Deletion
&lt;/h2&gt;

&lt;p&gt;This is the design decision I argue about most, and the one I am most confident in.&lt;/p&gt;

&lt;p&gt;When you discover that a stored note is wrong, the intuitive move is to delete it. Bad data, remove it, done. I did that first. Three weeks later the same wrong belief was back in the store, written by a different session, in almost identical words.&lt;/p&gt;

&lt;p&gt;Obvious in hindsight. The note was wrong because a reasonable agent, reading that code, drew a reasonable and incorrect conclusion. Deleting the note removes the conclusion but not the code that invites it. The next session walks the same path, makes the same inference, stores the same mistake. Deletion is not a fix, it is a reset button on a loop.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why the tombstone is the vaccine.&lt;/strong&gt; A deleted wrong note leaves the store in exactly the state it was in before the mistake was ever made, which is the state in which the mistake gets made. A revoked note leaves it in a strictly better state: the trap is still there, and now there is a sign in front of it. The sign is worth more than the empty space.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So revocation appends an event rather than removing a row. The note stays a live candidate for recall and for injection. What changes is what gets rendered in its place. Instead of the note's content, every surface substitutes a fixed template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Previously believed (recorded {created_date}, revoked {revoked_date},
reason: {reason}): "{summary}". Do not re-derive this from other sources
without verification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the last sentence again, because it is the load-bearing one. The template does not merely announce that something was wrong. It gives an instruction aimed at the specific failure: you are about to reason your way back to this belief from the same evidence, and you should not, without checking. The tombstone is addressed to the future session that is about to repeat the mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  The retrieval property that makes it work
&lt;/h3&gt;

&lt;p&gt;Appending an event leaves the note's embedding untouched, and that embedding was computed from the original wrong content when the note was written. It sounds like an implementation shortcut. It is the most useful consequence of the whole design.&lt;/p&gt;

&lt;p&gt;Because the vector still points at the wrong belief, the tombstone comes back for exactly the query that would have surfaced the mistake. A session working on locking, asking about lock scope, gets the struck-through page. A session working on billing never sees it and pays nothing for its existence. The deterrent rides the same similarity machinery that delivered the error, which means it is aimed at the moment of maximum risk without anyone having to aim it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The correction and the revocation are one write
&lt;/h3&gt;

&lt;p&gt;When a session discovers the truth, it usually wants to record the right fact &lt;em&gt;and&lt;/em&gt; mark the old one wrong. Doing that as two calls means there is a window where the store contains both the wrong note and the right one, un-linked, and if the second call fails you are left with a contradiction and no arbiter. So the write call takes a &lt;code&gt;contradicts&lt;/code&gt; parameter: record this new note, and in the same transaction, append a revoked event to the note it corrects, with the reason pointing at the new note's id. One write, no window, and the tombstone names its replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Revocation does not set a validity window
&lt;/h3&gt;

&lt;p&gt;There is a separate mechanism for supersession, where a note is closed out with a &lt;code&gt;valid_until&lt;/code&gt; timestamp because a newer note replaced it. Revocation deliberately does not touch that field. A superseded note is old news and can fall out of the working set. A revoked note is &lt;em&gt;wrong&lt;/em&gt;, and it needs to stay in front of readers precisely because its content is attractive. Those are different lifecycle facts and conflating them would make the wrong note quietly disappear, which is the behavior I was trying to eliminate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Revocations are themselves sometimes wrong
&lt;/h3&gt;

&lt;p&gt;A session decides an old note is wrong, revokes it, and is itself mistaken. This happens. It happened to me while I was building the feature, which is how it got built. So reinstatement exists, and it is always legal: append a reinstated event, the fold takes the latest transition, and the note's original content comes back. No special case, no permission check about who revoked it first, and no limit on how many times a note can flip.&lt;/p&gt;

&lt;p&gt;That last point sounds sloppy and is not. The alternative is arbitration logic that decides which of two disagreeing sessions was right, and no such logic can be correct, because the store has no independent access to the truth. Appending both transitions and letting the fold report the latest keeps the full history queryable and keeps the code honest about what it does not know.&lt;/p&gt;

&lt;p&gt;One guard rail sits under all of this: the actor on a revocation can never be the system. A staleness flag is machine-derived and gets recorded as such, but calling a note wrong is a judgment, and the store refuses to accept a revocation that claims otherwise. Nothing in the pipeline revokes anything on its own.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deletion still exists, and it is for a different job.&lt;/strong&gt; There is still a plain delete. Its job is notes that are &lt;em&gt;irrelevant&lt;/em&gt;, not notes that are wrong: a task note for finished work, a duplicate, something written by mistake in the wrong workspace. Confusing the two is the common error. If a future reader would benefit from knowing you once believed the thing, revoke. If knowing that would be pure noise, delete.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is what one note looks like at each point in its life. Active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[47] [HIGH] [GOTCHA] [agent] [scope=repo]  [locking, resolver]  (7w ago)
  Memory to verify (recorded by an AI session, not human-endorsed):
  lock_workspace() at resolver.rs:214 acquires a PID-scoped lock; it drops
  when the function returns.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revoked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[47] [REVOKED]  [locking, resolver]
  Previously believed (recorded 2026-06-14, revoked 2026-08-02, reason:
  lock is released on scope exit, not on return): "lock_workspace() drops
  its lock on return". Do not re-derive this from other sources without
  verification.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the event log behind it, which is what state is folded from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;created      actor=agent  2026-06-14
revoked      actor=agent  2026-08-02  reason="lock is released on scope exit, not on return"
reinstated   actor=agent  2026-08-02
revoked      actor=agent  2026-08-02  reason="contradicted by #48"
folded state: revoked      in recall results: yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Interactive version:&lt;/strong&gt; the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-trust-ladder/#demo-lifecycle" rel="noopener noreferrer"&gt;post on my site&lt;/a&gt; lets you drive this one yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Staleness: A Changed Anchor Means May, Never Is
&lt;/h2&gt;

&lt;p&gt;The second failure mode, right then stale, is the one with actual observable evidence. A note that says something about &lt;code&gt;agent/searcher.py&lt;/code&gt; can be tied to that file, and the file can be checked.&lt;/p&gt;

&lt;p&gt;The mechanism is unremarkable. A note can declare &lt;strong&gt;anchors&lt;/strong&gt;: file paths, each stored alongside a truncated SHA-256 of the file's content at write time. On recall, the current content is hashed with the same function and compared. Mismatch means drift.&lt;/p&gt;

&lt;p&gt;Three other signals feed the same flag, and they overlap deliberately. A referenced file whose modification time is later than the note's creation time. A stored hash of the specific code block the note described, if the note captured one. And explicit supersession, where a newer note has closed this one out. Any of these fires the flag.&lt;/p&gt;

&lt;p&gt;What matters is what happens next. The drifted note is not dropped, not down-ranked, and not marked false. It gets extra lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[47] [HIGH] [GOTCHA] [agent] [scope=repo]  [locking, resolver]  (23d ago) [STALE]
  Memory to verify (recorded by an AI session, not human-endorsed): lock_workspace()
  at resolver.rs:214 acquires a PID-scoped lock; it drops on scope exit.
  WARNING: These files changed after this note was written: agent/resolver.rs [anchor_changed]
  VERDICT: anchor changed since — verify: agent/resolver.rs [anchor_changed]
  WARNING: Verify this note is still accurate before relying on it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verdict line names the file and says verify. It does not say the note is wrong, because the check does not know that. This distinction is not pedantry, it is the difference between a useful signal and a boy-who-cried-wolf detector.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Anchor drift as a classifier.&lt;/strong&gt; Treat drift detection as a binary classifier for the event "this note is now false." Write &lt;em&gt;D&lt;/em&gt; for drift detected and &lt;em&gt;F&lt;/em&gt; for the note actually being false. What we have is a detector with very high recall and poor precision:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;P(D | F) ≈ 1&lt;/code&gt; and &lt;code&gt;P(F | D) ≪ 1&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The first says almost nothing becomes false without the anchored file changing, so drift catches nearly every genuine staleness case. The second says most drift is harmless: the file changed for unrelated reasons. Recall near 1.0, precision low, which is the profile of a screening test.&lt;/p&gt;

&lt;p&gt;You handle a screening test by routing positives to a confirmatory step, never by treating a positive as a diagnosis. The confirmatory step here is the agent re-reading the file, which the verdict line asks it to do. Down-ranking or hiding drifted notes would be treating a screen as a diagnosis, and would throw away correct notes at the low precision rate.&lt;/p&gt;

&lt;p&gt;The high-recall half comes with a condition attached, and that condition is where the mechanism is weakest: &lt;em&gt;the note's truth has to be determined by the content of the files it anchors to.&lt;/em&gt; A note about a function in &lt;code&gt;resolver.rs&lt;/code&gt; qualifies. A note about how two services interact at runtime does not, and neither does a note nobody anchored to anything, which is most notes in most stores. For those, &lt;code&gt;P(D | F)&lt;/code&gt; is not near 1. It is near 0, and drift detection is simply not a control that covers them. Elapsed time is the only signal left, which is why process and environment notes carry a last-confirmed date on top of the hash check.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One case gets special handling, and I think it is the most interesting part of the staleness design. Some notes describe processes rather than code: how the build works, which flags CI passes, what the environment needs. There is no single source file that &lt;em&gt;is&lt;/em&gt; that fact. What you can anchor to is a proxy: the lockfile, the CI config, the Dockerfile. That anchor stands in for the process it encodes, not for the note's content directly.&lt;/p&gt;

&lt;p&gt;So when a proxy anchor drifts, the honest reading is weaker than for a code anchor. It means the process this note describes may have changed. The rendering says exactly that, appending to the standard verdict rather than replacing it: the verify instruction stays, and a clause is added naming the proxy and the date the fact was last confirmed. Notes of this kind also carry a last-confirmed date even when nothing has drifted at all, because environment facts decay by elapsed time rather than by hash mismatch. A six-month-old claim about a CI runner is suspect whether or not the config file changed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The framing rule I ended up with.&lt;/strong&gt; Every status line in the recall output comes from deterministic machine state: a hash matched or it did not, a date is what it is. No adjectives, no model-produced confidence, no "this note seems outdated." If the system cannot compute it, the system does not claim it. That constraint killed several features I wanted and made the remaining ones trustworthy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  7. Delivery Rules: Trust Decides Where a Note Shows Up
&lt;/h2&gt;

&lt;p&gt;Provenance answers how much to trust a note. A second stored field, confusingly named &lt;code&gt;kind&lt;/code&gt;, answers something different: when should this note appear without anyone asking for it? Every note declares one, from a short list, and the choice determines whether the note waits to be searched for or shows up on its own.&lt;/p&gt;

&lt;p&gt;Unsolicited delivery is expensive. It spends context window on every session whether the note turns out to be relevant or not, and a store full of standing rules is a store whose rules get skimmed. So the right to interrupt is rationed by kind, and the kinds with the most interrupting power are the ones provenance restricts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;When it arrives&lt;/th&gt;
&lt;th&gt;Why that rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;directive&lt;/td&gt;
&lt;td&gt;Every session start, and again after compaction, at any priority&lt;/td&gt;
&lt;td&gt;A standing rule that is missed once is a rule that does not exist. This kind pays the unconditional cost, which is why an unreviewed capture is not allowed to hold it.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;task&lt;/td&gt;
&lt;td&gt;Session start, high priority only&lt;/td&gt;
&lt;td&gt;Resuming work needs the current state, but only the state that actually matters. Medium and low priority task notes stay recall-only.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gotcha&lt;/td&gt;
&lt;td&gt;When one of its anchored files is about to be edited&lt;/td&gt;
&lt;td&gt;A caveat about a file is worthless three days early and priceless three seconds before the edit. The anchor is both the staleness check and the delivery trigger.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;finding&lt;/td&gt;
&lt;td&gt;Relevance-ranked, on the prompt or on an explicit recall&lt;/td&gt;
&lt;td&gt;The default. Learnings are numerous and situational; they compete on similarity rather than arriving unconditionally.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decision&lt;/td&gt;
&lt;td&gt;On demand only, recallable in chronological order&lt;/td&gt;
&lt;td&gt;An architectural decision is meant to be read as a sequence with its neighbours, not pushed at a session that did not ask.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The trap everyone hits in week one.&lt;/strong&gt; A gotcha is delivered by its anchors. Write one with no anchor and it gets an empty delivery bundle: no path to fire on, so it never fires, and it looks from the outside exactly like a broken feature. It is not broken, it is a caveat about nothing in particular. The store could reject anchorless gotchas outright and probably should, but they are still perfectly good recall targets, so it accepts them and they sit there quietly. If you build this, put the warning at write time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two more constraints keep the pushed bundle from becoming its own problem. It is budgeted, so a store with two hundred notes does not hand a session two hundred notes, and a note that does not fit is dropped whole rather than truncated mid-sentence into something that reads like a different claim. It is deduplicated by note id within a turn, so a note that qualifies through two triggers at once arrives once.&lt;/p&gt;

&lt;p&gt;And the wrapper text around the whole bundle scales to the weakest note inside it. If everything in a batch is human-endorsed, the envelope says so. Slip one auto-captured note into the same batch and the envelope drops to the weaker wording, because the reading agent is about to consume the batch as one blob and calibrating it to the strongest member would be a lie about the rest.&lt;/p&gt;

&lt;p&gt;None of this is free, and the bill lands in the one place you cannot expand. The auto frame runs about a hundred characters, call it twenty-five tokens, prepended to every note that carries it. A twenty-note recall pays that twenty times, so you can spend five hundred tokens of a session's context on framing alone before a single fact arrives. That is not nothing, and it is the reason the frames are terse and fixed rather than generated per note. If your bundles run large, the honest move is to frame the envelope once and mark individual notes with a short tag, which trades per-note clarity for headroom. I have not needed to. Ask me again when someone runs this on a store with ten thousand notes.&lt;/p&gt;

&lt;p&gt;The pushed path also uses a different framing template from the one in section 3. When a session explicitly asks for a note, it has already decided to weigh it, so the provenance-hedged wording applies. When the system pushes a note at a session that did not ask, the framing states structural facts instead: the date it was recorded, the anchor it is tied to, and the anchor's current status.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Recorded {date} (anchor: {target}, status: {status}): {content}

status ∈ { "matches current state",
           "changed since — verify",
           "last confirmed {date}" }        # process and environment facts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two framings for two situations. A pulled note is answering a question the agent asked and the useful signal is who stands behind it. A pushed note is interrupting, and the useful signal is whether the world it describes still looks the way it did. Same trust model, different projection of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactive version:&lt;/strong&gt; the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-trust-ladder/#demo-delivery" rel="noopener noreferrer"&gt;post on my site&lt;/a&gt; has a demo where you pick a moment in a session and see which of five stored notes arrive unasked, and why.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 3: What I Have Not Solved
&lt;/h1&gt;

&lt;h2&gt;
  
  
  8. The Missing Middle Class
&lt;/h2&gt;

&lt;p&gt;Three classes are not enough. I know where the fourth goes and I have not built it.&lt;/p&gt;

&lt;p&gt;Picture a normal Tuesday. You type, in your own words, in a chat turn: never run the benchmark suite against the global Python install, always use the project virtual environment. The agent does exactly what it should and records that as a note, so the rule survives into future sessions. The note goes in through the agent-facing memory call, which means it gets &lt;code&gt;provenance="agent"&lt;/code&gt;, because that is what the enum allows.&lt;/p&gt;

&lt;p&gt;Now look at what a future session receives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Memory to verify (recorded by an AI session, not human-endorsed):
never run the benchmark suite against the global Python install,
always use the project virtual environment.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every word of that framing is technically accurate. An AI session did record it, and no human endorsement step took place. And it materially understates the authority of the content, which is a direct transcription of something you said. The framing tells the reading agent to verify a rule that was never in question.&lt;/p&gt;

&lt;p&gt;Now the mirror failure, which is worse. An agent half-remembers a conversation from ninety turns ago, decides the user probably wanted commits squashed before merge, and records it as a finding. Same class, same framing, no banner distinguishing it from the transcription case. In the first case the framing under-sells the truth. In the second it over-sells a fabrication. The class is doing the same thing in both, which means it is doing nothing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The shape of the gap.&lt;/strong&gt; The ladder currently measures &lt;em&gt;who performed the write&lt;/em&gt;. What it needs to measure is &lt;em&gt;whose claim this is&lt;/em&gt;. Those come apart exactly when an agent transcribes a user, which is one of the most common and most important writes a memory system ever handles.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The obvious fix is a fourth class between &lt;code&gt;agent&lt;/code&gt; and &lt;code&gt;human&lt;/code&gt;: user-stated, agent-transcribed. Something like "the user said this; an AI session wrote it down." That gives the reading agent the right posture, which is: treat this as authoritative, and if it looks wrong, ask rather than silently discount it.&lt;/p&gt;

&lt;p&gt;A new class alone fixes only half of the problem, though, and the half matters. It repairs the transcription case, where a true user rule was being hedged. It does nothing for the mirror case, because an agent that misremembers a conversation would reach for the new class just as readily as it reached for &lt;code&gt;agent&lt;/code&gt;, and would then be believed. The class is only worth adding if something other than the agent's own say-so decides who goes in it. Working out what that something is turned out to be harder than the class, which is the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Mixed Provenance, and the Attestation You Cannot Delegate
&lt;/h2&gt;

&lt;p&gt;Real notes are not pure. This one is from my own store, lightly edited:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User: never add query-side keyword heuristics to the ranker. This is the
third time this rule has come up. It applies to the searcher and to the
reranker, and the reason is that keyword branches cannot generalize across
languages; the two index-time priors handle the cases the heuristics were
covering.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first sentence is the user's rule, close to verbatim. The second is a fact about the conversation. The rest is the agent's own synthesis: where the rule applies, why it exists, what replaced the deleted code. Some of that synthesis is correct. Some of it is the agent's interpretation, and interpretation is exactly where the wrong-when-written failure lives.&lt;/p&gt;

&lt;p&gt;One class per note cannot represent this. Stamp it &lt;code&gt;agent&lt;/code&gt; and the user's rule gets hedged. Stamp it at a user-stated class and the agent's interpretation inherits authority it did not earn. Both choices are wrong, and splitting the note into two is not a real answer either: the rule and the reason belong together, and a memory system that forces users to write in provenance-pure fragments will not be used.&lt;/p&gt;

&lt;h3&gt;
  
  
  The candidate design
&lt;/h3&gt;

&lt;p&gt;What I keep coming back to is evidence rather than assertion. The harness hook that fires when you submit a prompt already sees your raw text before the model does. That text is the ground truth, and it exists outside the agent's reasoning. So: bind the verbatim excerpt to the note as an evidence field, and at render time check each span of the note against it. Spans that match the recorded user turn render as user-stated. Everything else renders at the agent class.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;User: never add query-side keyword heuristics to the ranker.
                 It applies to the searcher and to the reranker, and the
                 reason is that keyword branches cannot generalize.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;evidence&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;turn_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4417&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="na"&gt;excerpt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;never add query-side keyword heuristics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nl"&gt;render&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;stated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="nx"&gt;never&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;side&lt;/span&gt; &lt;span class="nx"&gt;keyword&lt;/span&gt; &lt;span class="nx"&gt;heuristics&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;        &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;ranker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;It&lt;/span&gt; &lt;span class="nx"&gt;applies&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;searcher&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt;
                 &lt;span class="nx"&gt;reranker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="nx"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;that&lt;/span&gt; &lt;span class="nx"&gt;keyword&lt;/span&gt; &lt;span class="nx"&gt;branches&lt;/span&gt;
                 &lt;span class="nx"&gt;cannot&lt;/span&gt; &lt;span class="nx"&gt;generalize&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The property that makes this worth building is that the check is machine-verifiable. The system is not asking the agent whether the user said something, it is comparing a stored string against a recorded turn. An agent can still choose a misleading excerpt or bound it badly, but it cannot manufacture text the user never typed.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The attestation you cannot delegate.&lt;/strong&gt; The tempting shortcut is a boolean on the write call: &lt;code&gt;user_stated=true&lt;/code&gt;. That is the agent certifying its own authority in a different costume, and it fails the same way. "The user said this" is exactly the claim that must be checkable against something outside the agent's control loop, because it is the claim with the most to gain from being wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The unsolved parts, honestly stated. Span boundaries are fuzzy: paraphrase, translation, and the user's own typos all break exact matching, and fuzzy matching reintroduces a judgment call. Rendering gets noisy fast if every note arrives striped with class markers. Storing verbatim user turns alongside notes is a privacy surface that needs its own retention rules. And there is a real question of whether the payoff justifies it, which is the subject of the last section, because I have not measured whether any of this framing changes behavior at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. The Banner Is Unmeasured
&lt;/h2&gt;

&lt;p&gt;Everything above rests on an assumption I have never tested: that the framing changes what the reading agent does.&lt;/p&gt;

&lt;p&gt;It is a plausible assumption. Prefixes shift model behavior in general, and this one is unusually direct. It is also exactly the kind of plausible assumption that turns out to be false, and I have not run the experiment. Neither, as far as I can find, has anyone else. The academic work on memory provenance mostly evaluates whether the right memory was retrieved, not whether the trust framing on it changed the downstream action.&lt;/p&gt;

&lt;p&gt;The failure mode I worry about is not that the banner is ignored. It is that the banner works too well in the wrong direction: an agent that reads "memory to verify" on a correct standing rule and, being agreeable, verifies instead of complying. Now every correct rule costs an extra tool call and a chance of the agent deciding the rule does not apply. That is a real regression, paid on every note, in exchange for protection against notes that are wrong some fraction of the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The experiment that would settle it
&lt;/h3&gt;

&lt;p&gt;Two arms, banner on and banner off, over two scenario families.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Family A, the correctly recorded directive.&lt;/strong&gt; Put a true standing rule in the store, one whose violation is unambiguous and detectable in the transcript. Run the task. Measure the compliance rate in each arm. The difference is the cost of hedging a true rule. Call it &lt;em&gt;L&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Family B, the deliberately misrecorded directive.&lt;/strong&gt; Put a note in the store that is confidently wrong in a way the agent could catch by checking the code. Run a task where acting on the note causes visible damage. Measure how often the agent verifies before acting. The difference between arms is the protection the banner buys. Call it &lt;em&gt;G&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What the banner is worth.&lt;/strong&gt; Pick a unit you actually care about. Minutes of engineer time is the one I use, because both sides of this trade end up costing somebody minutes. Let &lt;em&gt;p&lt;/em&gt; be the fraction of recalled notes that are actually wrong, &lt;em&gt;G&lt;/em&gt; the minutes saved each time the banner makes the agent catch a wrong note before acting on it, and &lt;em&gt;L&lt;/em&gt; the minutes burned each time the banner makes the agent second-guess a note that was fine. The expected value per recalled note is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;V = p · G − (1 − p) · L&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The banner pays for itself when &lt;code&gt;V &amp;gt; 0&lt;/code&gt;, which is when the wrong-note rate exceeds a break-even threshold:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;p* = L / (G + L)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Only the ratio between &lt;em&gt;G&lt;/em&gt; and &lt;em&gt;L&lt;/em&gt; matters for the threshold, so the choice of unit cancels out and you are left arguing about one number: how many times more expensive is acting on a wrong note than double-checking a right one? My working guess is twenty to one. Chasing a bad assumption through a debugging session costs the better part of an hour; an extra file read costs a couple of minutes. At that ratio the banner pays on any store where more than about five percent of notes are wrong, which every store I have looked at clears easily. That is a guess dressed in arithmetic, not a measurement, and if &lt;em&gt;L&lt;/em&gt; is really closer to &lt;em&gt;G&lt;/em&gt; because a hedge derails compliance outright, the whole design is upside down.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The measurement has to be per class rather than aggregate. The auto framing is much stronger language than the agent framing, and pooling them would average away the effect you are trying to see. The other thing that would quietly ruin the experiment is making the family B notes too obviously wrong. A note claiming the sky is green tests nothing, because any competent model catches it banner or no banner. The note has to be wrong the way the lock note was wrong: plausible, specific, and only detectable if you go and look.&lt;/p&gt;

&lt;p&gt;Until that runs, the honest status of the trust ladder is that it is coherent, cheap, and unfalsified. Which is not the same as known to work, and I would rather say so than let the tidiness of the design stand in for evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Interactive version:&lt;/strong&gt; the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-trust-ladder/#demo-ev" rel="noopener noreferrer"&gt;post on my site&lt;/a&gt; has sliders for &lt;em&gt;p&lt;/em&gt;, &lt;em&gt;G&lt;/em&gt; and &lt;em&gt;L&lt;/em&gt; so you can watch the break-even point move.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. What I Would Build Next
&lt;/h2&gt;

&lt;p&gt;The lock note from the first paragraph is still in my store. It carries a tombstone now: previously believed, revoked, reason, do not re-derive this without verification. I have watched a later session hit that tombstone while reading the same code, pause, and check. That is the single most convincing thing I have seen come out of this work, and it is an anecdote, not a result.&lt;/p&gt;

&lt;p&gt;If you are building a memory layer, the cheapest useful thing you can do this week is add a provenance field with a closed enum and render it on recall. A column and a string concatenation. What costs you is everything that follows once you take it seriously, and the hardest of those is not a feature at all: it is holding the line that the system never claims more than it can compute. Every time I relaxed that, I got a nicer-sounding recall block and a worse one.&lt;/p&gt;

&lt;p&gt;Two things go on my list next. Binding verbatim user turns to notes as evidence, because the transcription case is common and currently fails in both directions at once. Then the banner experiment, because I have built a fairly elaborate structure on top of an untested premise and I would like to know whether the premise holds. If it does not, most of part 2 of this post is scaffolding around a no-op, and I would rather find that out from an experiment than from a reader.&lt;/p&gt;

&lt;p&gt;The through-line is easy to state and hard to hold to. A memory system that only stores and retrieves smuggles a claim into every response: &lt;em&gt;this is true.&lt;/em&gt; Nothing in its architecture can support that claim. The way out is not storing less, it is being exact about what each note is, who stands behind it, whether the ground under it has moved, and whether anybody has since found it wrong. On anything that runs longer than one session, that precision is what makes the memory usable rather than merely present.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-2-working-memory-compact-survival/" rel="noopener noreferrer"&gt;Building Vectr, Part 2: What /compact Destroys and How to Survive It&lt;/a&gt;. The storage layer underneath this post: how notes are written, embedded, and recalled in the first place.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/what-survives-compact-claude-code/" rel="noopener noreferrer"&gt;What Actually Survives /compact in Claude Code&lt;/a&gt;. 108 and 138 forced compactions, graded fact by fact. Why a note has to survive the boundary before its trust class matters.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/four-families-llm-context-relief-eviction/" rel="noopener noreferrer"&gt;The Four Families of Context Relief for LLM Coding Agents&lt;/a&gt;. The wider map: eviction, offload and recall, retrieval, and subagent isolation.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Sentinel Pattern: A Multi-Agent Dev Loop That Doesn't Trust Its Own Agents</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Thu, 06 Aug 2026 21:44:22 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/the-sentinel-pattern-a-multi-agent-dev-loop-that-doesnt-trust-its-own-agents-1b95</link>
      <guid>https://dev.to/swapnanilsaha/the-sentinel-pattern-a-multi-agent-dev-loop-that-doesnt-trust-its-own-agents-1b95</guid>
      <description>&lt;p&gt;A coder agent finished its lane, wrote up a tidy report, and told me it had implemented nine of the things I asked for. I wrote nine into the ledger and moved on. Later, doing something unrelated, I enumerated the artifacts myself and counted seven.&lt;/p&gt;

&lt;p&gt;Nothing dramatic followed. Nobody shipped a bug; I caught it in time. But by then the number was already in the file that the rest of the loop reads from, which meant a later task had cited it, which meant a wrong fact had acquired provenance. That is the failure I keep coming back to, because it is not a model failure and it is not fixable by better prompting. The agent produced its report from its own compressed working context, and compression drops exactly the details you would want to audit. It wasn't lying. It was summarising, and summaries round.&lt;/p&gt;

&lt;p&gt;This post is the development process I actually use to build &lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, a semantic code search and working-memory tool, from one MacBook and one subscription. One orchestrating agent that writes no product code, a rotating cast of disposable specialists, and a gate in the middle that treats every claim as hearsay until an artifact backs it. The roles come first because they are the easy part. Then the rules, which are the hard part, because every one of them was written by something going wrong.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 01: The Shape
&lt;/h1&gt;

&lt;h2&gt;
  
  
  01. The Unit of Work Is Not the Agent
&lt;/h2&gt;

&lt;p&gt;Start with the constraint, because the constraint is what makes this interesting. I have one machine, one Claude subscription, and a task list longer than my attention span. Working with a single agent, my throughput is bounded by how fast I can read what it did. That bound is real and it is low. Reading a diff carefully is slower than generating it, and it has been slower ever since the models got good.&lt;/p&gt;

&lt;p&gt;So you reach for parallelism. Five agents, five tasks, five branches. I call one of those a &lt;strong&gt;lane&lt;/strong&gt;: one agent, one scoped task, one branch, running start to finish without supervision, and the word will do a lot of work in this post. The first version of that setup works beautifully right up until you try to merge, at which point you discover the thing nobody puts in the architecture diagrams: &lt;strong&gt;parallelism multiplies output and unverified claims at exactly the same rate.&lt;/strong&gt; Five lanes produce five diffs and five reports. The diffs are real. The reports are a summary of the diffs written by the same process that wrote them, from a context that has already been compressed once or twice along the way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;diff&lt;/strong&gt;: the set of changes between two versions of the code, shown line by line. It is what the agent actually did, as opposed to what it says it did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"The model hallucinates" is the wrong diagnosis here, and it sends you off tuning prompts when the problem is structural. A subagent working a long task fills its context window, gets compacted, keeps going, gets compacted again. By the time it writes "I implemented nine", the nine is not a count. It is a recollection of a count, reconstructed from a summary of a summary. Ask a human to write a status report from memory after eight hours of work and you get the same class of error, which is why we invented commit logs.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;subagent&lt;/strong&gt;: a subordinate agent launched by another agent to do one scoped task, with its own separate conversation and context window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;context window&lt;/strong&gt;: the fixed number of tokens a model can attend to at once. Everything the agent knows in the moment lives inside it; anything that falls out is gone unless it was written down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;compaction&lt;/strong&gt;: when a long conversation is summarised to reclaim context space. The summary keeps the gist and loses exact counts, signatures, and line numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coupling you are trying to break&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Agents are cheap and verification is not. If your process scales work and verification together, you have not parallelised anything; you have moved the queue from generation to review and made the queue longer. The whole point of a gate is to make verification cost less than generation, so the two can scale at different rates.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The pattern that fell out of this has one long-lived agent and many short-lived ones. I call the long-lived one the sentinel. It writes no product code. Its entire job is to scope work, launch specialists, attack their output, and decide what gets merged. Everything else in this post is a consequence of that split, and of the fact that I got the split wrong several times before it stuck.&lt;/p&gt;

&lt;h2&gt;
  
  
  02. Three Roles, and What Each One Is Trusted For
&lt;/h2&gt;

&lt;p&gt;Three kinds of agent run in this loop. What separates them is not capability, since they are all instances of the same family of model. What separates them is what I am willing to believe from each one without checking.&lt;/p&gt;

&lt;h3&gt;
  
  
  The sentinel
&lt;/h3&gt;

&lt;p&gt;Orchestrator and gatekeeper. It writes the briefs, launches the lanes, audits the results, owns the ledger, and merges. Two rules define it, and both are load-bearing.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;it writes no product code.&lt;/strong&gt; The practical reason is context: auditing four lanes is the most context-hungry job in the system, and an orchestrator that has also been elbow-deep in a refactor has spent that context. The structural reason is that a reviewer with a diff in the merge has an interest in the merge succeeding. I do not think a language model experiences that as a conflict of interest the way a person would, but I would rather not find out at the gate.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;anything paid or irreversible runs from the sentinel.&lt;/strong&gt; Pushes, tags, releases, and evaluation runs that consume the shared quota window. Not because the specialists would be reckless with them, but because those actions have no undo and I want exactly one place where they can originate. A lane can propose a release. It cannot cut one.&lt;/p&gt;

&lt;h3&gt;
  
  
  The coders
&lt;/h3&gt;

&lt;p&gt;One well-scoped task each, one git worktree branch each, the full test suite green before reporting, and a structured report back in a shape I specify in the brief. When the lane ends, the agent ends. There is no long-lived coder that accumulates knowledge across tasks, and that is deliberate: accumulated context is unaudited state, and unaudited state is the thing this entire process exists to minimise.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;git worktree&lt;/strong&gt;: a second working directory checked out from the same repository, on its own branch, sharing one object store. Two worktrees cannot overwrite each other's files.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The obvious objection to disposable coders is that every new one has to re-learn the codebase, at full token price, from scratch. That objection is correct and the cost is real. It is the single biggest inefficiency in the whole loop, and the only thing that makes it tolerable is that findings get written down where the next lane can retrieve them instead of rediscovering them (section 11). Without that, disposable agents would be indefensible.&lt;/p&gt;

&lt;p&gt;Coders get tight briefs. Task, the files they may touch, the rails they must not cross, and the exact shape of the report. A vague brief on a mid-tier model produces a lane that solves an adjacent problem beautifully.&lt;/p&gt;

&lt;p&gt;Two details in the report shape do more for me than everything else in the brief combined. Ask for &lt;strong&gt;raw command output&lt;/strong&gt;, pasted, rather than the lane's account of what the command said, because the account is where the rounding happens. And ask for the &lt;strong&gt;commit SHA&lt;/strong&gt; the report describes. Without a pinned SHA the audit races the lane: it can push another commit while I am reading, and then I have verified a state that no longer exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;TASK    one sentence, one outcome, no "and also"
WHERE   worktree wt-&amp;lt;name&amp;gt;, branch feat-&amp;lt;name&amp;gt;
TOUCH   src/searcher/*.py, tests/test_searcher.py
RAILS   do not touch the index schema; no destructive git;
        no new dependencies; no changes outside TOUCH
DONE    full suite green in your worktree
REPORT  1. commit SHA
        2. `git diff --name-only main...HEAD`, output pasted verbatim
        3. suite summary line, pasted verbatim (passed/failed/skipped/collected)
        4. anything you tried that did not work, one line each
        5. write your key findings to shared memory before you finish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fits on a screen and takes ten minutes to write, most of it spent on the TOUCH and RAILS lines. Those two are what stop a lane from helpfully refactoring something three modules away, which is the failure mode that turns a clean merge into an afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  The adversarial reviewers
&lt;/h3&gt;

&lt;p&gt;Separate agents whose only job is to attack the work, each on an independent axis. In my loop those axes are: does retrieval actually return the right thing, does the tool get adopted by the agent it is built for or ignored, and does it beat a no-tool baseline on turns and tokens and wall time. Three different questions, three different agents, deliberately not one reviewer asked to consider all three, because a single reviewer with three mandates will find the easiest failure on the easiest axis and stop.&lt;/p&gt;

&lt;p&gt;The instruction that made reviews useful was giving them the branch and a change note rather than a diff. A reviewer handed a diff reviews the diff. A reviewer handed a working build and told to go break it will use the product, wander outside the diff, and find the thing the diff broke two modules away. That is where the real defects have come from.&lt;/p&gt;

&lt;p&gt;Two guardrails on reviewers. Rounds are capped, because an adversary with unlimited rounds will always find another nit and you will never ship. And reviewer findings are independently verified before they gate anything, because reviewers invent defects at roughly the same rate coders invent completions. A fabricated defect that blocks a merge costs a full extra loop, which is the most expensive kind of wrong.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;Writes product code&lt;/th&gt;
&lt;th&gt;Lifetime&lt;/th&gt;
&lt;th&gt;Trusted for&lt;/th&gt;
&lt;th&gt;Never trusted for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sentinel&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Long, spans the sprint&lt;/td&gt;
&lt;td&gt;Scope, merge decisions, the ledger, paid and irreversible actions&lt;/td&gt;
&lt;td&gt;Nothing it did not verify itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coder&lt;/td&gt;
&lt;td&gt;Yes, in its own worktree&lt;/td&gt;
&lt;td&gt;One task, then discarded&lt;/td&gt;
&lt;td&gt;Producing a diff and a green suite in its lane&lt;/td&gt;
&lt;td&gt;Counts, completion claims, cross-lane impact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reviewer&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;One capped review round&lt;/td&gt;
&lt;td&gt;Pointing at where to look on its axis&lt;/td&gt;
&lt;td&gt;The finding itself, until reproduced&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The kitchen, not the assembly line&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most multi-agent diagrams look like an assembly line: work moves right, each station adds something, the last station ships. A working kitchen is closer. Cooks work in parallel on separate stations, and everything passes the expediter at the pass, who cooks nothing, tastes everything, and sends plates back. The expediter is not a stage in the line; they are the reason the line can run in parallel at all, because they are the single point where "done" gets decided. When a kitchen gets slammed, the pass is where it backs up, and that is the design working rather than failing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  03. The Ledger: One Append-Only File
&lt;/h2&gt;

&lt;p&gt;Every task, every decision, every merge, and every gate verdict goes into one file, appended, never rewritten. It sounds bureaucratic for a solo project. It is the most valuable artifact in the loop.&lt;/p&gt;

&lt;p&gt;The reason is that conversations do not survive. A chat window gets compacted, and when it does, the exact number in message forty gets replaced with a paraphrase. Sessions end. Machines sleep. Agents die mid-sentence. The ledger is the only thing in the system with a memory that does not degrade, so it is the only thing allowed to be authoritative about what happened.&lt;/p&gt;

&lt;p&gt;Append-only matters more than it looks. A mutable status file lets a wrong entry be quietly corrected, which sounds like a feature until you realise that a silently corrected error teaches you nothing. When the count of nine turned out to be seven, the correction went in as a new line underneath, next to the original claim and the enumeration that settled it. That line is why the rule exists at all. A file that hid the error would have produced a small tidy edit and no rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-07-14] TASK  retrieval-rerank-blend
  brief:  blend owning-class prior into rerank; touch searcher + index only
  lane:   coder / worktree wt-rerank / branch feat-rerank-blend
  report: "8 call sites updated, suite green (412 passed)"
  AUDIT   grep -c over the branch diff: 8 call sites  [matches]
  AUDIT   suite re-run on MAIN checkout: 412 passed, 0 skipped  [matches]
  verdict: MERGE  c1f9a2e

[2026-07-14] CORRECTION to entry [2026-07-11] docs-sweep
  ledger recorded 9 updated files on the lane's report
  enumeration of the branch diff: 7 files
  root cause: report written post-compaction; count was recalled, not counted
  rule added: no merge on a lane's claim, enumerate the artifacts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things that entry does. It records the evidence, not just the verdict, so a later reader can tell whether the merge was justified or lucky. And it makes the audit a written step rather than a thing I intended to do. If the AUDIT lines are missing, the merge did not happen, because I cannot tell afterwards whether I checked.&lt;/p&gt;

&lt;p&gt;The ledger doubles as the recovery artifact. If I lose a session entirely, the ledger plus &lt;code&gt;git branch --list&lt;/code&gt; reconstructs the state of the world in about a minute: what was assigned, what merged, what is still open on a branch somewhere. That property has saved me more than once, and it is worth designing for on purpose rather than discovering afterwards.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 02: The Gate
&lt;/h1&gt;

&lt;h2&gt;
  
  
  04. Rule 1: Never Merge on a Claim, Enumerate the Artifacts
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A report is hearsay, an artifact is evidence, and the gate only accepts evidence.&lt;/strong&gt; Everything below is what that costs in practice.&lt;/p&gt;

&lt;p&gt;Every claim in a lane's report has to be convertible into something I can produce myself in under a minute. "I updated nine files" becomes a file list from the branch diff, and I count the list. "The suite passes" becomes me running the suite, on the main checkout, and reading the summary line including the skip count. "I added the regression test" becomes finding the test by name and reading its body to check that it would actually fail without the fix. If a claim cannot be turned into an enumeration, I rewrite the brief so that next time it can, because a claim that cannot be checked is a claim that will eventually be wrong in a way nobody notices.&lt;/p&gt;

&lt;p&gt;This is not paranoia about models. It is a cost asymmetry. Enumerating a claim costs a minute or two. A wrong claim in the ledger costs however long it takes to notice, plus everything that was built on top of it in the meantime, plus the time to work out which downstream conclusions are now suspect. When one side of a trade costs minutes and the other costs hours with a long tail, you do not need a strong prior about failure rates to know which way to lean.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The second-order failure: laundering&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason a wrong count is worse than it looks is that the ledger confers authority. Once nine is written down, the next lane reads nine from the ledger and repeats it in its own report, and now the error has a citation. It has stopped being one agent's summary and become a fact of the project with a paper trail. Errors laundered through a trusted record are very hard to see, precisely because everything downstream is consistent with them. The only defence is that the record itself has to be built from artifacts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One nuance worth stating, because it changes how you write briefs. The enumeration should be something the sentinel does, not something the lane does and reports. Asking a coder to "verify and confirm the count" produces a confirmation, which is another claim from the same source. Verification has to cross an agent boundary or it is not verification. This is the same reason we do not let people mark their own exams, and it is a much older idea than any of this tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Turning a claim into evidence
&lt;/h3&gt;

&lt;p&gt;The useful skill at the gate is spotting the gap between what a sentence asserts and what it actually proves. Three claim types, and the audit each one needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A count.&lt;/strong&gt; The report says: &lt;em&gt;"Updated 9 files across the docs sweep. Lane complete."&lt;/em&gt; What that does not prove: nothing about the number. The report was written after the lane had been compacted twice, so 9 is a &lt;em&gt;recollection&lt;/em&gt; of a count. The sentence is equally fluent whether the true answer is 9, 7, or 12. The enumeration: ask git, not the agent. &lt;code&gt;git diff --name-only main...feat-branch&lt;/code&gt; prints one path per line; count the lines yourself. What came back: &lt;strong&gt;7 paths.&lt;/strong&gt; Two of the nine were files the lane opened, considered, and left unchanged. Both appeared in its narration, neither appeared in the diff. The gap is two files that were &lt;em&gt;read&lt;/em&gt; rather than written, which is exactly the distinction a summary flattens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A green suite.&lt;/strong&gt; The report says: &lt;em&gt;"Full test suite passes."&lt;/em&gt; What that does not prove: which suite, on which checkout, with how many tests skipped. A run inside a dot-directory worktree can quietly skip path-walking tests and still print a green line. "Passes" hides the denominator. The enumeration: run it yourself on the &lt;strong&gt;main checkout&lt;/strong&gt; and read the summary line verbatim: passed, failed, skipped, collected. Compare the collected total to the last known-good run. Settled when the collected count matches or exceeds the previous run, skips are zero or individually explained, and the run happened where the code will actually live. The interesting number in a test report is never the failures; it is the collected total, because a suite that silently collects fewer tests than yesterday is green for the wrong reason and will stay green all the way to production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A regression test.&lt;/strong&gt; The report says: &lt;em&gt;"Added a regression test for the fix."&lt;/em&gt; What that does not prove: that the test exercises the bug. A test can be present, named correctly, pass, and assert on something adjacent to the actual defect. It then passes forever, including on the broken code it was written to catch. The enumeration: find the test by name, read its body, then invert it: revert the fix and confirm the test &lt;em&gt;fails&lt;/em&gt;. A regression test that cannot fail is documentation with a green tick. This is the claim I audit hardest, because a fake regression test does not merely fail to catch the bug once. It &lt;strong&gt;certifies&lt;/strong&gt; that the bug cannot come back, and everything downstream trusts that certificate.&lt;/p&gt;

&lt;p&gt;The count case is a real event from this project. The other two are the same procedure applied to the two claim types I audit most often; they show the method and the standard of proof rather than a recorded outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  05. What the Gate Costs: the Arithmetic of a Single Bottleneck
&lt;/h2&gt;

&lt;p&gt;Now the part that decides how many agents you should actually run, which is a question I have never seen answered with a number.&lt;/p&gt;

&lt;p&gt;Lanes run concurrently. Audits do not. There is one gate, it is me plus the sentinel, and it processes one lane's evidence at a time. That single fact is enough to derive the ceiling on the whole approach.&lt;/p&gt;

&lt;p&gt;Numbers first, algebra after. Say a lane takes 40 minutes and auditing it takes 8. Done serially, four tasks cost 4 × 48 = 192 minutes. Run the four lanes at once and they all finish at minute 40, then the four audits go back to back and the last verdict lands at minute 72. That is 2.67 times faster, not 4 times, and the gap is entirely the audit queue.&lt;/p&gt;

&lt;p&gt;Generalising: let &lt;em&gt;L&lt;/em&gt; be lane duration, &lt;em&gt;A&lt;/em&gt; be audit duration, &lt;em&gt;N&lt;/em&gt; the number of concurrent lanes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Speedup with a serialised gate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For one batch of &lt;em&gt;N&lt;/em&gt; identical lanes that start together:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T_serial   = N (L + A)
T_parallel = L + N A

S(N) = N (L + A) / (L + N A)
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;As &lt;em&gt;N&lt;/em&gt; grows the &lt;em&gt;N A&lt;/em&gt; term dominates the denominator, so the speedup converges:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lim S(N) as N → ∞  =  (L + A) / A  =  L/A + 1
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Your maximum useful fleet size is set by the ratio of lane work to audit work, and by nothing else. Lanes of 40 minutes with 8-minute audits cap at 6 times. Not 6 times if you get the tooling right: 6 times, full stop, and the tenth lane costs ten lanes of tokens to buy a rounding error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That formula describes one batch. A project is not one batch, it is a stream of tasks, and the stream version is worth deriving separately because the shape is different. In steady state the lanes deliver work at &lt;em&gt;N&lt;/em&gt;/(&lt;em&gt;L&lt;/em&gt;+&lt;em&gt;A&lt;/em&gt;) tasks per minute and the gate consumes it at 1/&lt;em&gt;A&lt;/em&gt;. Throughput is whichever is smaller, so the speedup over one-at-a-time is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The stream version, and the knee&lt;/strong&gt;&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S_stream(N) = min( N , (L + A) / A ) = min( N , L/A + 1 )
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Perfectly linear until the gate saturates, then flat forever. Same ceiling as the batch formula, but a hard corner instead of a gentle curve. The corner sits at &lt;strong&gt;N* = L/A + 1&lt;/strong&gt;, which for a 40-minute lane and an 8-minute audit is 6. Five lanes gets you 5 times. Six gets you 6. Seven gets you 6, and so does seventy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is Amdahl's law wearing a different hat, and naming it that way tells you where the leverage is. The sequential fraction of this system is the audit, so the highest-value move is not adding lanes, it is &lt;strong&gt;making &lt;em&gt;A&lt;/em&gt; smaller&lt;/strong&gt;, and every bit of that comes from designing claims to be checkable by a command rather than by reading.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Amdahl's law&lt;/strong&gt;: the speedup of a parallel system is capped by the fraction of work that must stay sequential. Here the sequential fraction is the audit.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That reframing changed how I write briefs. "Report what you changed" produces prose, and prose costs me four minutes to convert into something checkable. "Report the list of changed paths and the suite summary line verbatim" produces something I can compare against reality in thirty seconds. Same lane, same model, same work, and my ceiling roughly doubled because the audit got cheaper. Structured reporting reads like paperwork and behaves like throughput.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two things the formula leaves out
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Rework.&lt;/strong&gt; Lanes fail their audit, and a failed lane goes back out and comes through the same serial gate a second time. If a fraction &lt;em&gt;f&lt;/em&gt; of lanes need that second pass, each delivered task costs 1+&lt;em&gt;f&lt;/em&gt; trips through the gate, so throughput at saturation falls from 1/&lt;em&gt;A&lt;/em&gt; to 1/(&lt;em&gt;A&lt;/em&gt;(1+&lt;em&gt;f&lt;/em&gt;)) delivered tasks per minute. Be careful with what that does to the &lt;em&gt;ratio&lt;/em&gt;, though: a one-at-a-time baseline reworks at roughly the same rate, so &lt;em&gt;f&lt;/em&gt; largely cancels out of the speedup and the ceiling stays near &lt;em&gt;L&lt;/em&gt;/&lt;em&gt;A&lt;/em&gt; + 1. What does not cancel is absolute throughput, gate time, and the bill. At &lt;em&gt;f&lt;/em&gt; = 0.3, three lanes in ten produced nothing shippable and still consumed a full audit each. That is the strongest argument I know for spending twenty minutes on a brief, because &lt;em&gt;f&lt;/em&gt; tracks brief quality far more closely than it tracks model quality.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sentinel's context window.&lt;/strong&gt; There is a second ceiling that has nothing to do with time. Evidence for one lane costs some number of tokens, call it &lt;em&gt;E&lt;/em&gt;, and the sentinel has a finite window &lt;em&gt;W&lt;/em&gt;. Once the accumulated evidence approaches &lt;em&gt;W&lt;/em&gt;, the sentinel compacts, and a compacted gate is a gate that has started summarising the very details it exists to check. So the real bound is &lt;strong&gt;min(&lt;em&gt;L&lt;/em&gt;/&lt;em&gt;A&lt;/em&gt; + 1, &lt;em&gt;W&lt;/em&gt;/&lt;em&gt;E&lt;/em&gt;)&lt;/strong&gt;. On long audit-heavy days the second term binds first, which is why the sentinel does not write code: every token it spends on anything else is a lane it can no longer audit properly.&lt;/p&gt;

&lt;p&gt;One last effect the arithmetic hides. If all lanes start together they finish together, and then the gate has a queue while every lane sits idle waiting for a verdict. Gate utilisation hits 100% and lane utilisation collapses. Staggering starts, or deliberately mixing lane sizes, smooths the arrivals without changing the makespan of the batch. In practice I pair one long lane with two or three short ones rather than launching four identical ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where your fleet stops paying
&lt;/h3&gt;

&lt;p&gt;The live version of this on the site is a slider: set your own &lt;em&gt;L&lt;/em&gt; and &lt;em&gt;A&lt;/em&gt;, and watch the speedup bars flatten. The thing to read off them is that past a certain lane count you are spending tokens for almost no wall-clock gain, and the position of that knee is fixed by how expensive your audits are, not by how many agents your machine can run. The model assumes lanes are independent, start together, and never fail, so treat it as optimistic.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 03: Rules Written by Failures
&lt;/h1&gt;

&lt;h2&gt;
  
  
  06. Rule 2: Worktree Isolation, and the Trap Inside It
&lt;/h2&gt;

&lt;p&gt;Two agents editing the same checkout is a lost-update machine. One writes a file, the other writes the same file from a stale read, and now half of one lane's work is gone with no error anywhere. Worse, they stage each other's half-finished edits, so a commit from lane A contains three broken lines from lane B and the test failure looks like it belongs to A.&lt;/p&gt;

&lt;p&gt;The fix is standard and it works: a &lt;code&gt;git worktree&lt;/code&gt; per lane, a branch per lane. Each lane gets its own working directory and its own index while sharing one object store, so the filesystem cannot be a shared mutable variable between agents. Two rails go with it. Lanes never run destructive git operations, because the object store, the refs, and the hooks are all shared even though the checkouts are not, so a garbage collection or a force-delete in one lane reaches into every other one. And lane scopes are assigned so that two lanes do not touch the same files, since a worktree prevents corruption but does not prevent a merge conflict you scheduled yourself.&lt;/p&gt;

&lt;p&gt;Then there is the trap, which cost me an hour the first time and which I have not seen written down anywhere.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Hidden directories change tool behaviour&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My worktrees live under a dot-directory, because that is where the agent harness puts them. Plenty of tools skip hidden directories by convention: indexers, file walkers, some test and coverage discovery. So a couple of tests in my suite, the ones that walk the workspace and assert on what they find, fail inside the worktree and pass on the main checkout. Identical code. Different path visibility.&lt;/p&gt;

&lt;p&gt;The first time it happened I spent an hour debugging a defect that did not exist. The rule that came out of it: &lt;strong&gt;a lane's green suite is a smoke signal, and the gate's suite on the main checkout is the gate.&lt;/strong&gt; Every merge decision runs the suite where the code will actually live.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a related thing that catches people who set worktrees up correctly and then trust them too far: isolation ends at the merge. Lane 3's suite went green against the main branch as it stood when lane 3 started, and by the time lane 3 reaches the gate, lanes 1 and 2 have already landed. So the suite run that decides the merge has to happen &lt;em&gt;after&lt;/em&gt; the merge commit exists, not before it, on the resulting tree. I merge in the order the audits complete, and each merge is followed by its own run. Twice now that post-merge run has caught a pair of changes that were individually correct and jointly wrong, which no amount of pre-merge testing in isolated worktrees would have found.&lt;/p&gt;

&lt;p&gt;Dot-directories are only the version of this I happened to hit. Worktrees isolate files and nothing else. Ports, caches, temporary directories, on-disk databases, and background daemons stay shared, and two lanes that each start a service on the same port collide in a way that reads as flakiness rather than as a collision. So the brief either pins a lane's runtime resources to unique values or forbids it from starting them. In my case the expensive shared resource is index building, which is why heavy indexing jobs run strictly one at a time (section 10).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Isolation is per-resource, and you only have the isolation you explicitly built.&lt;/strong&gt; Everyone remembers files. Almost nobody remembers the port.&lt;/p&gt;

&lt;h2&gt;
  
  
  07. Rule 3: Tier the Models by Failure Cost
&lt;/h2&gt;

&lt;p&gt;The instinct is to put the best model everywhere. It is the wrong instinct, and the argument against it is not really about money.&lt;/p&gt;

&lt;p&gt;Different roles have different failure costs. A coder's mistake gets caught twice: by its own suite and again at the gate. A reviewer's mistake is a false negative, which is invisible and therefore expensive, or a false positive, which is caught when I try to reproduce it. The gate's mistake ships. Those are three different consequences, so they justify three different amounts of capability.&lt;/p&gt;

&lt;p&gt;My split: the sentinel runs on the strongest model available, coders on a mid tier, reviewers and validators on a strong but cheaper tier. In current terms that means Opus at the gate, Sonnet in the lanes, and a previous-generation Opus doing review.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where the cost actually lives&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Count agent-runs by role in a typical loop: &lt;em&gt;N&lt;/em&gt; coders, two or three reviewers, one sentinel. Coders also read and write the most, so they carry the largest token volume per run. Total spend is roughly:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cost ≈ N · V_coder · p_mid  +  R · V_rev · p_rev  +  1 · V_gate · p_top
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;The only term that scales with fleet size is the first one. Putting the top-tier model on that term multiplies the fastest-growing part of the bill by roughly five, in exchange for capability on the role that already has two independent checks behind it. Meanwhile the gate, which has no check behind it at all, is a single instance whose price barely moves the total. &lt;strong&gt;Buy judgment where there is one of it. Buy throughput where there are many.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The honest cost of tiering down the coders is that mid-tier models need tighter briefs. Ambiguity that a top-tier model resolves sensibly, a mid-tier model resolves plausibly and differently from what you meant. So you pay in specification what you saved in tokens. I think that is a good trade, and not only for the money: a brief is a reusable artifact that makes the next lane better, and tokens are gone the moment they are spent. Being forced to write a precise brief has improved my task decomposition more than any tool has.&lt;/p&gt;

&lt;p&gt;What I would not do is tier the reviewers down to the cheapest option available. A weak adversary produces the most dangerous output in the whole system, which is a clean review that means nothing. False confidence at the review stage does not fail loudly; it fails later, in front of a user, and by then you have a track record of green reviews telling you the process works. Reviewers stay strong.&lt;/p&gt;

&lt;h2&gt;
  
  
  08. Rule 4: Killed Agents Get Resumed, Not Restarted
&lt;/h2&gt;

&lt;p&gt;Agents die. The quota window runs out mid-lane, the API returns an error the harness cannot recover from, the machine sleeps and takes the stream with it. On a long run this is not an edge case, it is a weekly event.&lt;/p&gt;

&lt;p&gt;The thing to understand about an agent death is exactly what is lost and what is not. The worktree is on disk. The branch is on disk. Every edit the agent had already written is on disk. &lt;strong&gt;A kill loses no disk state.&lt;/strong&gt; What it loses is context: the plan the agent had formed, the four files it had read and understood, the reason it chose the approach it chose, the thing it discovered in minute twelve that changed the design.&lt;/p&gt;

&lt;p&gt;Which means the recovery move is to restore context, not to restart work. Resuming an agent from its transcript brings back the reasoning that produced the half-finished diff. Starting a fresh agent instead produces something worse than a slow restart: an agent with no memory of the edits already on disk, which will re-derive a plan, re-read the files, and quite possibly apply a change that is already there. Double-applied edits are a nasty class of bug because they often still compile.&lt;/p&gt;

&lt;p&gt;I have verified this twice on real kills, once when a quota window closed under a running lane and once on an unrecoverable API error. Both agents resumed from their transcripts and finished the task. Resume first, always.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When resume fails, change the framing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the transcript is gone or the resume does not take, do not tell a fresh agent what the dead one was doing. That produces an agent trying to reconstruct someone else's intent from a description. Point it at the diff instead: here is a branch with partial work on it, read it, and finish it. The diff is ground truth and it is already in the right format for an agent to reason about. "Complete this diff" is a well-posed task. "Continue what the last agent was doing" is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This rule also quietly explains why disposable coders work at all. Because a lane's durable output is its branch rather than its conversation, the conversation is allowed to be fragile. Design the system so the fragile part is the part you can afford to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  09. Rule 5: Quota Is a Shared Resource, So Everything Is Resumable
&lt;/h2&gt;

&lt;p&gt;Development lanes and paid evaluation runs draw from the same quota window. There is no separate budget for "the experiment" and "the work". A benchmark run that eats the window leaves nothing for the lanes, and four parallel lanes on a busy afternoon can close the window before an evaluation has started.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;quota window&lt;/strong&gt;: a subscription's usage allowance over a rolling period. Once it is spent, everything stops until the window rolls over, whether you were mid-task or not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most write-ups of agent fleets do not have this constraint, because they are running on a company card. It changes the design, and mostly for the better, because it forces three habits that turn out to be good practice regardless of who is paying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is resumable.&lt;/strong&gt; A run that cannot survive being interrupted is a run you cannot afford to start, because you cannot promise it an uninterrupted window. In practice that means checkpointing to disk after each unit of work, writing results incrementally instead of at the end, and making a re-run of an already-completed unit cheap and harmless rather than something the design forbids.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Work is sequenced, not crammed.&lt;/strong&gt; Full-factorial experiment matrices are simply infeasible: every additional axis multiplies runs, and runs are the scarce thing. So the design is tiered. Run the headline configuration first, the one whose result would change what you do next. Only if it lands do the secondary arms get a window. Half the matrices I have designed were never worth running once the headline came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explore once, reuse everywhere.&lt;/strong&gt; The most wasteful pattern in a multi-lane loop is four agents independently discovering the same three facts about the codebase, at full token price, four times. So exploration happens once, in its own lane, and its output is written down as notes that later lanes read instead of rediscovering. This is section 11's mechanism, and quota pressure is what made me take it seriously.&lt;/p&gt;

&lt;p&gt;I resented this constraint for about a month and now I would keep it even if someone handed me an unlimited budget. It killed a whole class of plans that would have looked productive for a week and answered nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Rule 6: Laptop Physics Is Part of the Architecture
&lt;/h2&gt;

&lt;p&gt;This runs on a MacBook. Not a cluster, not a CI fleet. The machine has opinions and they are enforced by the operating system, not by anything I can prompt my way out of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sleep kills long-running agent streams.&lt;/strong&gt; I did not believe this at first and assumed the stream was dying for some network reason. Then I lined up the kill timestamps against the machine's own wake log and they matched to the second. Every one. A long lane left running while the machine idles into sleep is a dead lane. The fix is &lt;code&gt;caffeinate -is&lt;/code&gt; wrapped around long runs, which holds off idle sleep for the duration. It does not cover everything: closing the lid on battery still sleeps the machine, and no flag changes that. Long runs mean lid open and power connected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;caffeinate -is&lt;/strong&gt;: a macOS command that prevents the machine from sleeping while a given process runs. The &lt;code&gt;-i&lt;/code&gt; flag blocks idle sleep, &lt;code&gt;-s&lt;/code&gt; keeps it alive on AC power.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What survives the kill is the disk state, which is exactly why rule 4 exists. These two rules are the same rule seen from different ends: the machine will take your agents, so build so that losing one costs you context and not work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heavy indexing runs strictly one at a time.&lt;/strong&gt; Building a semantic index over a large repository is the most resource-hungry thing in my stack. Two of them concurrently do not take twice as long; they thrash memory and the CPU, and everything else on the machine, including the lanes you were trying to accelerate, slows to a crawl. This one I learned by doing it and watching a fan-loud hour produce less than a quiet twenty minutes would have.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Your fleet size is bounded by the machine before it is bounded by the model&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every lane is a process, plus the tool subprocesses it spawns: test runners, compilers, language servers, indexers. Six agents is not six chat sessions, it is six build environments competing for the same cores and the same disk. Long before you hit any interesting limit of the orchestration pattern, you hit thermal throttling and swap. When I plan a fleet I count the heavy subprocesses, not the agents.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;None of this is profound. It is the kind of thing that reads as trivia until it eats an afternoon, which is precisely why I put it in the ledger and then in this post. The failure mode that costs the most is the one you attribute to the wrong layer, and "my agent framework is unreliable" is what a sleeping laptop looks like from the inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Rule 7: Notes Are the Handoff, Transcripts Are Not
&lt;/h2&gt;

&lt;p&gt;When a lane finishes, the sentinel needs to know what it learned. I spent a while reading transcripts for this, which is the intuitive move and cost me more context than any other habit I have had to break.&lt;/p&gt;

&lt;p&gt;A finished lane's transcript is long, and most of it is procedural noise: files read and discarded, an approach tried and abandoned, tool output nobody needs again. The signal is a handful of findings scattered through it. Reading all of that into the sentinel's context spends the scarcest resource in the system on mostly-noise, and the sentinel's context is scarce because auditing four lanes is exactly the job that needs room to think. Tokens spent absorbing a transcript are tokens not spent at the gate.&lt;/p&gt;

&lt;p&gt;So the protocol is: &lt;strong&gt;every subagent writes its key findings as notes to a shared working-memory store before it finishes, tagged with its own identifier, and the sentinel recalls notes instead of reading transcripts.&lt;/strong&gt; The store is a local daemon that every agent talks to over the same interface, so a note written by a coder in one worktree is immediately recallable by a reviewer in another and by the sentinel at the gate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;daemon&lt;/strong&gt;: a background process that keeps running and answers requests from other programs. Here it holds the shared note store and answers store and recall calls in milliseconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A shared scratch file gets you most of the way and then stops. The difference is retrieval: the sentinel asks a question and gets the four relevant notes, rather than paging in the whole file to find them, and that is the entire saving. Attribution matters for a smaller reason that turns out to bite, which is that a claim needs its source attached before I can weight it, and a flat file loses that as soon as two agents append. Surviving compaction and session boundaries is the part I use most in practice: a finding from Tuesday morning is still there on Thursday when it finally becomes relevant.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note is still a claim&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the part I have to keep reminding myself. Moving findings from transcripts into notes makes them cheaper to read, and does nothing whatsoever to make them true. A note saying "the parser handles empty input" is exactly as much hearsay as a report saying it, and it now looks more official because it is short, structured, and attributed. Notes make the gate faster; nothing about them makes it optional. Everything in section 04 applies to a note.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I built the tool this loop runs on, so treat me as an interested party. The mechanism itself is not exotic: any store with relevance-ranked retrieval and per-agent attribution would give you the same shape. The &lt;a href="https://swapnanilsaha.com/blog/vectr-1-1-team-mode-seven-agent-test/" rel="noopener noreferrer"&gt;seven-agent test&lt;/a&gt; in the team-mode post pushed the idea to its limit, with seven agents building a product coordinating &lt;em&gt;only&lt;/em&gt; through shared memory and no other channel. That test is also where the boundary showed up clearly: not one of the defects the reviewers found in that run was present in any of the fifty-two notes. Memory told the reviewers where to look. Running the code did the finding.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 04: The Bill
&lt;/h1&gt;

&lt;h2&gt;
  
  
  12. Honest Accounting: What This Buys, What It Costs, When Not To
&lt;/h2&gt;

&lt;p&gt;Every methodology post I distrust has the same shape: here is my process, it is great, adopt it. So here is the other side.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it buys
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Parallelism that survives the merge.&lt;/strong&gt; Four lanes converging on a clean main branch, rather than four branches and a weekend of reconciliation. That is the headline and it is real, within the ceiling from section 05.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adversarial coverage a single agent will not generate.&lt;/strong&gt; A model asked to check its own work is trying to complete the task "review this favourably-written summary of my work". A separate agent with a hostile brief and no ownership of the diff finds different things. Splitting review across independent axes finds more still, because a single reviewer with three mandates stops at the first easy failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditability.&lt;/strong&gt; Months later I can answer why a thing was merged, on what evidence, and what the reviewers said. Not from memory and not from a chat log I would have to re-read, but from a file that was written at the time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The gate is the bottleneck, by design.&lt;/strong&gt; This is not a flaw to be optimised away; it is where the trust in the system is manufactured. But it does mean the sentinel is the constraint, and on a busy day I am reading evidence rather than thinking about the product. Section 05 quantifies exactly how much that costs you and where it stops being worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Brief-writing is real work.&lt;/strong&gt; A good lane brief takes ten to twenty minutes, which on a short task is longer than doing the task. That overhead is the reason the pattern has a floor below which it makes things slower, not faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token spend scales with the fleet.&lt;/strong&gt; Four lanes is roughly four times the tokens, and some of those lanes will produce work you throw away. Tiering (section 07) blunts this. It does not remove it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordination failures are subtle.&lt;/strong&gt; Two lanes with overlapping scope, a shared runtime resource nobody isolated, a brief that was ambiguous in exactly one place. These do not announce themselves. They show up at the gate as work that has to be redone, and redone work is the most expensive failure mode in the system: it consumed a full lane, produced nothing, and it comes back through the same serial gate, which is the &lt;em&gt;f&lt;/em&gt; term from section 05 spending your scarcest minutes twice.&lt;/p&gt;

&lt;h3&gt;
  
  
  When not to use it
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Single-lane work.&lt;/strong&gt; One focused change, one agent, direct supervision. The whole apparatus is overhead when there is nothing to coordinate. I still use a plain single-agent session for most of my day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exploratory work where the spec is unknown.&lt;/strong&gt; You cannot brief a lane on a task you cannot specify, and a lane briefed vaguely will produce something confidently wrong at speed. Exploration wants one agent and a human who can change their mind mid-sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anything where the audit costs as much as the task.&lt;/strong&gt; If &lt;em&gt;A&lt;/em&gt; approaches &lt;em&gt;L&lt;/em&gt;, the ceiling from section 05 drops to roughly two times regardless of fleet size, and you are running a fleet for a doubling. Do it yourself.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The honest version of the thesis&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This pattern does not make agents trustworthy. Nothing in it improves a single agent's reliability by one percentage point. What it does is make their unreliability cheap: bounded to a lane, caught at a gate, recorded in a ledger, recoverable by resume. That is a smaller claim than "multi-agent development works", and it is the one I can actually defend.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The count of nine that turned out to be seven is a small story, and I keep telling it because everything else in this loop is downstream of it. Not "the model was wrong" but "I merged on a summary". Once you accept that a report is the weakest evidence in the system, the roles fall out, the gate falls out, the ledger falls out, and so does the uncomfortable conclusion that the bottleneck is supposed to be you.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sentinel writes no product code.&lt;/strong&gt; It scopes lanes, audits evidence, owns the append-only ledger, and is the only place paid or irreversible actions can originate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coders are disposable, one task each&lt;/strong&gt;, isolated in a git worktree branch, suite green before reporting, in a report shape the brief specifies. Reviewers attack on independent axes, in capped rounds, and their findings are verified before they gate anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rule 1, enumerate.&lt;/strong&gt; A lane reported nine; the artifacts said seven; the nine had already reached the ledger. Reports are hearsay, artifacts are evidence, and verification has to cross an agent boundary to count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The gate sets the ceiling.&lt;/strong&gt; With lanes of length &lt;em&gt;L&lt;/em&gt; and audits of length &lt;em&gt;A&lt;/em&gt;, batch speedup is N(L+A)/(L+NA) and stream speedup is min(N, L/A + 1). Both cap at L/A + 1. Rework at rate &lt;em&gt;f&lt;/em&gt; costs you 1+&lt;em&gt;f&lt;/em&gt; trips through the gate per delivered task, and the sentinel's context window imposes a second, independent bound. Cheaper audits raise the ceiling; more lanes do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules 2 to 4, mechanics.&lt;/strong&gt; Worktree isolation is per-resource and dot-directory worktrees change how path-walking tools behave, so the gate re-runs on the main checkout. Tier models by failure cost, since cost scales with the fleet and the fleet is coders. Killed agents get resumed from their transcripts, because the kill takes context and never disk state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rules 5 to 7, constraints.&lt;/strong&gt; One quota window funds dev and evaluation, so everything is resumable and exploration happens once. Sleep kills streams (verified against the wake log) and heavy indexing runs one at a time. Subagents hand off through attributed notes, not transcripts, so the gate's context stays free for auditing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The honest bill.&lt;/strong&gt; You buy parallelism, adversarial coverage, and auditability. You pay in gate overhead, brief-writing, and token spend, and you accept that the orchestrator is the bottleneck by construction. Below a certain task size this makes you slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one thing from this: design your loop so that the cheapest step is the one that catches errors. Everything else is scheduling.&lt;/p&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/vectr-1-1-team-mode-seven-agent-test/" rel="noopener noreferrer"&gt;Vectr 1.1.0: Team Mode and the Seven-Agent Test&lt;/a&gt;: seven agents coordinating through one shared store, used as a release gate.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;Vectr v1.0.0: The Release Gate and the Honest Numbers&lt;/a&gt;: the dogfood gate that caught two release blockers, and the costs published with it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;Claude Code Hooks and Deterministic Agent Behavior&lt;/a&gt;: the mechanism behind deterministic injection: events, exit codes, and a working pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://swapnanilsaha.com/blog/sentinel-pattern-multi-agent-development/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>softwaredevelopment</category>
      <category>programming</category>
    </item>
    <item>
      <title>The Agent Never Chooses to Remember: Memory as a Harness Property</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 04 Aug 2026 19:58:52 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/the-agent-never-chooses-to-remember-memory-as-a-harness-property-4gag</link>
      <guid>https://dev.to/swapnanilsaha/the-agent-never-chooses-to-remember-memory-as-a-harness-property-4gag</guid>
      <description>&lt;h1&gt;
  
  
  The Agent Never Chooses to Remember: Memory as a Harness Property
&lt;/h1&gt;

&lt;p&gt;&lt;em&gt;Every shipped memory design asks the model to decide to save and to recall. In a control run with the answers already sitting in the store, it decided zero times across 114 turns. Here is the tier we skipped, and how to build it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I spent most of a year building a memory system for coding agents and only realised at the end that I had been solving the wrong half of the problem. The store was fine. The retrieval was fine. What I had never questioned was the step in the middle: that the agent would decide, on its own, to use either of them.&lt;/p&gt;

&lt;p&gt;Open any agent memory product and you will find the same shape. A place to put things, a way to get them back, and a paragraph of instructions telling the model when to do both. Instruction files, memory directories, memory tools over MCP, vector stores with a recall endpoint. The architectures differ wildly. The assumption underneath them does not: somewhere in the loop, the model has to want to remember.&lt;/p&gt;

&lt;p&gt;That assumption is the thing I want to take apart. Not because models are lazy, and not because the tools are bad, but because we borrowed the wrong half of human memory when we designed them. The memory that carries you through a working day is not the memory you go looking for. It is the memory that arrives because the situation summoned it. That tier, in an agent, cannot be a skill the model exercises. It has to be a property of the harness, the program the model runs inside, which owns the context window and can put things in it without asking.&lt;/p&gt;

&lt;p&gt;This post is that argument, the runs behind it, and the shape of the thing you actually build. A warning up front on scope: the measurements are one task inside one harness at one version, and section 12 is where I lay out how far I think they generalise and what would make me abandon the position.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The paper this post accompanies.&lt;/strong&gt; The full treatment, including the protocol, the graders, and the two-tier theory this post argues informally, is &lt;a href="https://arxiv.org/abs/2607.20972" rel="noopener noreferrer"&gt;Delivery, Not Storage: Cue-Anchored Working Memory as a Harness Property for Coding Agents&lt;/a&gt; (arXiv:2607.20972, 23 July 2026). Every number in this post comes from that paper. Where the paper hedges, this post hedges the same way.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A note on vocabulary before we start.&lt;/strong&gt; A &lt;em&gt;harness&lt;/em&gt; is the program the model runs inside. It owns the conversation, decides what text enters the context window, runs the tools, and handles compaction. Claude Code, Cursor, and any agent runtime are harnesses. The &lt;em&gt;context window&lt;/em&gt; is the fixed-size block of text the model can see on any one forward pass; everything the model "knows" about the current session is either in the weights or in that window.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 1: The Assumption Nobody States
&lt;/h1&gt;

&lt;h2&gt;
  
  
  01. One Assumption, Every Agent Memory Product
&lt;/h2&gt;

&lt;p&gt;Line up the current designs and sort them, not by how they store things, but by who decides a stored fact enters the model's context window. That column has only three values in it, and the difference between them turns out to matter more than everything the marketing pages compare.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Design&lt;/th&gt;
&lt;th&gt;Who decides it arrives&lt;/th&gt;
&lt;th&gt;When it arrives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Instruction file&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;The harness&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Session start, and after every compaction. Unconditional.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory directory&lt;/td&gt;
&lt;td&gt;The model&lt;/td&gt;
&lt;td&gt;Whenever the model decides to open it. Possibly never.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory tool over MCP&lt;/td&gt;
&lt;td&gt;The model&lt;/td&gt;
&lt;td&gt;Whenever the model decides to call it. Possibly never.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector store with recall&lt;/td&gt;
&lt;td&gt;The model&lt;/td&gt;
&lt;td&gt;Whenever the model forms a query. Possibly never.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path-scoped rule file&lt;/td&gt;
&lt;td&gt;The harness, conditionally&lt;/td&gt;
&lt;td&gt;When a matching file is read. Cue-triggered.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice which rows have the sophisticated engineering in them and which rows actually deliver. The instruction file is a flat text document with no retrieval logic whatsoever, and it is the single most reliable memory channel any coding agent has. Not because it is well designed. Because a mechanism outside the model pastes it into context whether the model was thinking about it or not.&lt;/p&gt;

&lt;p&gt;The rows below it are where all the interesting engineering happens: retrieval by meaning rather than keyword, reranking of candidates, knowledge graphs, merging old notes into newer ones, ageing out what is stale. Every bit of that quality gets multiplied by a number nobody puts on the slide, which is the fraction of the time the model actually invokes the thing. If that number is near zero, everything above it is arithmetic on zero.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The uncomfortable ranking.&lt;/strong&gt; Sort agent memory channels by reliability and you get an inverse ranking of engineering effort. The dumbest channel, a static file the harness pastes in, is the most reliable. The smartest channel, semantic retrieval with a well-tuned index, is the least reliable, because it is gated on an act of model initiative that may not happen. Reliability here is not a property of the retrieval. It is a property of who pulls the trigger.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not an argument that instruction files are good. They are terrible in most ways: static, unconditional, growing without bound, and paid for on every single cycle. It is an argument that &lt;em&gt;delivery&lt;/em&gt; is doing the work we keep crediting to storage and retrieval. And once you see that, the next question is obvious: is there a way to get instruction-file reliability without instruction-file cost? That question is the rest of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  02. The Memory Tier You Do Not Choose
&lt;/h2&gt;

&lt;p&gt;Try to remember what you had for lunch on the third Tuesday of last month. You just did something effortful: you constructed a search, you probed for constraints, you either found it or gave up. Psychologists call that voluntary retrieval. It is slow, it is strategic, and it is the tier every agent memory tool models.&lt;/p&gt;

&lt;p&gt;Now think about the last time a smell in a stairwell put you somewhere twenty years ago, unbidden, complete, before you had decided anything. Or, less romantically: you sit down to edit a config file and the thought &lt;em&gt;careful, this one is load-bearing, someone broke prod with it in March&lt;/em&gt; shows up on its own. You did not query for that. The situation delivered it.&lt;/p&gt;

&lt;p&gt;That second tier is &lt;strong&gt;involuntary retrieval&lt;/strong&gt;, and diary studies of everyday cognition find it is not a curiosity at the edges. It is a routine, frequent part of how people function, and it shows up most in situations where attention is diffuse, which is to say, while you are busy doing something else. The retrieval is associative and context-sensitive rather than effortful and strategic.&lt;/p&gt;

&lt;p&gt;The condition for it firing is well characterised. Endel Tulving's &lt;strong&gt;encoding specificity principle&lt;/strong&gt; says a cue works to the degree it overlaps with what was encoded when the memory formed. Later work on spontaneous retrieval sharpens it: distinctive cue-to-event pairings produce far more involuntary memories than generic ones. A specific sound tied to a specific scene brings the scene back. A generic sound brings back nothing in particular.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The two tiers, in a kitchen.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Voluntary retrieval is looking up a recipe. You know you need it, you know roughly where it lives, you go and get it. It works, and it costs you a deliberate trip.&lt;/p&gt;

&lt;p&gt;Involuntary retrieval is reaching for the pan handle and your hand stopping short, because that handle was hot once. Nobody looked anything up. The situation, the handle, the reach, produced the memory as a side effect of being in that situation. If you had to consciously query "is this handle historically hot" before every reach, you would eventually forget to, and you would eventually get burned.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here is the part that matters for design. The tier that keeps you safe while you work is the second one, and it is precisely the tier that is &lt;em&gt;not&lt;/em&gt; under your control. You do not decide to remember the hot handle. Deciding is the failure mode. If the memory required a decision, the decision would compete with the actual task, and the actual task would win, because the actual task is what you are attending to.&lt;/p&gt;

&lt;p&gt;Every agent memory product I have used models the recipe book. None of them model the hot handle. And the hot handle is where the load is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What this analogy does not claim.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I am not saying a language model has episodic encoding, consolidation, or anything resembling involuntary recall as a cognitive process. It does not. The model has a context window and a set of weights, and that is the whole of it.&lt;/p&gt;

&lt;p&gt;What transfers is the &lt;em&gt;delivery discipline&lt;/em&gt;, not the mechanism. Human memory research earns its place here for one reason: it establishes that a system doing serious work leans heavily on a retrieval path the agent never initiates, and that the path is governed by cue specificity. That constraint you can implement in a harness with a glob pattern and an event hook. Take the constraint; leave the neuroscience.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One caveat before anyone who knows this literature objects. Human involuntary recall is not reliable. It fires at useless moments, it misses the moments you needed it, and it is famously bad at surfacing the name of the person walking towards you. If the argument were "copy human memory", that unreliability would be a serious problem for it.&lt;/p&gt;

&lt;p&gt;It is not the argument. A cue in a harness is a boolean over observable state, so it fires exactly when it is true and never otherwise. The engineered version is strictly more dependable than the thing that inspired it, and that is the point of building it rather than admiring it. What biology contributes is the observation that the un-initiated path is load-bearing at all, which is not obvious and which the entire agent memory field has been building as though it were false.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 2: What the Runs Actually Showed
&lt;/h1&gt;

&lt;h2&gt;
  
  
  03. Zero Voluntary Memory Operations in 114 Turns
&lt;/h2&gt;

&lt;p&gt;Theory is cheap. The reason I believe the two-tier framing is a run I set up to disprove the opposite hypothesis, and could not.&lt;/p&gt;

&lt;p&gt;The setup removes every excuse an agent could have for not using a memory store. The store was pre-seeded with four notes about the exact task the agent was about to attempt: real gotchas, the kind of thing that saves a wrong turn if you read it. The memory tools were connected and verified in the session's own initialisation. The workspace instruction file carried usage guidance telling the model when to reach for them. Then the agent was given a naturalistic coding task and left alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The measurement.&lt;/strong&gt; Across &lt;strong&gt;114 turns&lt;/strong&gt;, the agent performed &lt;strong&gt;zero voluntary memory operations&lt;/strong&gt;. It never wrote a note. It never read one. The four answers to its own problems sat in the store the entire time, one call away, unqueried.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A &lt;em&gt;turn&lt;/em&gt; here is one full think-act-observe cycle: the model produces a response, optionally calls tools, receives the results. One hundred and fourteen of them went by. The agent read files, ran commands, wrote code, and made progress on the task. It simply never treated the memory store as part of its working repertoire.&lt;/p&gt;

&lt;p&gt;The first thing anyone says when I show them this is that the guidance must not have been strong enough. It is a fair reflex and the numbers do not support it. Across the five unseeded tool-equipped runs in the same set, voluntary memory writes came in at zero to one per run, against &lt;strong&gt;32&lt;/strong&gt; separate mentions of memory guidance in the workspace instruction file that the model reads at every session start. A different run in the same family put ten tools in front of the agent with 30 mentions of them in that file and got &lt;strong&gt;zero tool calls in 63 turns&lt;/strong&gt;. There is no dose-response curve here to climb. More words did not buy more calls.&lt;/p&gt;

&lt;p&gt;The second reflex is that this is a bias against third-party tooling, which is a more interesting hypothesis and also wrong. The harness ships its own native memory feature: a built-in directory the model is trained to write notes to itself in, no external server involved, no tool schema to learn. That directory was never created in any run of any arm. So the model was not preferring the house option to mine; it was declining the whole category of stopping mid-task to do memory hygiene.&lt;/p&gt;

&lt;p&gt;The third reflex, that the task simply was not hard enough to need memory, is the one that took a whole extra experiment to rule out, and section 05 is where that goes. It is the strangest result in the project.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The confound I have to state before you quote this.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is one configuration, not a law. The tool descriptions, the guidance wording, the system prompt, and the task shape were all held fixed at one setting, and incentives were not manipulated. A different tool description, a stronger prompt, or an explicit reward for using memory might well produce nonzero usage; I have not run those arms.&lt;/p&gt;

&lt;p&gt;So the claim is deliberately narrow: &lt;strong&gt;this configuration produced zero&lt;/strong&gt;. What that licenses is not "agents cannot use memory tools". It is "you cannot assume they will, and if your design's reliability depends on them doing so, you have an unmeasured dependency sitting at the centre of it".&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That narrower claim is still enough to break most designs, because most designs do not have a fallback. If the recall does not happen, nothing happens. There is no partial credit for a store with the right contents.&lt;/p&gt;

&lt;h2&gt;
  
  
  04. What the Compaction Probe Adds to the Picture
&lt;/h2&gt;

&lt;p&gt;The zero tells you the voluntary channel does not fire. It does not tell you whether that matters, because maybe the conversation holds everything the agent needs anyway. The second experiment closes that hole.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compaction&lt;/strong&gt; is what happens when a long session fills its context window. The harness takes the older conversation, has the model summarise it, and replaces the raw history with that summary. It comes in two flavours: autocompact, which fires on its own at a threshold you do not control, and the manual &lt;code&gt;/compact&lt;/code&gt;, which fires when you ask and lets you pass a custom instruction into the summarisation prompt.&lt;/p&gt;

&lt;p&gt;People reach for that custom instruction as the fix, and it is worth understanding why it does not hold. Writing "preserve all operational facts" into the compact prompt is still asking a model to decide what counts as an operational fact, on a context it is seeing under compression pressure, with no way for you to check the result before the original is gone. You have not removed the judgement call. You have moved it one step and made it harder to audit.&lt;/p&gt;

&lt;p&gt;The structural catch is worse than the judgement call. Each compaction summarises the &lt;em&gt;previous summary&lt;/em&gt;, not the original conversation. Summary &lt;em&gt;k&lt;/em&gt; is a function of summary &lt;em&gt;k&lt;/em&gt; minus one plus whatever happened since. So a detail that one pass declines to carry cannot reappear in any later pass, because there is no original left for it to come from. The only way a lost fact returns is if something outside the recursion puts it back into the raw context, which is exactly what happened in the two anomalous summaries below, and exactly what a delivery hook does on purpose.&lt;/p&gt;

&lt;p&gt;The probe seeds ten synthetic operational facts into a session exactly once, deletes the file they came from so they cannot be re-read, then forces compaction over and over and grades every summary for whether the facts are still there. Two arms, identical workload, differing in one thing: one has no memory tier, the other has the same ten facts delivered back into each fresh context by harness hooks.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The two channels, measured.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversation-only facts:&lt;/strong&gt; absent from &lt;strong&gt;106 of 108&lt;/strong&gt; post-compaction summaries. The two exceptions are the re-entry case: the agent happened to grep something that put the facts back into raw context, they survived roughly one further summarisation pass, and then they were gone again. No summary ever carried a partial set. Always ten or zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harness-delivered facts:&lt;/strong&gt; arrived at &lt;strong&gt;all 138&lt;/strong&gt; compact-resumes of the memory-equipped arm. Delivery fired at every fresh window and nowhere else.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those two numbers measure different channels, and conflating them is the most common mistake I see people make with this result. The 106-of-108 figure is about the &lt;em&gt;summary&lt;/em&gt;: what the summariser chose to carry forward. The 138-of-138 figure is about &lt;em&gt;delivery&lt;/em&gt;: what the harness put into the fresh window afterwards. In the memory-equipped arm the two came apart completely: from summary 67 onward there is a stretch of 72 consecutive summaries carrying none of the ten facts, while deliveries kept arriving on schedule throughout, and the run was entirely unaffected because the facts were in the window regardless of what the summary said about them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Being in the context is not being in the summary.&lt;/strong&gt; In the memory arm, the very first summary dropped all ten facts even though they had been injected into the exact context the summariser was compressing. The summariser is a model making a fresh judgement about what matters, every time. Whether it keeps your fact is not something you can design around, and in this run it flipped mid-way for reasons I still cannot fully explain. A delivery channel does not have that problem because it never asks.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Put the two experiments together and you have the shape of the argument. Facts that live only in the conversation die at the boundary. The voluntary channel that could bring them back does not fire. Therefore, in any session long enough to compact more than once, a fact that is neither in an instruction file nor delivered by a hook is a fact that is functionally gone, no matter how well you stored it.&lt;/p&gt;

&lt;h2&gt;
  
  
  05. The Agent That Grepped Its Own Session Logs
&lt;/h2&gt;

&lt;p&gt;My favourite result in the whole project was not designed. It fell out of the no-memory arm and it is the reason I stopped worrying that the demand for this tier might be imaginary.&lt;/p&gt;

&lt;p&gt;In the closing phase of that arm, the facts had been gone from every summary for dozens of boundaries. The prompt asked for them in the final report. The agent's shell access was explicitly forbidden, and that ban was restated in every single phase prompt. It used the shell anyway. It worked out that the harness persists its own session transcript to disk as newline-delimited JSON, and that the CLI keeps tool results as files too, and it grepped those artifacts for the fact markers until it found them. Of the 260 shell commands it ran, 38 were this archaeology.&lt;/p&gt;

&lt;p&gt;Read that as a capability statement and it is genuinely impressive. Denied a memory tier, the model reverse-engineered one out of the substrate it happened to be standing on. It understood that the harness's disk state outlives its conversation state, and it mined the difference.&lt;/p&gt;

&lt;p&gt;Read it as evidence and it says something narrower and more useful: &lt;strong&gt;the need was real&lt;/strong&gt;. This was not a system politely declining a feature it had no use for. It was a system that needed the information badly enough to violate a restated constraint and burn 38 shell commands reconstructing it. The demand exists. What is missing is a supply path that does not depend on the model noticing it needs one, mid-task, under load.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Need and initiative are different variables.&lt;/strong&gt; The same run gives you both halves. Given a memory store and a tool, the model does not call it. Denied a store entirely and pushed to produce the facts, the model builds its own retrieval out of log files. Those are not contradictory. Voluntary recall is a deliberate act competing with the task; log archaeology at the end &lt;em&gt;was&lt;/em&gt; the task. The gap between "needs the information" and "will go and get the information at the moment it would help" is exactly the gap a delivery tier fills.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a smaller lesson here that cost me a pilot run, and I include it because it is the kind of thing that will silently invalidate your version of this experiment too. The harness restores recently-read files across the compaction boundary, small ones verbatim. In an early version of the probe, the fixture holding the ten facts came back whole right after the first compaction, the agent recited all ten, and for about an hour I believed the facts had survived. They had not; the file had. That is why the real runs delete the fixture before forcing anything, and it generalises: when you measure memory, make very sure you are not measuring file restoration wearing memory's clothes.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 3: Building the Tier
&lt;/h1&gt;

&lt;h2&gt;
  
  
  06. Stored Is Not Available: The Design Axiom
&lt;/h2&gt;

&lt;p&gt;Here is the sentence the whole project reduces to, and I would put it on the wall of any team building this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The axiom.&lt;/strong&gt; &lt;strong&gt;Availability is a property of the delivery rule, not of the store.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Storage answers "does this fact exist somewhere". Availability answers "is this fact inside the model's context at the moment it changes what the model does". They are independent. A store at 100 percent storage and 0 percent availability contributes exactly nothing, and it will look healthy on every dashboard you build for it, because dashboards measure the first quantity.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most memory evaluations I have read measure retrieval precision on held-out queries. That is a measurement of the store, conditional on a query arriving. It says nothing about whether the query arrives. If you want a number that predicts value, you want availability, and you can write it down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The metric to instrument.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a fact &lt;code&gt;f&lt;/code&gt;, let &lt;code&gt;R(f)&lt;/code&gt; be the set of turns at which &lt;code&gt;f&lt;/code&gt; is relevant, meaning it would change what the agent does. Let &lt;code&gt;C_t&lt;/code&gt; be the context window at turn &lt;code&gt;t&lt;/code&gt;. Then&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A(f) = |{ t in R(f) : f in C_t }| / |R(f)|
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Availability is the fraction of the moments that mattered at which the fact was actually present. For a voluntary tier, &lt;code&gt;A(f)&lt;/code&gt; factors as &lt;code&gt;P(the model issues a query)&lt;/code&gt; times &lt;code&gt;P(the store returns f | queried)&lt;/code&gt;. Store-side work only improves the second factor. The measured value of the first was zero, and zero times anything is what it is.&lt;/p&gt;

&lt;p&gt;For a delivered tier the first factor is replaced by whether your cue predicate is true at those turns, which is something you control at design time and can check by inspection.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I would push this metric on anyone benchmarking agent memory. Retrieval precision on a query set is a number about your index. Availability is a number about your product. They can differ by a factor of infinity, and in the seeded control they did.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interactive demo 1: stored versus available
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;(Live on the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-harness-property/" rel="noopener noreferrer"&gt;canonical post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One note, one session, three tracks. The top track shows what the store holds, which never changes. The two below show whether that note is actually inside the model's context at each turn, once under a voluntary tool the model may or may not call, once under cue-anchored delivery. Drag the call rate down to the measured zero and watch the second track go dark while the store stays full.&lt;/p&gt;

&lt;p&gt;The rules the simulation uses: a compaction clears the window, so anything that was in context is lost unless it is delivered again; a voluntary call at rate &lt;em&gt;p&lt;/em&gt; puts the note in context from that turn until the next compaction; cue-anchored delivery fires at session start, after each compaction, and when a file matching the note's path glob is touched. Mechanism illustration, not measured data.&lt;/p&gt;

&lt;p&gt;The thing to take from it is not that one track is greener. It is that the top track never moves. Everything inside the store, how the text is split up, how it is indexed, how results are ranked, how old notes are merged or aged out, is a decision about track one. Tracks two and three are decided by something else entirely, and that something else is where reliability lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  07. What a Cue Actually Is, Mechanically
&lt;/h2&gt;

&lt;p&gt;"Cue-anchored" sounds like a metaphor. In an implementation it is not; it is a boolean.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Definition.&lt;/strong&gt; A &lt;strong&gt;cue&lt;/strong&gt; is a predicate over harness state, evaluated by the harness at fixed lifecycle points, whose truth causes a stored note to be written into the model's context.&lt;/p&gt;

&lt;p&gt;Three properties are doing all the work in that sentence. It is evaluated by the &lt;em&gt;harness&lt;/em&gt;, so no model initiative is required. It fires at &lt;em&gt;fixed points&lt;/em&gt;, so it is deterministic and auditable. And it is a &lt;em&gt;predicate&lt;/em&gt;, so it can be as narrow as you like, which is what stops the channel from degenerating into a second instruction file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The state a harness can see is richer than people assume. It knows the session just started. It knows a compaction just finished. It knows which file is about to be edited, which command is about to run, what the user typed in the prompt that is about to be submitted, and what time it is. Each of those is a candidate condition.&lt;/p&gt;

&lt;h3&gt;
  
  
  The condition classes worth having
&lt;/h3&gt;

&lt;p&gt;These are the ones I ended up shipping in &lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, and the ones I would build again from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Path glob.&lt;/strong&gt; The note fires when a file matching the pattern is touched. This is the workhorse. Most operational knowledge is anchored to a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle event.&lt;/strong&gt; &lt;code&gt;session-start&lt;/code&gt;, &lt;code&gt;prompt-submit&lt;/code&gt;, &lt;code&gt;pre-edit&lt;/code&gt;, &lt;code&gt;pre-run&lt;/code&gt;, &lt;code&gt;pre-commit&lt;/code&gt;, &lt;code&gt;post-compaction&lt;/code&gt;. These are moments the harness already owns, which is why riding them costs nothing structural.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symbol reference.&lt;/strong&gt; An exact symbol name, so a caveat about one function surfaces when that function is in play rather than whenever its file is opened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic match.&lt;/strong&gt; Similarity between the note and the submitted prompt. This is the one condition class that is probabilistic rather than exact, and it is the one to be most careful with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal guard.&lt;/strong&gt; &lt;code&gt;not_before&lt;/code&gt;, &lt;code&gt;expires_visibility&lt;/code&gt;, &lt;code&gt;cooldown&lt;/code&gt;. Not for finding the note but for governing how often it is allowed to arrive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The event surface is coarser than that list makes it sound
&lt;/h3&gt;

&lt;p&gt;Worth saying plainly, because it is the first thing you hit when you implement this. Those six event names are my vocabulary, not the harness's. Claude Code exposes &lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt; and a few more. My &lt;code&gt;pre-edit&lt;/code&gt; and &lt;code&gt;pre-run&lt;/code&gt; are both &lt;code&gt;PreToolUse&lt;/code&gt; with a matcher on the tool name. My &lt;code&gt;pre-commit&lt;/code&gt; is &lt;code&gt;PreToolUse&lt;/code&gt; on the shell tool with a pattern match for a git commit, which is leaky in the obvious way: a commit made through some other path does not look like a commit to the matcher, and you will not find out until the note fails to fire.&lt;/p&gt;

&lt;p&gt;Where the output lands also differs by hook, and it reads differently to the model. &lt;code&gt;UserPromptSubmit&lt;/code&gt; writes to standard output and the harness prepends that text to the user's prompt, so it arrives looking like part of what the user said. &lt;code&gt;PreToolUse&lt;/code&gt; feedback arrives attached to the tool call. Same bytes, different framing, and in my experience the prompt-prepended channel gets attended to noticeably more. That is an impression from watching transcripts, not a measurement, and I would like someone to run it properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Composition, and why the semantics matter
&lt;/h3&gt;

&lt;p&gt;Conditions combine with &lt;strong&gt;AND inside a single trigger entry&lt;/strong&gt; and &lt;strong&gt;OR across entries&lt;/strong&gt;. That is a small design decision with a large consequence: it lets you narrow a fire by conjunction and cover multiple distinct moments by disjunction, without a query language.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;vectr_remember&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/test_session.py deadlocks under parallel test execution. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Run it with -p no:xdist or it hangs the whole suite.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gotcha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;triggers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-edit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/test_session.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;   &lt;span class="c1"&gt;# entry 1: AND
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-run&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tests/**&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                &lt;span class="c1"&gt;# entry 2: AND
&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre-commit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;                                  &lt;span class="c1"&gt;# entry 3
&lt;/span&gt;  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# fires when: (editing that exact file) OR (running anything under tests/) OR (committing)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most notes never need an explicit &lt;code&gt;triggers&lt;/code&gt; list, because the note's &lt;em&gt;kind&lt;/em&gt; already implies a delivery rule. That mapping is the part of the design I would defend hardest, because it is what makes the tier usable by an agent that is not thinking about delivery at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Kind&lt;/th&gt;
&lt;th&gt;What it holds&lt;/th&gt;
&lt;th&gt;Default delivery rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;directive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A standing rule the user stated once&lt;/td&gt;
&lt;td&gt;Injected at every session start, unconditionally. This is the one class that behaves like an instruction file, on purpose.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gotcha&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A caveat anchored to a specific file&lt;/td&gt;
&lt;td&gt;Fires when that file is touched. Silent otherwise.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;task&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current-work state&lt;/td&gt;
&lt;td&gt;Returned newest-first, because for in-flight work recency beats similarity.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;finding&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A learning about the codebase&lt;/td&gt;
&lt;td&gt;Relevance-ranked against what is being worked on.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;reference&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A pointer to a URL or ticket&lt;/td&gt;
&lt;td&gt;Retrieved on demand. Low value delivered, high value looked up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;decision&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;An architectural decision and its reasoning&lt;/td&gt;
&lt;td&gt;Not auto-injected. Recalled chronologically when someone asks for the decision history.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the last two rows. Not everything belongs in the delivered tier, and a design that pushes everything into it has misunderstood the point. The question for each note is not "is this valuable" but "can I name the moments at which it changes behaviour". If you can name them, deliver it. If you cannot, it is a lookup, and a lookup is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  08. Composing Triggers: Precision Is the Whole Game
&lt;/h2&gt;

&lt;p&gt;Cue design has the same structure as any retrieval problem, with one twist that changes the economics. A cue that never fires misses the moment. A cue that fires everywhere costs tokens on every cycle, and unlike a bad search result, you cannot ignore it: it is already in the context, already paid for, already displacing something else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Interactive demo 2: design a trigger
&lt;/h3&gt;

&lt;p&gt;&lt;em&gt;(Live on the &lt;a href="https://swapnanilsaha.com/blog/agent-memory-harness-property/" rel="noopener noreferrer"&gt;canonical post&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One note ("tests/test_session.py deadlocks under parallel test execution, run it with &lt;code&gt;-p no:xdist&lt;/code&gt;", about 42 tokens), a fixed stream of eighteen harness events, and six of those events are moments where the note would actually change what the agent does. You compose up to three trigger entries out of an event selector, a &lt;code&gt;tests/**&lt;/code&gt; path condition, a semantic condition on the prompt, and an optional cooldown. The demo scores moments caught, moments missed, surplus fires, and injected tokens.&lt;/p&gt;

&lt;p&gt;The naive preset, one entry matching any event, costs 756 injected tokens on a stream of eighteen events, for a note worth 42. That is what "just inject it always" looks like once you count, and it is roughly what happens to every instruction file that has been alive for a year.&lt;/p&gt;

&lt;p&gt;The tuned preset catches all six moments and still leaves one surplus fire, and that one is the more useful lesson. Running &lt;code&gt;pytest tests/test_billing.py&lt;/code&gt; and running &lt;code&gt;pytest tests/test_session.py&lt;/code&gt; are different situations to you and identical situations to a glob over &lt;code&gt;tests/**&lt;/code&gt;. You cannot cue more finely than your harness can observe. Every cue design bottoms out on that ceiling, and knowing where yours sits is most of the work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The asymmetry between a miss and a surplus fire.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They are not symmetric errors, and the direction flips depending on scale. One miss costs you one bad decision, once. One surplus fire costs you tokens on &lt;em&gt;every&lt;/em&gt; cycle the cue matches, forever, and that cost compounds across a session while the miss does not.&lt;/p&gt;

&lt;p&gt;Which means the usual retrieval instinct, favour recall and let ranking sort it out, is wrong here. In a delivered tier, over-firing is the failure mode that kills the configuration, and section 09 is about why that is more literal than it sounds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  09. What Delivery Costs, Honestly
&lt;/h2&gt;

&lt;p&gt;I have spent eight sections arguing for delivery. Here is the bill, because a post that only showed the wins would be lying by omission.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixed per-cycle context, not a one-time payment
&lt;/h3&gt;

&lt;p&gt;Every delivered token is a token of context you do not get back. In the probe, each delivery cost around &lt;strong&gt;240 tokens&lt;/strong&gt;, mean injected text of 969 characters under the standard four-characters-per-token estimate, and roughly &lt;strong&gt;34k tokens&lt;/strong&gt; across the full run. As an absolute number that is small. As a structural property it is not, because it is paid on every cycle rather than once.&lt;/p&gt;

&lt;p&gt;That distinction bit me in a way I did not anticipate, and it cost me two invalidated launches before I understood what I was looking at. Claude Code has an autocompact thrash guard: it aborts the call when the context refills to the limit within three turns of a compaction, three times running. Sensible protection against a session that has become a compaction loop. The guard is not memory-aware and should not have to be, because all it reads is refill pressure. But a memory-carrying configuration has fixed per-cycle payload the bare one does not: reloaded tool schemas, guidance files, per-prompt injections. None of it large. All of it always there. At the shared file-read cap both arms started with, the guard killed the memory arm's first audit phase fourteen turns in. Running that arm at all meant dropping its read cap from 8k to 6k, and even then the guard killed the final phase once the growing report had eaten the remaining headroom.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lean payload is not a nicety, it is a precondition.&lt;/strong&gt; A harness tuned for memoryless operation treats a memory-carrying configuration's context profile as elevated thrashing risk. Your per-cycle payload competes for exactly the headroom the guard is watching. Roughly 240 tokens per delivery was what let the configuration run here. That number will differ in your harness; the fact that there is such a number will not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Costs that only show up once you run this for a while
&lt;/h3&gt;

&lt;p&gt;Duplication is the one I did not see coming. A delivered note enters the context; the next compaction summarises that context; if the summariser happens to keep the note, it is now in the summary as well. Delivery then fires again on the fresh window and you are carrying two copies, neither of which knows about the other. Either you deduplicate against what is already present, or you pay for the note twice for the rest of the session. Nobody I have read on agent memory mentions this, and it falls out of the design immediately.&lt;/p&gt;

&lt;p&gt;Then there is latency, which is a design constraint disguised as an implementation detail. Hooks run synchronously in front of the operation they are attached to, so a cue evaluator on &lt;code&gt;pre-edit&lt;/code&gt; means every edit the agent makes now waits on your predicate. Make that a network round trip to a hosted store and you have slowed the agent down on every file it touches, all day, in exchange for a note it may not even need. That is a large part of why vectr runs as a local daemon with recall under 50ms instead of as a service. Not a purity argument about local-first software. The budget for a synchronous pre-edit hook is simply tiny, and the architecture has to fit inside it.&lt;/p&gt;

&lt;p&gt;The one I would flag hardest to anyone building this is that the &lt;code&gt;directive&lt;/code&gt; class grows without bound. It is the kind that injects unconditionally at every session start, which means it behaves like an instruction file and inherits the instruction file's disease exactly. Every standing rule anyone adds stays forever. The block only ever gets bigger. Eighteen months in you are back where you started with more moving parts. So the tier needs a budget: a hard token ceiling on the unconditional block, with the least-recently-relevant entries evicted when it is exceeded. I would much rather be forced to demote a stale directive into a cued one than discover the block has quietly reached four thousand tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  The rest of the ledger
&lt;/h3&gt;

&lt;p&gt;The memory arm ran &lt;strong&gt;21 percent more turns&lt;/strong&gt; and &lt;strong&gt;36 percent more cost&lt;/strong&gt; than the baseline. Some of that is delivery overhead, some is the tighter read cap the guard forced on it, and some is a late-run stretch where the agent began inventing progress it had not made, announcing phases and file ranges that did not exist. Its audit completeness, scored against a mechanical count over the files it was supposed to have reviewed, came in at 0.70 against the baseline's 0.93. I would not read that gap as a memory effect. It is confounded with the read cap, and the read cap is the thing I had to change to get the arm to run at all.&lt;/p&gt;

&lt;p&gt;Which is worth sitting with for a second, because it is not the result I wanted. The memory arm was slower, more expensive, and scored worse on the task metric. What it did do was keep ten facts alive across 138 compaction boundaries that the baseline lost at the second one. Those are answers to different questions. If your session is short enough never to compact, this tier is pure overhead and you should not build it.&lt;/p&gt;

&lt;h3&gt;
  
  
  The failure mode that worries me most
&lt;/h3&gt;

&lt;p&gt;Not cost. Staleness. A delivered fact that has gone wrong is worse than an absent one, because the agent has no way to tell. An absent fact produces uncertainty, which is a state the model handles reasonably. A confidently delivered wrong fact produces confident wrong action, and it arrives with the authority of the harness behind it.&lt;/p&gt;

&lt;p&gt;So revocation has to be a first-class operation, and deletion is not good enough. If you silently delete a note the agent has been acting on, the belief it seeded does not disappear with it. What worked better was keeping a revoked note visible as a correction: it still surfaces on recall, marked as previously believed and now wrong, with the reason attached. The agent sees the retraction rather than a hole where a fact used to be. Temporal guards do the softer version of the same job: &lt;code&gt;expires_visibility&lt;/code&gt; on a note about an in-flight migration stops it outliving the migration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Three ways this tier goes bad in production.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Over-firing.&lt;/strong&gt; Broad globs and generous semantic thresholds turn the tier into a second instruction file, at which point you have reinvented the thing you were trying to improve on, with more moving parts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Staleness.&lt;/strong&gt; Delivered facts age. Without expiry and revocation, the tier degrades from useful to actively misleading, and it does so invisibly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic cues that drift.&lt;/strong&gt; The one probabilistic condition class is the one that silently widens as the store grows. Every other class is exact and stays exact. Threshold it conservatively and prefer the exact classes where you can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  10. Where Voluntary Retrieval Still Belongs
&lt;/h2&gt;

&lt;p&gt;I want to be careful not to argue past what the evidence supports, because the obvious misreading of this post is "delete the memory tools". That is not the conclusion, and if you act on it you will build something worse.&lt;/p&gt;

&lt;p&gt;The voluntary tier is the right shape for anything whose cue you cannot enumerate in advance. Exploring an unfamiliar area of a codebase. Answering a question nobody anticipated. Pulling up a decision history because someone in review asked why the schema looks like that. In every one of those cases the agent has a query and no way for the harness to have predicted it. Retrieval is exactly right there, and a good index earns its keep.&lt;/p&gt;

&lt;p&gt;What the measurements rule out is one specific and very common bet: putting &lt;em&gt;load-bearing&lt;/em&gt; facts behind a call the model has to remember to make. The model is perfectly capable of making that call. The problem is that making it competes for attention with the task, and the moments you most need the fact are precisely the moments the task is most demanding. You are asking for initiative at the exact point initiative is scarcest.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Delivered tier&lt;/th&gt;
&lt;th&gt;Voluntary tier&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cue known in advance?&lt;/td&gt;
&lt;td&gt;Yes, that is the precondition&lt;/td&gt;
&lt;td&gt;No, the query is formed at use time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failure if it does not fire&lt;/td&gt;
&lt;td&gt;Silent wrong action&lt;/td&gt;
&lt;td&gt;The agent explores instead. Slower, not wrong.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost model&lt;/td&gt;
&lt;td&gt;Fixed, per cycle, paid whether used or not&lt;/td&gt;
&lt;td&gt;Paid only when invoked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right content&lt;/td&gt;
&lt;td&gt;Standing rules, file gotchas, live task state&lt;/td&gt;
&lt;td&gt;Open-ended lookup, history, references&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right size&lt;/td&gt;
&lt;td&gt;Small. Tens of notes, tight cues.&lt;/td&gt;
&lt;td&gt;Large. The store can grow indefinitely.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two tiers are complements with different economics, and the mistake the field keeps making is not building the wrong one. It is building only one, calling it memory, and being surprised when the durable facts do not survive.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 4: Provenance and Limits
&lt;/h1&gt;

&lt;h2&gt;
  
  
  11. This Idea Has Ancestry, and I Should Say So
&lt;/h2&gt;

&lt;p&gt;Cue-triggered retrieval is not something I invented, and a post that implied otherwise would be worth less than one that traces where it came from.&lt;/p&gt;

&lt;p&gt;Production-rule systems have worked this way since the 1970s. A production rule is a condition-action pair, and the system fires it when its condition matches working memory. Nothing calls the rule. The match is the invocation. That is structurally the same move I am describing, applied to knowledge delivery rather than action selection.&lt;/p&gt;

&lt;p&gt;ACT-R goes further and is worth reading if you build this. Its declarative memory retrieves chunks by activation, and activation spreads from whatever is currently sitting in the system's buffers. The context you are in changes what is retrievable, without any explicit retrieval request. Soar makes a related move, biasing retrieval from declarative memory using the activation of elements already in working memory. Both architectures took cue-driven retrieval as foundational decades before anyone was writing hooks for a coding agent.&lt;/p&gt;

&lt;p&gt;And the coding harnesses already ship pieces of this. Claude Code re-reads and re-injects the project instruction file after every compaction, unconditionally, with no model involvement. Path-scoped rule files reload when a matching file is read, which is a path-glob cue in everything but name. The mechanism exists. What it lacks is a way for a note the agent wrote during the session to opt into the same delivery.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What is actually new here.&lt;/strong&gt; Three things, none of them the cue idea itself. The &lt;strong&gt;composition&lt;/strong&gt;: one store where a note's kind implies its delivery rule, and explicit triggers compose path, event, symbol, semantic, and temporal conditions over that default. The &lt;strong&gt;delivery argument&lt;/strong&gt;: the claim that for the operational tier this is not an optimisation but the only channel that works. And the &lt;strong&gt;measurement&lt;/strong&gt;: an actual number for what the voluntary channel delivers in a controlled run, which as far as I can tell nobody had published.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The measurement is the part I would defend most. Everyone in this field has an intuition about whether agents use their memory tools. Intuitions are free. The seeded control cost real money and produced a number, and the number was zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. What Would Change My Mind
&lt;/h2&gt;

&lt;p&gt;The evidence here is narrower than the thesis, and pretending otherwise would make the thesis less useful, not more.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One task, one harness, one model.&lt;/strong&gt; The compaction probes and the adoption runs are a single naturalistic coding task inside Claude Code CLI v2.1.211 in July 2026, driving Claude Haiku 4.5. Harness internals are exactly the kind of thing that changes between point releases, and I would not be surprised to find the thrash guard retuned or the file-restoration behaviour different by the time you read this. Read the specific mechanisms as a dated snapshot. The shape of the finding is the part I expect to hold: a channel gated on model initiative has a reliability you cannot design, and a channel gated on a harness predicate has one you can.&lt;/p&gt;

&lt;p&gt;A smaller version of the same caveat: a more capable driving model might well volunteer more. Haiku 4.5 is a fast, cheap model and it is a fair question whether a frontier model with more headroom would spend some of it on memory hygiene. I would guess it helps at the margin and does not change the structure, since the competition for attention gets worse, not better, as tasks get harder. That is a guess and I have labelled it as one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The zero has an incentive confound.&lt;/strong&gt; Tool descriptions, guidance wording, and task framing were fixed at one setting, and the agent was never given an explicit incentive to use memory. Elicitation arms that vary those, a differently phrased tool description, a system-prompt nudge, an explicit reward, are the obvious next experiment and I have not run them. If one of those arms produced substantial voluntary usage, the correct update would be: the voluntary channel is elicitable, and the design question becomes how robustly, across which models, at what cost to the task.&lt;/p&gt;

&lt;p&gt;Notice that even a strong elicitation result would not restore the original assumption. A channel that works when you tune the prompt just right, on this model version, is still a channel whose reliability you have to re-establish on every model update. The delivery channel does not have that property, and that asymmetry is most of why I favour it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The falsifier I would take seriously.&lt;/strong&gt; A run where an agent, without harness-side injection, reaches a comparable availability number on operational facts across many compaction boundaries, using only voluntary recall, and does it across model versions rather than on one. That would tell me the tier can be a skill after all. I have not seen it, and the seeded control is the strongest evidence I could construct against my own position: I gave the voluntary channel every advantage I could think of, and it returned zero.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Close: If a Fact Must Survive, Deliver It
&lt;/h2&gt;

&lt;p&gt;The wrong half I was solving, back at the top, was the store. I had built retrieval that understood code and a good place to keep what an agent learned, and I had left the most important step in the pipeline as an exercise for the model: notice you need this, then go and get it.&lt;/p&gt;

&lt;p&gt;It does not notice. The capability is there, but noticing competes with the work and the work wins, every time, and it wins hardest exactly when the stakes are highest. Meanwhile the instruction file, which is the crudest channel in the whole stack and has no retrieval logic in it at all, wins on reliability because it never asks the model for anything. Everything I have built since has been an attempt to keep that property and drop the cost. That is the whole of what a cue is: an instruction file that is only present at the moments it is true.&lt;/p&gt;

&lt;p&gt;So the practical version, if you are building on an agent right now. Take the facts your system must not lose and give each one a delivery rule instead of a storage location, expressed as something your harness can evaluate on its own. Make it as narrow as your event surface allows, and expect the event surface to be the binding constraint. Whatever refuses to be expressed that way stays a lookup, and that is fine, so long as you have stopped quietly assuming the lookup will happen.&lt;/p&gt;

&lt;p&gt;The paper puts it more compactly than I have managed anywhere in twenty-seven minutes of this: the reliable memory channel for agents is the one the agent never has to think about.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links and Further Reading
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The paper and the runs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Saha, S. &lt;a href="https://arxiv.org/abs/2607.20972" rel="noopener noreferrer"&gt;Delivery, Not Storage: Cue-Anchored Working Memory as a Harness Property for Coding Agents&lt;/a&gt;. arXiv:2607.20972, 23 July 2026. Every number in this post is from here.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr/tree/main/research/proactive-gate" rel="noopener noreferrer"&gt;Run archives&lt;/a&gt; for the adoption and compaction runs: protocol, graders, and per-run artifacts, including invalidated launches and their diagnoses.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, the working-memory daemon used as the instrument, and the implementation the trigger examples are drawn from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Harness behaviour&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anthropic. &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;How Claude remembers your project&lt;/a&gt;. Documents instruction-file re-injection after compaction and path-scoped rules loading on touch.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/34556" rel="noopener noreferrer"&gt;Persistent Memory Across Context Compactions (59 compactions, built our own)&lt;/a&gt;, a field report from a team that hit the same wall independently.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/78795" rel="noopener noreferrer"&gt;Triggered injection for auto-memory topic files&lt;/a&gt;, the upstream feature request the measurements motivated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The cognitive science behind the two tiers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Berntsen, D. et al. &lt;a href="https://royalsocietypublishing.org/rstb/article/376/1817/20190693/31394/Involuntary-autobiographical-memories-and-their" rel="noopener noreferrer"&gt;Involuntary autobiographical memories and their relation to other forms of spontaneous thoughts&lt;/a&gt;. Phil. Trans. R. Soc. B.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://link.springer.com/article/10.3758/s13421-019-00904-w" rel="noopener noreferrer"&gt;Retrieval intentionality and forgetting: how retention time and cue distinctiveness affect involuntary and voluntary retrieval&lt;/a&gt;. Memory &amp;amp; Cognition.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Cue-driven retrieval in cognitive architectures&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/pdf/2201.09305" rel="noopener noreferrer"&gt;An Analysis and Comparison of ACT-R and Soar&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="http://act-r.psy.cmu.edu/" rel="noopener noreferrer"&gt;ACT-R&lt;/a&gt;, the cognitive architecture whose declarative memory model is the closest prior art to a cue-anchored tier.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/what-survives-compact-claude-code/" rel="noopener noreferrer"&gt;What Actually Survives /compact in Claude Code&lt;/a&gt; - 108 and 138 forced compactions, graded fact by fact.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/mcp-tool-adoption-agents/" rel="noopener noreferrer"&gt;Your MCP Tool Works. The Model Still Won't Call It.&lt;/a&gt; - The measured gap between a connected tool and a called one.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;Claude Code Hooks and Deterministic Agent Behavior&lt;/a&gt; - The mechanism behind deterministic injection: events, exit codes, and a working pipeline.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://swapnanilsaha.com/blog/agent-memory-harness-property/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>agents</category>
      <category>programming</category>
    </item>
    <item>
      <title>Your MCP Tool Works. The Model Still Won't Call It.</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:36:06 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/your-mcp-tool-works-the-model-still-wont-call-it-56df</link>
      <guid>https://dev.to/swapnanilsaha/your-mcp-tool-works-the-model-still-wont-call-it-56df</guid>
      <description>&lt;p&gt;I built a tool I was sure agents would use. It solved a real, felt pain — an AI coding agent losing everything it learned the moment its context filled up — and it was one clean install away from any editor that speaks the protocol. Then I ran the honest test: I connected it, gave an agent a real task, and counted the calls. The count was zero.&lt;/p&gt;

&lt;p&gt;Not zero because the tool was broken. Every function was reachable, every response correct, the connection verified in the session's own startup log. Zero because the model, turn after turn, simply chose to do something else. That gap, between a tool that works and a tool that actually gets &lt;em&gt;used&lt;/em&gt;, is the most under-measured number in the whole agent stack, and it's what this piece is about.&lt;/p&gt;

&lt;p&gt;I'm going to show you the measurements: the run where an agent ignored a tool it was told about thirty times, the control where the answers were already sitting in the store and it never looked, and the counter-evidence where the exact same tool suddenly got used the instant the harness stopped asking and started &lt;em&gt;delivering&lt;/em&gt;. Then I'll explain the mechanism, and give you a ladder you can climb from "polite invitation" to "the model has no choice."&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 1 — The Unmeasured Multiplier
&lt;/h1&gt;

&lt;h2&gt;
  
  
  1. The Benchmark Nobody Runs
&lt;/h2&gt;

&lt;p&gt;Every tool eval I've ever seen — mine included, for a while — measures the same thing: how good the tool is when it runs. Retrieval precision. Latency. Correctness on a held-out set. You tune, you publish a number, you feel good. And then that number gets multiplied by a second number nobody put on the slide: the fraction of the time the model actually decides to call the thing.&lt;/p&gt;

&lt;p&gt;Write it out and the problem is obvious.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The value equation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Shipped value = retrieval quality × &lt;strong&gt;adoption&lt;/strong&gt; × good use.&lt;/p&gt;

&lt;p&gt;A tool that is 10× better at retrieval, multiplied by zero calls, ships zero value. Every benchmark you publish about your tool measures the first factor. The model decides the second. And — this is the part that took me a full quarter to accept — the &lt;em&gt;default&lt;/em&gt; matters more than the quality.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Here's the trap. The first factor, retrieval quality, is where all the fun engineering lives: embeddings, rerankers, chunking, graph traversal. It's legible, it benchmarks cleanly, it feels like progress. The second factor is a behavioral property of a model you don't control, in a harness you don't own, and it doesn't show up in any dashboard unless you go looking. So nobody looks. They ship the quality improvement and quietly assume adoption is 100%.&lt;/p&gt;

&lt;p&gt;It is not 100%. In the runs I'm about to walk through, it was closer to 0%. And you can only find that out one way — by measuring it, on purpose, before you spend three months polishing a factor that gets multiplied by nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Headline Zero
&lt;/h2&gt;

&lt;p&gt;The instrument for all of this is a tool I wrote called &lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt; — a working-memory and code-search server that speaks MCP, the Model Context Protocol. I evaluated it in a controlled, multi-arm study on a naturalistic coding task, with the full protocol and every per-run artifact archived publicly so anyone can audit them. I care about the tool, but the number below is not about the tool. It's about what agents do.&lt;/p&gt;

&lt;p&gt;One arm ran a &lt;strong&gt;63-turn&lt;/strong&gt; task with the MCP server connected and verified — all &lt;strong&gt;10 tools&lt;/strong&gt; visible in the session's initialization — and the workspace instruction file carrying &lt;strong&gt;30 mentions&lt;/strong&gt; of the tools and exactly when to use them. Thirty. Not a passing "you have some tools"; a document that repeatedly, specifically, named the tool and the moment to reach for it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The result&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent made &lt;strong&gt;0 tool calls to the server. Zero, in 63 turns.&lt;/strong&gt; A connected, correct, heavily-advertised tool, and not one call across an entire task.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sit with how strange that is. If you had asked me to predict the number before the run, I'd have guessed something modest — maybe the agent leans on it a handful of times, maybe it forgets. I would not have guessed zero. Zero is not "under-used." Zero is the model treating a fully-wired capability as though it were not there.&lt;/p&gt;

&lt;p&gt;A &lt;em&gt;turn&lt;/em&gt;, here, is one cycle of the agent's loop — think, act, observe. Sixty-three of them went by. The agent read files, ran shell commands, grepped, wrote code. It reached for its defaults every single time. The one thing it never did was call the tool that had been connected specifically to help it, and described to it thirty separate times.&lt;/p&gt;

&lt;p&gt;Before you reach for the usual explanation: this wasn't tool overload. The well-worn advice — "you've connected too many MCP servers, the model is drowning in tool schemas" — is real, but it doesn't fit here. This was one server, ten tools, cleanly connected, with nothing else competing for the model's attention. A single tidy toolset, and still nothing.&lt;/p&gt;

&lt;p&gt;My first instinct was that I'd broken something. I re-checked the connection. Fine. I re-read the instructions. If anything they were too pushy. The tool wasn't the problem, and neither was the guidance. Something more structural was going on, and the only way to find it was to stop guessing and run the controls.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 2 — It Isn't a Guidance Problem
&lt;/h1&gt;

&lt;h2&gt;
  
  
  3. More Mentions, Same Zero
&lt;/h2&gt;

&lt;p&gt;The obvious hypothesis after a zero is "the instructions weren't strong enough." So the natural fix is to turn up the guidance. I want to kill that hypothesis early, because it's where most teams sink their next month.&lt;/p&gt;

&lt;p&gt;Across all five unseeded, tool-equipped runs of the matrix, voluntary memory writes came in at &lt;strong&gt;0–1 per run&lt;/strong&gt;. That's the whole distribution: some runs wrote nothing, the best wrote a single note. This was against &lt;strong&gt;32 memory-guidance mentions&lt;/strong&gt; spread across the workspace instruction files and a tool surface that was verified connected in every run. More words did not buy more calls.&lt;/p&gt;

&lt;p&gt;And here's the detail that rules out the tidy "the agent is biased against third-party MCP tools" explanation. The harness has its own &lt;em&gt;native&lt;/em&gt; memory feature — a built-in place the model is trained to write notes to itself, no MCP involved. In these runs, that native auto-memory directory &lt;strong&gt;was never created in any run either&lt;/strong&gt;. The model didn't skip my tool in favor of the built-in one. It skipped voluntary memory &lt;em&gt;of any kind&lt;/em&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The misdiagnosis that costs you a month&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Usage is low, so my instructions must be too weak" leads straight to writing more instructions. But the failure isn't dose. Zero native-memory writes alongside zero MCP writes tells you the model isn't rejecting your tool specifically — it's declining a whole &lt;em&gt;category&lt;/em&gt; of voluntary behavior. No amount of louder prose changes the category.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the moment to separate two things that feel identical from the outside. There is the quality of your tool, and there is the model's standing disposition to reach for a non-default action unprompted. Guidance density pushes on the first — it makes the tool sound better, more relevant, more clearly the right choice. It does almost nothing to the second, because the second isn't a belief the model holds about your tool. It's a groove worn into the model by training. You can't argue a groove flat.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The Answers Were Already Inside
&lt;/h2&gt;

&lt;p&gt;If louder guidance can't do it, maybe the problem is that the store starts empty — an agent won't consult a memory it has no reason to think contains anything. Fair. So I ran the control that removes that excuse entirely.&lt;/p&gt;

&lt;p&gt;In the seeded-voluntary arm, the store was &lt;strong&gt;pre-seeded with four task-relevant notes&lt;/strong&gt; — not filler, but real gotchas about the exact task the agent was about to attempt. The kind of thing that, if the agent read it, would save it a wrong turn. Same connected tools. Same guidance. The only change: the answers were already sitting inside, waiting to be recalled.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The strongest control&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The agent made &lt;strong&gt;zero memory calls in 114 turns.&lt;/strong&gt; One hundred and fourteen turns, four correct answers to its own problems within reach, and it never once looked.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the result that changed how I think about memory tools, so let me be precise about what it does and doesn't show. It does not show the notes were useless — they were exactly on point. It shows that &lt;em&gt;relevance in the store is invisible to a model that never queries the store&lt;/em&gt;. Knowledge that isn't delivered might as well not exist. A perfectly-stocked library with a door the reader never opens is, functionally, an empty room.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The unopened fridge&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Picture a housemate who orders takeout every night while the fridge is full of food you cooked and labeled for them. You could stock it better. You could tape a bigger note to the door. None of it works, because the failure isn't the contents of the fridge — it's that opening the fridge was never part of their routine. The only thing that ever works is putting a plate in front of them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Storage without delivery is dead weight. That sentence sounds like a slogan, but it's the literal reading of a 114-turn run. If you're building a memory tier for agents, this is the load-bearing fact of your entire product: the value was never in holding the knowledge. It's in getting it in front of the model at the moment the model needs it — a moment the model, left to its own devices, will not reach for.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 3 — Why the Model Won't Call You
&lt;/h1&gt;

&lt;h2&gt;
  
  
  5. Training Priors and the Agency Tax
&lt;/h2&gt;

&lt;p&gt;So why does a capable model, told thirty times, with the answers in hand, still not call? Two forces stack up, and once you see them the zeros stop being surprising.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;training priors&lt;/strong&gt;. Agent models are trained, and then system-prompted, toward a specific default toolset: read the file, grep the repo, run the command. They're also trained to treat &lt;em&gt;disk files&lt;/em&gt; as the canonical place memory lives. These aren't neutral capabilities the model weighs freshly each turn; they're the well-worn path. When the model needs to know something, "read a file" is the reflex, and reflexes fire before deliberation.&lt;/p&gt;

&lt;p&gt;The second is what I've started calling the &lt;strong&gt;agency tax&lt;/strong&gt;. Look at how the things that &lt;em&gt;do&lt;/em&gt; reliably reach the model actually get there. A &lt;code&gt;CLAUDE.md&lt;/code&gt; instruction file isn't something the model chooses to consult — the harness reads it and pastes it into context at startup, with zero model agency. The native memory index is loaded the same way. They "work" not because they're persuasive but because a mechanism outside the model puts them in front of it unconditionally.&lt;/p&gt;

&lt;p&gt;A third-party MCP tool has none of that. To benefit from it, the model has to perform a fresh act of choice — notice the tool is relevant, decide to call it, form the arguments — and it has to do this at exactly the moments it's most loaded: mid-task, context filling, juggling the actual work. That's the tax. Every voluntary call costs a scarce unit of deliberate agency, spent against the pull of a reflex that's free.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The tax you pay in the moment you can least afford it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's the gym membership you have but don't use. Not because you doubt exercise works — because using it demands a deliberate act at the exact moment your day is fullest. Meanwhile the couch requires no decision at all. Injected context is the trainer who shows up at your door and hands you your shoes. Same activity, but the choice has been made for you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Put the two together and the zeros are almost overdetermined. The model's cheapest path is its trained default. Your tool sits behind a deliberate choice it must make while busy. Guidance tries to tilt that choice, but it's tilting against a slope that training carved and the harness reinforces for free on every other surface. No wonder "prefer these tools" reads, to the model, as a suggestion it's welcome to skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. What the Docs Actually Say
&lt;/h2&gt;

&lt;p&gt;You don't have to take my framing on faith — the harness vendors describe this split themselves, in their own documentation, in plain language. I pulled these quotes from the current Claude Code docs while writing this.&lt;/p&gt;

&lt;p&gt;On what an instruction file even is, the memory documentation is blunt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;From the Claude Code memory docs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Both are loaded at the start of every conversation. Claude treats them as context, not enforced configuration. To block an action regardless of what Claude decides, use a PreToolUse hook instead."&lt;/p&gt;

&lt;p&gt;And, on why compliance varies: "CLAUDE.md content is delivered as a user message after the system prompt, not as part of the system prompt itself. Claude reads it and tries to follow it, but there's no guarantee of strict compliance, especially for vague or conflicting instructions."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read that carefully. The canonical mechanism for guiding an agent is, by the vendor's own account, &lt;em&gt;context, not enforced configuration&lt;/em&gt;, with &lt;em&gt;no guarantee&lt;/em&gt;. That's not a bug to be fixed with better wording. It's the design. Instruction files shape behavior; they don't compel it.&lt;/p&gt;

&lt;p&gt;And when the docs describe the thing that &lt;em&gt;does&lt;/em&gt; compel, notice the contrast in verbs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;From the Claude Code hooks docs&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Hooks are user-defined shell commands that execute at specific points in Claude Code's lifecycle. They provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Ensuring certain actions always happen rather than relying on the LLM to choose." That single clause is the entire thesis of this piece, written by the people who build the harness. There's a category of thing the model is invited to do, and a category the harness guarantees. Your MCP tool, by default, lives in the first category. The features that never fail to reach the model all live in the second.&lt;/p&gt;

&lt;p&gt;So this was never a marketing problem. You can't wordsmith your way from "invited" to "guaranteed." It's an &lt;strong&gt;architecture&lt;/strong&gt; problem, and it reduces to one line I'd tattoo on every MCP builder: anything that depends on the model choosing to call you loses to anything the harness delivers unconditionally.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Version caveat, stated once&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The measured runs used Claude-family agent models from mid-2026 in an agentic CLI harness. Adoption behavior is model- and version-specific, and training toward tool use is a moving target — a future model could reach for tools more readily. The mechanism (invitation vs. delivery) is structural; the exact zeros are a snapshot.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h1&gt;
  
  
  Part 4 — What Actually Moves Adoption
&lt;/h1&gt;

&lt;h2&gt;
  
  
  7. The Adoption Ladder
&lt;/h2&gt;

&lt;p&gt;Here's the useful part. Adoption isn't binary — there's a ladder from weakest to strongest, and I've operated all four rungs on this same tool. Each one buys you more reliability and costs you more integration. The mistake is starting at the top and never climbing; the other mistake is thinking rung one is a real rung.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 1 — Polite guidance.&lt;/strong&gt; "Prefer these tools." This is the 30-mentions run. Measured effect: roughly zero. It is the most common thing MCP builders ship and the least effective. If your entire adoption strategy is prose in an instruction file, you are, empirically, shipping nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 2 — Prescriptive prohibition.&lt;/strong&gt; Not "prefer X" but "use X for &lt;em&gt;all&lt;/em&gt; code exploration; do &lt;em&gt;not&lt;/em&gt; use file reads or grep for browsing." I found this early in the project: "prefer" is ignored, while "do not use the default" is what registers. Forbidding the reflex works better than praising the alternative, because it attacks the prior directly. But it's partial and fragile — it holds best in directly-driven prompts, and it re-litigates the fight against training priors on every single turn. Lean on it and you'll get compliance that quietly erodes as the task gets long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 3 — Response-packing.&lt;/strong&gt; Make every tool response carry what the caller needs &lt;em&gt;next&lt;/em&gt;. A search result that names the exact id to fetch for more. A write confirmation that shows, inline, how to recall what you just stored. It's deterministic and additive — you're not persuading the model, you're paving the road so the next call is the path of least resistance. This lowers the per-call agency tax meaningfully. Its limit: it can only help &lt;em&gt;after&lt;/em&gt; the first call. It makes the second step cheap; it doesn't make the first step happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rung 4 — Harness injection.&lt;/strong&gt; Remove the choice. Don't ask the model to call you — have the harness deliver your content as context the model can't skip. This is the only rung that beats the priors, because it stops playing their game. It's also the rung that depends on your harness cooperating: Claude Code exposes hook surfaces you can inject through, but not every MCP host does, which is exactly why part of the fix belongs upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Injection Begets Engagement
&lt;/h2&gt;

&lt;p&gt;The best evidence that delivery is the lever isn't the zeros. It's what happened when I stopped inviting and started delivering. Same tool, same task family. The only change: the harness injected the relevant notes itself, at session start and per prompt, instead of hoping the model would go get them.&lt;/p&gt;

&lt;p&gt;And the agents didn't just passively receive. They came alive.&lt;/p&gt;

&lt;p&gt;In one run, the agent's &lt;strong&gt;first three tool calls were recalls of exactly the three injected note ids&lt;/strong&gt; — ids it could not have known from anywhere except the injection that had just landed seconds earlier. It saw the delivered context, and immediately pulled the threads. Another run issued a semantic recall about &lt;strong&gt;17 seconds in&lt;/strong&gt;. A third produced a full memory-hygiene loop on its own: recall a note by id, &lt;em&gt;forget&lt;/em&gt; a gotcha that its own change had just made obsolete, then &lt;em&gt;remember&lt;/em&gt; a completion note for the next session.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The clincher&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In a separate long-horizon probe, the injection-equipped agent voluntarily issued &lt;strong&gt;11 remember calls and 9 recalls&lt;/strong&gt; mid-run — against &lt;strong&gt;zero voluntary operations&lt;/strong&gt; in the matched voluntary-only control. Same tool, same task. The difference was that one of them had been handed a plate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read the sequence carefully, because it inverts the intuition most people carry. You'd expect injection and voluntary use to be substitutes — if the harness is force-feeding context, why would the model bother calling the tool itself? The opposite happened. Delivery didn't replace engagement; it &lt;em&gt;triggered&lt;/em&gt; it. Once the model had a delivered note in front of it, calling the tool became the obvious next move, and it kept calling. The plate on the table is what got the housemate to finally open the fridge for seconds.&lt;/p&gt;

&lt;p&gt;This is worth stating flatly because it's the hopeful part: &lt;strong&gt;delivery is not a substitute for engagement; it is the cause of it.&lt;/strong&gt; You are not choosing between "force it" and "let the model use it naturally." Forcing the first contact is how you unlock the natural use.&lt;/p&gt;

&lt;p&gt;One more thing injection has to earn: it has to be safe. Content the model can't skip is exactly as dangerous as it is powerful — a mis-fired injection is noise the model can't ignore. So the trigger logic was audited. Across the gated runs, there were &lt;strong&gt;zero false-alarm injections across 40 and 35 audit-logged trigger evaluations&lt;/strong&gt;, and the file-anchored gotcha fired on the &lt;strong&gt;first touch of its anchored file in both runs&lt;/strong&gt;. Delivery only earns the right to be unconditional if it's also disciplined.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Match Delivery to the Stakes
&lt;/h2&gt;

&lt;p&gt;"Just inject everything" is the wrong lesson. Injected context is a finite, expensive resource — every token you deliver unconditionally is a token the model can't spend on the task. The craft is matching the &lt;em&gt;delivery mechanism&lt;/em&gt; to the &lt;em&gt;stakes&lt;/em&gt; of the content. In a Claude Code-style harness, the hooks give you three natural tiers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Content type&lt;/th&gt;
&lt;th&gt;Stakes&lt;/th&gt;
&lt;th&gt;Delivery mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Never-deviate directive&lt;/td&gt;
&lt;td&gt;A miss is unacceptable — "never push to main," "never delete prod data"&lt;/td&gt;
&lt;td&gt;Unconditional injection at every &lt;strong&gt;SessionStart&lt;/strong&gt;. No similarity threshold — a semantic near-miss on a hard rule is a catastrophe.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;File-scoped gotcha&lt;/td&gt;
&lt;td&gt;Only matters when a specific file is in play&lt;/td&gt;
&lt;td&gt;Trigger on file touch via &lt;strong&gt;PreToolUse&lt;/strong&gt;. Fires exactly when the anchored file is opened, and stays silent otherwise.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Episodic finding&lt;/td&gt;
&lt;td&gt;Useful if relevant to this turn, harmless to skip&lt;/td&gt;
&lt;td&gt;Ranked semantic recall per prompt via &lt;strong&gt;UserPromptSubmit&lt;/strong&gt; — use the user's own prompt as the query, inject the top matches.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mechanism has to fit the failure mode. A never-deviate rule cannot ride on semantic similarity, because the one time the retriever scores "never push to main" just below the cutoff is the one time it mattered most. That rule belongs in unconditional injection, full stop. An episodic finding, on the other hand, would be wasteful to inject every turn — ranked recall keyed to the prompt is exactly right, because a miss just means the model does what it would have done anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why "let the model search its memory" fails&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One flat tool that says "the model can search its memory when it wants" collapses all three tiers into a single voluntary call — and then delivers none of them reliably. Standing rules become skippable. File-scoped caveats fire only if the model happens to query at the right moment. Episodic recall competes with the reflex to grep. The conflation is the bug. Three stakes levels need three delivery mechanisms.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the design principle the whole study points at: &lt;strong&gt;match the delivery mechanism to the stakes.&lt;/strong&gt; It's more work than shipping one tool and a paragraph of guidance. It's also the difference between a memory layer that exists and one that fires.&lt;/p&gt;




&lt;h1&gt;
  
  
  Part 5 — For People Shipping MCP Servers
&lt;/h1&gt;

&lt;h2&gt;
  
  
  10. What To Do On Monday
&lt;/h2&gt;

&lt;p&gt;If you have an MCP server in the wild or about to be, here's the short list I wish someone had handed me before I spent a quarter tuning a factor that was being multiplied by zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure adoption before quality.&lt;/strong&gt; Connect your server, give the agent a real task (a naturalistic one, not "please use the tool"), and count your tool's calls. That's the whole test. The number may be zero. Far better to learn that in an afternoon than after a quarter of retrieval tuning that never reaches a user. This is the single highest-leverage hour you can spend on your tool, and almost nobody spends it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't ship guidance prose; ship defaults.&lt;/strong&gt; Prose in an instruction file is rung one, and rung one is empirically ~zero. If the harness exposes hooks or any injection surface, integrate there. Context beats invitation, every time, by construction. Your README's tone doesn't matter; your delivery mechanism does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design responses for the next call, not for completeness.&lt;/strong&gt; Every response your tool returns should carry the next affordance — the id to fetch, the command to recall, the follow-up that's probably coming. You're not writing documentation, you're paving a road. Completeness is for humans reading logs; the model needs the next step made cheap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your tool is a memory tier, delivery is the product.&lt;/strong&gt; The 114-turn control settles this. Storing knowledge is table stakes and, on its own, worth nothing to an agent. The product is getting the right note in front of the model at the right moment — which means your real engineering surface is triggers and injection, not storage and indexing. I develop this argument in full in &lt;a href="https://github.com/swapnanil/vectr/blob/main/research/brain-memory/delivery-not-storage.md" rel="noopener noreferrer"&gt;the full write-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push for first-class harness support for triggered delivery.&lt;/strong&gt; Right now, injection lives in hooks you wire up yourself. It should be a first-class harness feature — memory files with rules-style &lt;code&gt;paths:&lt;/code&gt; and &lt;code&gt;events:&lt;/code&gt; frontmatter, default-off, so the harness delivers them on the right trigger without every builder reinventing it. That's the substance of an upstream feature request I filed, &lt;a href="https://github.com/anthropics/claude-code/issues/78795" rel="noopener noreferrer"&gt;claude-code#78795&lt;/a&gt;, with a related field report in &lt;a href="https://github.com/anthropics/claude-code/issues/34556" rel="noopener noreferrer"&gt;claude-code#34556&lt;/a&gt;. If this problem bites you too, that's the thread to pile onto.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The honest caveats&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sample sizes here are small — a controlled matrix over one task family. The claim is &lt;em&gt;existence and mechanism&lt;/em&gt; ("adoption can be ~zero even with maximal affordance, and injection flips it"), not a universal adoption rate you can plug into a forecast. Behavior is model- and version-specific. And I'm the author of the tool under test, so the runs are self-run — which is exactly why the protocol, per-run artifacts, and graders are &lt;a href="https://github.com/swapnanil/vectr/tree/main/research/proactive-gate" rel="noopener noreferrer"&gt;public&lt;/a&gt;. Don't trust the story; audit the runs.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Multiplier, Again
&lt;/h2&gt;

&lt;p&gt;I opened with a tool that got zero calls and a value equation with a middle term nobody measures. Those are the same story. The retrieval quality I'd been polishing was real; it just never got the chance to matter, because the factor next to it — adoption — was sitting near zero and quietly zeroing the product.&lt;/p&gt;

&lt;p&gt;The fix wasn't a better tool or a louder pitch. It was accepting that a model reaching for its trained reflexes will not, on its own, climb over the agency tax to call you — not with thirty mentions, not with the answers already in the store. The things that reliably reach a model reach it because a mechanism outside the model puts them there. So you become that mechanism. You stop inviting and start delivering, and you match how forcefully you deliver to how much a miss would cost.&lt;/p&gt;

&lt;p&gt;The next time you're about to publish a benchmark for your tool, run the other one first. Connect it, hand an agent a real task, and count. If the number is zero, no amount of quality was ever going to save you — and now you know precisely which factor to go fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Notes &amp;amp; Sources
&lt;/h2&gt;

&lt;p&gt;Every number in this piece comes from the controlled runs, whose protocol and per-run artifacts are public. The vendor quotes were pulled from the live Claude Code documentation while writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The runs &amp;amp; the argument&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr/tree/main/research/proactive-gate" rel="noopener noreferrer"&gt;Proactive-gate run archive&lt;/a&gt; — protocol, per-run artifacts, and graders for every number quoted here.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr/blob/main/research/brain-memory/delivery-not-storage.md" rel="noopener noreferrer"&gt;Delivery, not storage&lt;/a&gt; — the full write-up developing the memory-tier argument.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt; — the MCP working-memory + code-search server used as the instrument. Local, MCP, no API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Harness documentation (verified live)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;Claude Code — memory / CLAUDE.md&lt;/a&gt; — source of "context, not enforced configuration" and the compliance caveat.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Claude Code — hooks&lt;/a&gt; — source of "deterministic control … rather than relying on the LLM to choose."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Upstream&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/78795" rel="noopener noreferrer"&gt;claude-code#78795&lt;/a&gt; — feature request: triggered injection for auto-memory topic files (&lt;code&gt;paths:&lt;/code&gt;/&lt;code&gt;events:&lt;/code&gt; frontmatter, default-off).&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/anthropics/claude-code/issues/34556" rel="noopener noreferrer"&gt;claude-code#34556&lt;/a&gt; — related field report.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Written by &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;Swapnanil Saha&lt;/a&gt;. Originally published at &lt;a href="https://swapnanilsaha.com/blog/mcp-tool-adoption-agents/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>aiagents</category>
      <category>tooladoption</category>
      <category>agentmemory</category>
    </item>
    <item>
      <title>Embedding Dilution: Why Semantic Code Search Misses the Answer</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:12:46 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/embedding-dilution-why-semantic-code-search-misses-the-answer-5b9i</link>
      <guid>https://dev.to/swapnanilsaha/embedding-dilution-why-semantic-code-search-misses-the-answer-5b9i</guid>
      <description>&lt;p&gt;I had a query that should have been boring. "Get a single object from the database." In Django, that is &lt;code&gt;QuerySet.get&lt;/code&gt; — the method whose entire job is to fetch exactly one row matching your lookup, or raise. Its docstring reads, almost verbatim: &lt;em&gt;"Perform the query and return a single object matching the given keyword arguments."&lt;/em&gt; That is not a loose match to my query. It is nearly a paraphrase of it.&lt;/p&gt;

&lt;p&gt;The chunk was indexed. I checked. The embedding was computed and stored like every other chunk in the corpus. And when I ran the search, the method was not in the top result, not at rank fifty — it was not in the top two hundred candidates at all. It never made it far enough into the pipeline to be judged. Two hundred other chunks, none of which described getting a single object from the database, beat it into the pool.&lt;/p&gt;

&lt;p&gt;That failure bothered me enough to take apart the whole retrieval path and measure where the answer died. This post is the post-mortem. The system under test is &lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, a semantic code-search and working-memory tool I build; the corpus is Django, used purely as a public witness. But the mechanism I found is not specific to code, and it is not specific to my tool. It is a property of how a single embedding vector has to summarize a long, mixed passage — and it quietly limits recall in a lot of retrieval systems that look like they are working. If you have never read the embeddings foundations, my earlier &lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;complete guide to text embeddings and RAG&lt;/a&gt; is the primer; this is the failure that guide's happy path hides.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on terms.&lt;/strong&gt; An &lt;em&gt;embedding&lt;/em&gt; is a list of numbers (a vector) that captures a passage's meaning, so passages with similar meaning get similar numbers. A &lt;em&gt;chunk&lt;/em&gt; is one indexed piece of the codebase — roughly one method plus a little context. A &lt;em&gt;docstring&lt;/em&gt; is the documentation written inside a function. &lt;em&gt;Cosine similarity&lt;/em&gt; measures the angle between two vectors: 1.0 is identical direction, 0 is unrelated.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1 · The Miss
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Pipeline and the Query That Broke It
&lt;/h3&gt;

&lt;p&gt;Before the failure makes sense, you need the shape of the pipeline it happened in, because the shape is where the whole story turns. At measurement time, vectr retrieved in the way most production semantic-search systems do — a two-stage funnel.&lt;/p&gt;

&lt;p&gt;Stage one is &lt;strong&gt;hybrid retrieval&lt;/strong&gt;. It runs two searches in parallel and merges them. One leg is &lt;strong&gt;dense retrieval&lt;/strong&gt;: encode the query into a vector, encode every chunk into a vector, and rank chunks by cosine similarity — so it can match on meaning even with no shared words. The other leg is &lt;strong&gt;BM25&lt;/strong&gt;, a keyword-scoring function that rewards exact term overlap. Together they produce a &lt;strong&gt;candidate pool&lt;/strong&gt; of the top 200 chunks.&lt;/p&gt;

&lt;p&gt;Stage two reranks that pool. A &lt;strong&gt;cross-encoder reranker&lt;/strong&gt; — &lt;code&gt;bge-reranker-base&lt;/code&gt; — reads the query alongside each of the 200 pool members and re-scores them properly, followed by a quality pass. That reranker is the smart part of the system. It is also the expensive part, which is exactly why it only ever sees 200 candidates instead of all 40,538.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one number that decides everything.&lt;/strong&gt; The reranker, the importance priors, the quality scores — every clever thing downstream operates &lt;em&gt;only on the 200 chunks in the pool&lt;/em&gt;. A chunk that is not in the pool is invisible to all of it. So the first question for any retrieval miss is never "why did the reranker score it low." It is "was it even in the pool to be scored." Recall gates everything after it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the pieces that matter for the failure. The dense embedder was &lt;strong&gt;snowflake-arctic-embed-m-v1.5&lt;/strong&gt; — a general text embedder, not a code-specialized one. Hold onto that; it is not the villain, but it shapes the numbers. The corpus was a Django checkout from June 2026: &lt;strong&gt;4,129 files, 40,538 indexed chunks&lt;/strong&gt;. And the chunk for &lt;code&gt;QuerySet.get&lt;/code&gt; was, by any reasonable standard, ideal. It carried a class marker (&lt;code&gt;QuerySet&lt;/code&gt;), the full method signature, and that near-perfect docstring — followed by roughly thirty lines of mechanical implementation.&lt;/p&gt;

&lt;p&gt;That last clause is the whole problem in embryo. But to see why, we first have to pin down &lt;em&gt;where&lt;/em&gt; the miss happened, because the fix depends entirely on that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Miss Is at Pool Entry, Not Ranking
&lt;/h3&gt;

&lt;p&gt;My first instinct was the wrong one, and it is probably yours too: the reranker must have mis-scored it. Bump the reranker, add an importance prior for well-known symbols, tune the quality pass. Every one of those fixes operates on the pool. So I checked the pool directly, leg by leg, and the reranker turned out to be innocent — it never got the chance to be guilty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dense leg.&lt;/strong&gt; For every natural-language phrasing I tried — "get a single object from the database," "fetch one row matching criteria," "retrieve a single record by lookup" — &lt;code&gt;QuerySet.get&lt;/code&gt; was &lt;strong&gt;absent from the top 200 dense results&lt;/strong&gt;. Not low-ranked. Absent. The only phrasing that got it into the dense pool at all was a deliberately ORM-flavored control, written in Django's own vocabulary, and even that reached only &lt;strong&gt;#123 of 200&lt;/strong&gt; — barely inside a pool it should have topped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keyword leg.&lt;/strong&gt; BM25 was all over the place, which is its nature: it lives and dies on exact term overlap. Phrase the query as "return exactly one matching object or raise…" — words that literally appear near the method — and BM25 ranked it &lt;strong&gt;#1&lt;/strong&gt;. Phrase it as "fetch one row matching criteria" and the same method fell to &lt;strong&gt;#127&lt;/strong&gt;. Other phrasings missed entirely. BM25 wasn't a safety net; it was a coin whose bias depended on whether I happened to echo the source text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where the answer actually died.&lt;/strong&gt; The reranker never saw &lt;code&gt;QuerySet.get&lt;/code&gt; for the natural-language queries, because &lt;code&gt;QuerySet.get&lt;/code&gt; was never in the 200 it was handed. This is the structural point the rest of the post builds on: &lt;strong&gt;if the right chunk never enters the pool, nothing downstream can save it.&lt;/strong&gt; A brilliant reranker on an incomplete pool is a brilliant answer to the wrong question.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is also the moment most retrieval dashboards lie to you by omission. They display the reranked top-k — the final, polished output — which looks fine because the reranker did a competent job on the pool it received. The miss is one layer up, invisible on that screen. You have to instrument pool entry itself to see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace the chunk's fate through the funnel&lt;/strong&gt; (three real scenarios from this run):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phrasing&lt;/th&gt;
&lt;th&gt;Dense top-200&lt;/th&gt;
&lt;th&gt;BM25 top-200&lt;/th&gt;
&lt;th&gt;Fused pool&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A · "get a single object from the database"&lt;/td&gt;
&lt;td&gt;absent (&amp;gt;200)&lt;/td&gt;
&lt;td&gt;missed&lt;/td&gt;
&lt;td&gt;not in pool&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;miss&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B · ORM-vocabulary control&lt;/td&gt;
&lt;td&gt;#123 of 200&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;enters (weak)&lt;/td&gt;
&lt;td&gt;reaches reranker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C · "return exactly one matching object or raise…"&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;dropped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;miss&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scenario C is the case where BM25 ranked the target first, yet the dense-dominated fusion dropped it before the returned top-60 — more on that in Part 4. A dash means that leg's rank was not separately recorded for that phrasing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2 · The Cause
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dilution, Measured
&lt;/h3&gt;

&lt;p&gt;So the dense leg failed to rank a chunk whose docstring paraphrases the query. Why? The lazy answer is "the embedder isn't good enough, throw a bigger model at it." That answer is wrong, and I can show it is wrong with one micro-experiment.&lt;/p&gt;

&lt;p&gt;I took the exact same embedder and embedded two things. First, the full chunk: class marker, signature, perfect docstring, plus the ~30 lines of body — the &lt;code&gt;combinator&lt;/code&gt; handling, the &lt;code&gt;_chain()&lt;/code&gt; call, the &lt;code&gt;select_for_update&lt;/code&gt; checks, the &lt;code&gt;NotSupportedError&lt;/code&gt; raises. Second, just the &lt;strong&gt;signature and docstring alone&lt;/strong&gt; — call it the "purpose-only" version, the part that says &lt;em&gt;what this is for&lt;/em&gt; with none of the machinery that says &lt;em&gt;how it does it&lt;/em&gt;. Then I measured cosine similarity from each version to four query phrasings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Full chunk&lt;/th&gt;
&lt;th&gt;Purpose-only&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;get a single object from the database&lt;/td&gt;
&lt;td&gt;0.601&lt;/td&gt;
&lt;td&gt;0.706&lt;/td&gt;
&lt;td&gt;+0.105&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fetch one row matching criteria&lt;/td&gt;
&lt;td&gt;0.511&lt;/td&gt;
&lt;td&gt;0.606&lt;/td&gt;
&lt;td&gt;+0.095&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retrieve a single record by lookup&lt;/td&gt;
&lt;td&gt;0.529&lt;/td&gt;
&lt;td&gt;0.625&lt;/td&gt;
&lt;td&gt;+0.096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;return exactly one matching object or raise…&lt;/td&gt;
&lt;td&gt;0.617&lt;/td&gt;
&lt;td&gt;0.678&lt;/td&gt;
&lt;td&gt;+0.061&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Purpose-only is closer to the query on every single phrasing, by &lt;strong&gt;+0.06 to +0.10 cosine&lt;/strong&gt;. Same embedder, same docstring, same query. The only thing I removed was the implementation body — and the chunk got measurably &lt;em&gt;more&lt;/em&gt; relevant to the thing it does. The signal that answers the query was in the chunk the whole time. The body was burying it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why a longer chunk drifts away from its own purpose.&lt;/strong&gt; An encoder turns a passage into one fixed-size vector. Cosine similarity then compares directions:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cos(q, d) = (q · d) / (‖q‖ · ‖d‖)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catch is &lt;em&gt;d&lt;/em&gt;. Whether the model builds it by literally averaging its token vectors (mean pooling) or by a summary token that attends across all tokens, the result is one point that must stand in for the whole passage. For the literal mean-pooling case it is just an average:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;d ≈ (1/N) · Σ eᵢ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A CLS- or attention-pooled encoder weights the tokens unevenly instead of averaging them flat, but the consequence is the same. Add thirty lines of body and you pour in dozens of token vectors pointing toward "loop, chain, raise, check." They pull &lt;em&gt;d&lt;/em&gt; toward the body's center of mass and away from the docstring's direction. The docstring's contribution does not vanish — it gets outvoted. That is dilution: not a missing signal, a &lt;strong&gt;drowned&lt;/strong&gt; one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the calibration that turns this from a curiosity into a recall failure. In this embedding space, for the "get a single object" query, the weakest chunk that made it into the 200-deep pool sat at a cosine of about &lt;strong&gt;0.697&lt;/strong&gt;. Purpose-only scored &lt;strong&gt;0.706&lt;/strong&gt; — over the line, into the pool, in front of the reranker. The full chunk scored &lt;strong&gt;0.601&lt;/strong&gt; — under the line, out of the pool, invisible. The entire difference between "the reranker gets a shot at the right answer" and "the right answer is never considered" is that &lt;strong&gt;+0.10 of cosine the body ate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A caveat on that floor: 0.697 was calibrated on the first query only, and the pool floor is a per-query quantity — treat it as a reference for that query, not a universal threshold. The general lesson is the recovered delta, which is positive for all four.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The instinct this kills: "just enrich the input."&lt;/strong&gt; The reflex when recall is bad is to feed the embedder more context — add the class body, the surrounding file, richer metadata. Here that makes it worse. The purpose signal is &lt;em&gt;already present&lt;/em&gt;; enriching the chunk only adds more body tokens to average against it. You cannot fix a drowning by adding water. The problem is the pooling of a long, mixed chunk into one vector — so the fix has to change what gets pooled, not what gets added.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Phrasing Doesn't Rescue It — and the Symbol Index Proves Why
&lt;/h3&gt;

&lt;p&gt;There is an obvious objection here: maybe I just phrased the queries badly. Maybe the right words would have pulled the chunk in. So I ran a &lt;strong&gt;60-query sweep&lt;/strong&gt; — 10 topics, 6 phrasings each — to give phrasing every chance to matter.&lt;/p&gt;

&lt;p&gt;It didn't. Rephrasing shuffled which wrong answers came back; it did not surface the right ones. Ask for a "signal dispatcher implementation" and the top results were &lt;strong&gt;1-to-6-line re-export stubs&lt;/strong&gt; — the little shim modules that just re-expose a name — while the real &lt;code&gt;Signal&lt;/code&gt; class, the thing that actually implements dispatch, was absent from the top three. Across the sweep, conceptual queries kept returning wrong or incomplete top-5 sets no matter how I said them. Phrasing is a knob on the query side. Dilution is a problem on the document side. Turning the query knob cannot un-average a document vector.&lt;/p&gt;

&lt;p&gt;Then came the control that settled it. vectr also keeps a deterministic &lt;strong&gt;symbol graph&lt;/strong&gt;: a plain lookup from a name to its definition site, no embeddings involved. For &lt;em&gt;every&lt;/em&gt; canonical symbol that semantic search had just missed, the deterministic lookup resolved it exactly and instantly — &lt;code&gt;Signal&lt;/code&gt;, &lt;code&gt;BaseCache&lt;/code&gt;, &lt;code&gt;Query&lt;/code&gt;, &lt;code&gt;SQLCompiler&lt;/code&gt;, &lt;code&gt;QuerySet.get&lt;/code&gt;, each to the correct file and line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the control that localizes the bug.&lt;/strong&gt; Same corpus, same index build. The symbol table resolves the target perfectly; semantic search cannot find it. That gap is not the parser's fault, not the chunker's fault, not a missing document. &lt;strong&gt;The index and the symbol table were correct. The failure is purely in the embedding and search layer.&lt;/strong&gt; When two views of the same index disagree this cleanly, the broken one tells you exactly where to look.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One more result, and it is the one that made me stop trusting cold semantic search on its own. I ran a round of "famous symbols" — targets every Django developer knows by heart. Cold semantic search was roughly a &lt;strong&gt;coin flip&lt;/strong&gt; even there. &lt;code&gt;get_object_or_404&lt;/code&gt;, &lt;code&gt;QuerySet.get&lt;/code&gt;, and &lt;code&gt;reverse&lt;/code&gt; were all absent from the top-5; &lt;code&gt;ForeignKey&lt;/code&gt; came in at #2 and &lt;code&gt;Paginator&lt;/code&gt; at #4, each sitting behind look-alikes with more generic wording. If a system cannot reliably surface &lt;code&gt;get_object_or_404&lt;/code&gt;, the failure is not exotic. It is the common case wearing a docstring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3 · A Trap in the Scores
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Normalized Scores Lie About Confidence
&lt;/h3&gt;

&lt;p&gt;This one is a short aside, but it burned me while I was debugging the above, so it earns its place. While hunting the dilution bug I ran control queries for concepts that &lt;em&gt;do not exist&lt;/em&gt; in Django core — CORS handling, for instance, which Django leaves to middleware and third-party packages. A search for something absent should come back empty, or at least visibly unsure.&lt;/p&gt;

&lt;p&gt;It came back with five hits scored between &lt;strong&gt;0.77 and 1.0&lt;/strong&gt;, looking every bit as confident as a real match. Nothing in the corpus answered the query, and the system reported near-certainty anyway.&lt;/p&gt;

&lt;p&gt;The reason is a modeling choice that is easy to make and easy to forget. The displayed score was the reranker's output after &lt;strong&gt;per-query normalization&lt;/strong&gt; — rescaled so the best result of &lt;em&gt;this&lt;/em&gt; query becomes ≈1.0. That rescaling throws away the only thing you needed: how good the top match is in absolute terms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The top score is always ≈1.0 by construction.&lt;/strong&gt; A per-query-normalized score can't tell you "nothing here matches," because it is defined to make the best available result look like a perfect one — even when the best available result is garbage. The number describes rank within the query, not relevance to the world. If you surface it as confidence, your UI will radiate certainty at the exact moment it has found nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The practical rule that fell out of this: never show a per-query-normalized score as if it were confidence. Keep a non-normalized signal alongside it — a raw cosine, or a BM25 floor — so the system retains an honest way to say "nothing here is actually close." Recall failures like the dilution one are already invisible enough; a score that reads 0.99 over an empty result set makes them worse, because it converts a silent miss into a confident wrong answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4 · The Fix That Shipped
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Fix I Shipped: Dual-Vector Indexing
&lt;/h3&gt;

&lt;p&gt;The measurement points at its own fix. If purpose-only embeddings score +0.06 to +0.10 higher — enough, in the calibrated case, to clear the pool floor — then the answer is not to throw away the body vector. It is to &lt;em&gt;also&lt;/em&gt; keep a purpose vector, and let a query match whichever one fits it.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;dual-vector indexing&lt;/strong&gt;. At index time, store two vectors per symbol: a &lt;strong&gt;purpose vector&lt;/strong&gt; built from the qualified signature and docstring with the body stripped out, alongside the existing &lt;strong&gt;full-body vector&lt;/strong&gt;. At query time, retrieve over both, and blend or take the max of the two similarities for pool entry. Nothing else in the pipeline changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy · The book and its spine.&lt;/strong&gt; A full-body embedding is like shelving a book by blending every word in it into one average color. Two books with very different covers but similar bulk end up the same muddy shade, and you can't find either by its subject. The purpose vector is the printed spine: title and one-line description, nothing else. You keep the whole book on the shelf — you just also write a legible spine, so someone looking for the &lt;em&gt;subject&lt;/em&gt; can find it without reading all 300 pages first. Dual-vector indexing shelves every symbol with both: the full text, and a spine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I like about this shape is that it does not privilege one kind of query. Intent-shaped queries — "get a single object from the database" — land on the purpose vector, where the docstring is undiluted. Implementation-detail queries — "where is &lt;code&gt;select_for_update&lt;/code&gt; checked" — still land on the body vector, because that string only exists in the body. You are not trading one failure mode for its mirror image; you are giving each query the surface it needs.&lt;/p&gt;

&lt;p&gt;Two properties make it safe to apply blindly across a whole corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Undocumented symbols degrade gracefully.&lt;/strong&gt; No docstring? The purpose vector is just the qualified signature. It never gets worse than the name itself, and the name is often enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a uniform structural transform.&lt;/strong&gt; The same body-stripping rule applies to every symbol, at index time, with &lt;em&gt;no query-side special-casing&lt;/em&gt; — no keyword lists, no "if the query looks conceptual, reroute it." Query-side heuristics are the thing I have spent a long time deleting from this system; they are brittle, they compound, and they never generalize. A transform on the index side has none of those failure modes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not free, though, and I would rather name the costs than let you find them. Storing a second vector per symbol roughly doubles the number of vectors in the index — reason enough to confirm dilution is actually your problem before you spend on it. And it does not stand alone: dual-vector &lt;em&gt;composes&lt;/em&gt; with structural ranking signals like symbol importance rather than replacing them. A diluted docstring and an under-weighted call graph are different failure classes; fixing one leaves the other exactly where it was.&lt;/p&gt;

&lt;p&gt;The honest sequence, stated plainly: I measured the cause first, then shipped the fix. The spike proved the mechanism — purpose-only embeddings clear the pool floor where full-chunk embeddings do not — and that measurement, not a hunch, is why dual-vector indexing &lt;strong&gt;shipped in vectr v1.0.0&lt;/strong&gt; on 8 July 2026. If someone tells you a retrieval change "should work," ask them for the cosine table. This one had one before a line of the fix was written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fusion bug hiding underneath.&lt;/strong&gt; While validating the direction I tripped over a second, separate problem worth its own paragraph, because it will bite anyone running hybrid retrieval. Remember that BM25 ranked the target &lt;strong&gt;#1&lt;/strong&gt; for one phrasing. You would assume a #1 in either leg guarantees pool entry. It did not: the fused final top-60 &lt;em&gt;did not contain the target&lt;/em&gt; even though BM25 had ranked it first. The fusion was dense-dominated, and the dense leg's absence outvoted the keyword leg's #1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Check that your fusion can't discard a leg's #1.&lt;/strong&gt; Hybrid retrieval is supposed to be a safety net: if one leg misses, the other catches. That promise only holds if your fusion actually lets a strong single-leg result survive. A dense-dominated blend can throw away the exact result BM25 nailed. Before you trust hybrid search, feed it a query where you &lt;em&gt;know&lt;/em&gt; one leg ranks the answer #1, and confirm the answer is still in the fused output. Mine wasn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Same Dilution Shows Up Far Beyond Code
&lt;/h3&gt;

&lt;p&gt;I found this in code search, but nothing about the mechanism is about code. Dilution appears in any corpus where a document mixes "what this is for" with "how it works" or with plain boilerplate. The purpose is a small fraction of the tokens; the pooled vector drifts toward the bulk; a query written in terms of purpose lands short.&lt;/p&gt;

&lt;p&gt;You have almost certainly hit it without naming it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API reference pages&lt;/strong&gt; where a one-line summary sits on top of exhaustive parameter tables and examples. Search for what the endpoint &lt;em&gt;does&lt;/em&gt; and the parameter soup dominates the vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal clauses&lt;/strong&gt; buried inside pages of recitals and boilerplate. The operative sentence is three lines; the surrounding scaffolding is three hundred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product descriptions&lt;/strong&gt; embedded in spec sheets, where the one line a buyer would search for is outweighed by dimensions, SKUs, and compliance notices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two mitigations generalize as cleanly as the problem does. First, &lt;strong&gt;embed a purpose or summary field separately from the full text, and retrieve over both&lt;/strong&gt; — the dual-vector idea, minus the word "symbol." A short, curated summary vector per document is often the single highest-leverage change you can make to recall, precisely because it is immune to dilution by construction. Second, and this is the cheaper habit to build: &lt;strong&gt;audit recall at the pool level, not just the final ranking.&lt;/strong&gt; Most RAG dashboards show you the reranked top-k and nothing else, which means a pool-entry miss is completely invisible on the screen you are staring at. The failure that started this whole post would never have shown up on a top-k view. I only found it because I went looking one layer up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Choosing the embedder is a real lever — measure it, don't assume it.&lt;/strong&gt; These deltas came from one general text embedder on one corpus. A code-specialized model would move the numbers; the CoIR benchmark evaluates nine retrieval models across ten code datasets and eight tasks and finds even state-of-the-art systems struggle with code retrieval, which is exactly why the embedder is not a detail. But a better embedder does not repeal dilution — it raises the whole curve, floor included, and a long mixed chunk still averages its purpose away. Dual-vector composes with a better model; it does not compete with one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 5 · Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What To Actually Do With This
&lt;/h3&gt;

&lt;p&gt;Go back to the opening. A function whose docstring paraphrased the query ranked below two hundred chunks that didn't. You now know the chain underneath that sentence. The docstring's signal was real and present. The thirty lines of body around it pulled the pooled vector away — enough to cost about a tenth of a cosine point. That tenth was the difference between clearing the pool floor and never entering the pool at all — between reaching the reranker and never being considered. The reranker never failed, because the reranker never saw it. And a normalized score would have happily reported confidence over whatever wrong answers did make the pool.&lt;/p&gt;

&lt;p&gt;If you build retrieval, here is the short version to carry out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure recall at pool entry, not at the reranked top-k.&lt;/strong&gt; The reranker can only be as good as its pool. The miss that matters most is the one that never reaches the screen you monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When recall is bad, test purpose-only against full-chunk.&lt;/strong&gt; Embed a summary or signature alone, measure its cosine to the query, and compare. A large positive delta names your problem: dilution, not a weak model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index a separate purpose vector, and retrieve over both.&lt;/strong&gt; Keep the full text for detail queries; add a body-stripped summary vector for intent queries. It is a structural transform on the index, so it needs no query-side heuristics to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm your fusion can't drop a leg's #1&lt;/strong&gt;, and never surface a per-query-normalized score as confidence. Keep a raw, absolute signal for an honest no-match.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dual-vector fix shipped in vectr v1.0.0 on the strength of the cosine table above rather than a hunch. The boring query that started this — get a single object from the database — is exactly the case it was built to catch: the method whose docstring says precisely that, given a surface where its own body can no longer outvote it. The signal was never missing. It just needed somewhere to be read on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links &amp;amp; Further Reading
&lt;/h2&gt;

&lt;p&gt;Every external claim in this post was confirmed against the source it points to; where a source did not confirm a specific number, that number is not stated here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt; — the semantic code-search and working-memory tool used as the system under test, and the instrument that produced these measurements.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;The Complete Guide to Text Embeddings, Vector Databases &amp;amp; LLMs&lt;/a&gt; — the primer this post assumes: tokenization, pooling, cosine similarity, and how a RAG pipeline fits together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2407.02883" rel="noopener noreferrer"&gt;CoIR: A Comprehensive Benchmark for Code Information Retrieval Models&lt;/a&gt; — a benchmark of ten code datasets across eight retrieval tasks and seven domains; it evaluates nine retrieval models and finds significant difficulty with code retrieval even for state-of-the-art systems. arXiv:2407.02883&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2507.02107" rel="noopener noreferrer"&gt;Structural Code Search using Natural Language Queries&lt;/a&gt; — reports that a natural-language-driven structural search outperforms baselines based on semantic code search by up to 57% F1; embeddings alone under-serve structural queries. arXiv:2507.02107&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Numbers here are from one embedder (arctic-embed-m-v1.5) on one corpus (Django, June 2026 checkout). The deltas are model-specific; the mechanism is general. Cosine thresholds like the ~0.697 pool floor are corpus- and index-specific calibration points, not universal constants.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>embeddings</category>
      <category>semanticsearch</category>
      <category>rag</category>
      <category>coderetrieval</category>
    </item>
    <item>
      <title>The Four Families of Context Relief for LLM Coding Agents</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:50:58 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/the-four-families-of-context-relief-for-llm-coding-agents-5e6o</link>
      <guid>https://dev.to/swapnanilsaha/the-four-families-of-context-relief-for-llm-coding-agents-5e6o</guid>
      <description>&lt;p&gt;Run a coding agent on anything bigger than a toy repo and you hit the same wall. The context window fills up. Not with the answer — with the &lt;em&gt;search for&lt;/em&gt; the answer. Twelve file reads, four grep results, a stack trace, the output of a test run that failed for an unrelated reason. By the time the agent is ready to write the fix, half its working memory is archaeology it will never look at again.&lt;/p&gt;

&lt;p&gt;I've spent the last few months building a semantic-search-plus-working-memory MCP server (I'll call it &lt;strong&gt;vectr&lt;/strong&gt; throughout — it's the running example, not the point of the post), and the single most clarifying thing I did early on was stop treating "the context is full" as one problem. It's four problems wearing a trench coat. Each has its own mechanism, its own cost, its own failure mode, and — this is the part people miss — they only work when you compose them correctly. Get the composition wrong and you don't get relief; you get a subtle new class of bug where the agent confidently reasons over information it no longer has.&lt;/p&gt;

&lt;p&gt;So here's the map I wish someone had handed me. Four families of context relief: what each one actually buys you, where each one bites, and how they fit together.&lt;/p&gt;

&lt;p&gt;A few definitions first, because the jargon is dense. A &lt;strong&gt;token&lt;/strong&gt; is the unit an LLM reads and bills by — roughly three-quarters of a word. The &lt;strong&gt;context window&lt;/strong&gt; is the fixed number of tokens the model can attend to at once (a million, on the current Claude models). &lt;strong&gt;Prompt caching&lt;/strong&gt; lets you pay a reduced rate to re-send an identical prefix instead of reprocessing it. And &lt;strong&gt;MCP&lt;/strong&gt; (Model Context Protocol) is the standard interface an agent uses to call external tools. Keep those four in your head and the rest follows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Fails when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 · Eviction&lt;/td&gt;
&lt;td&gt;Delete stale context, leave a placeholder&lt;/td&gt;
&lt;td&gt;Removes&lt;/td&gt;
&lt;td&gt;You threw away what you can't cheaply restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 · Offload &amp;amp; recall&lt;/td&gt;
&lt;td&gt;Write findings to a durable store, fetch on demand&lt;/td&gt;
&lt;td&gt;Restores&lt;/td&gt;
&lt;td&gt;Recall isn't automatic — the model forgets to look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 · Retrieval&lt;/td&gt;
&lt;td&gt;Fetch the exact function, not the whole file&lt;/td&gt;
&lt;td&gt;Restores&lt;/td&gt;
&lt;td&gt;Used on structural questions (call graphs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 · Subagents&lt;/td&gt;
&lt;td&gt;Burn messy work in a separate window&lt;/td&gt;
&lt;td&gt;Removes&lt;/td&gt;
&lt;td&gt;No shared memory — children re-derive everything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Family 1 — Eviction: throw it away, but keep a receipt
&lt;/h2&gt;

&lt;p&gt;Eviction is the most literal answer to a full context: delete the stale stuff. The tool result from twenty turns ago, the file you read and already edited, the thinking block from a reasoning step that's now resolved — drop it out of the live window so the model stops paying to carry it.&lt;/p&gt;

&lt;p&gt;The harness can do this for you, and increasingly it does. Claude Code calls this &lt;strong&gt;compaction&lt;/strong&gt;: as a session approaches its context limit, it clears the oldest tool outputs first and only summarizes the rest of the conversation if that isn't enough on its own. Recent tool results stay inline so you can keep reasoning over them; older ones get cleared first.&lt;/p&gt;

&lt;p&gt;At the API level there's a more configurable version. Anthropic's &lt;strong&gt;context editing&lt;/strong&gt; feature exposes a strategy with the delightfully machine-generated name &lt;code&gt;clear_tool_uses_20250919&lt;/code&gt;. You turn it on with a beta header (&lt;code&gt;anthropic-beta: context-management-2025-06-27&lt;/code&gt;) and it watches your accumulating tool results. Once input tokens cross a threshold — the default &lt;code&gt;trigger&lt;/code&gt; is 100,000 input tokens — it clears the oldest tool results in chronological order, keeping the most recent few (&lt;code&gt;keep&lt;/code&gt; defaults to 3 tool uses).&lt;/p&gt;

&lt;p&gt;Here's the detail that matters more than any of the parameters: &lt;strong&gt;each cleared result is replaced with placeholder text so the model knows it was removed.&lt;/strong&gt; The agent doesn't silently lose a tool result and then hallucinate what was in it. It sees a tombstone — "this tool result was cleared" — which is a very different thing from a gap.&lt;/p&gt;

&lt;p&gt;The config below overrides the defaults to make the behaviour easy to see — I've set &lt;code&gt;trigger&lt;/code&gt; to 30,000 tokens rather than the stock 100,000 so it fires early, and pinned &lt;code&gt;clear_at_least&lt;/code&gt; so each pass removes a real chunk. In production you'd leave &lt;code&gt;trigger&lt;/code&gt; higher.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;context_management&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear_tool_uses_20250919&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;trigger&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_uses&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear_at_least&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exclude_tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web_search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it saves
&lt;/h3&gt;

&lt;p&gt;Straightforwardly, input tokens — though the headline figure Anthropic has published for this feature is a task-performance number, not a token count: on an internal agentic-search evaluation, context editing alone lifted performance 29% over baseline, rising to 39% when paired with a memory tool. Hold onto that second number. It's the whole thesis of this post hiding in a benchmark.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;Two things, and the second is non-obvious. The first is the risk that you evict something the model actually needed — mitigated by the placeholder tombstone and by &lt;code&gt;exclude_tools&lt;/code&gt;, which lets you mark, say, your search tool's results as never-clearable. The second cost is about prompt caching, and it's where a lot of naive eviction setups quietly lose money.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The cache-invalidation math.&lt;/strong&gt; Prompt caching bills a cached prefix at &lt;strong&gt;0.1×&lt;/strong&gt; the base input rate on a read, but a cache &lt;em&gt;write&lt;/em&gt; costs &lt;strong&gt;1.25×&lt;/strong&gt; the base rate for the default 5-minute TTL (2× for the 1-hour TTL). The catch is invalidation: the cache key is a cumulative hash of everything up to and including your cache breakpoint, so changing any block at or before the breakpoint produces a different hash and a full cache miss.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read those two facts together and the tension jumps out. Eviction &lt;em&gt;edits the middle of your conversation&lt;/em&gt;. Every time it fires, it changes the prefix, which invalidates the cache from that point forward, which means your next request pays the 1.25× write cost to re-cache the new prefix. Evict a little bit, often, and you can spend more on cache churn than you saved on the evicted tokens. This is exactly why the &lt;code&gt;clear_at_least&lt;/code&gt; parameter exists: it forces each clearing pass to remove a worthwhile chunk of tokens so the cache invalidation is amortized against a real saving, not a rounding error. If you take one operational lesson from this whole family, make it that one — evict in big, infrequent passes, never in a trickle.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;Eviction fails the moment the model needs something you threw away and &lt;em&gt;can't cheaply get it back&lt;/em&gt;. A tombstone that says "tool result cleared" is honest, but honesty doesn't reconstruct the file. If the only copy of that information lived in the evicted tool result, the agent is now stuck: it has to re-run the tool, re-read the file, re-derive the thing. You've converted a token cost into a latency-and-tool-call cost — and if the underlying state has changed in the meantime, possibly into a correctness bug.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The governing rule of eviction.&lt;/strong&gt; You can only safely evict what you can cheaply restore. Eviction on its own is not a memory strategy — it's a bet that restoration is cheap. That bet is only good if something else in your system guarantees it. Which is the entire reason the other three families exist.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Family 2 — Offload &amp;amp; recall: write it down where it survives
&lt;/h2&gt;

&lt;p&gt;If eviction is "throw it away and hope you don't need it," offload-and-recall is "write it down somewhere durable &lt;em&gt;before&lt;/em&gt; you throw it away, and fetch it back on demand." The agent, mid-session, notices it has learned something worth keeping — a function signature, a gotcha, a decision, a partial result — and commits it to an external store. Later, instead of carrying that finding in the context window the whole time, it recalls it in a single cheap call exactly when it's relevant.&lt;/p&gt;

&lt;p&gt;This is the working-memory pattern, and it's the half of vectr I care about most. When the agent discovers that, say, a workspace lock is acquired at &lt;code&gt;resolver.rs:214&lt;/code&gt; and released on scope exit, it doesn't keep that fact parked in context for forty turns. It stores a note. The note sits in a local store keyed to the workspace, and a &lt;code&gt;recall&lt;/code&gt; call pulls it back — in my case in under 50 milliseconds — whenever the agent's current task touches locking.&lt;/p&gt;

&lt;p&gt;The reason this is a distinct family, and not just "eviction with extra steps," is &lt;em&gt;what it survives&lt;/em&gt;. A finding in the live context window dies three deaths. It costs tokens the entire time it sits there. It gets mangled or dropped when the conversation is compacted into a summary — &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-2-working-memory-compact-survival/" rel="noopener noreferrer"&gt;compaction preserves the gist and loses the exact line number&lt;/a&gt;. And it vanishes completely when the session ends. A note in an external store survives all three. It's there after &lt;code&gt;/compact&lt;/code&gt;. It's there in tomorrow's session. It costs nothing until you ask for it.&lt;/p&gt;

&lt;p&gt;Anthropic's own memory tool works on this principle, and it's the reason for that 39%-versus-29% gap I told you to hold onto. Context editing &lt;em&gt;alone&lt;/em&gt; improves performance 29% over baseline on Anthropic's internal agentic-search evaluation. Context editing &lt;em&gt;plus a memory tool&lt;/em&gt; gets you 39% — because the agent writes the important bits to memory before the eviction pass clears them, so clearing becomes safe instead of lossy. That extra ten points isn't a second independent optimization stacked on the first. It's the same optimization made &lt;em&gt;safe to run harder&lt;/em&gt;, because family 2 backs it up.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the standing token cost of carrying a finding you only need occasionally. And, less measurably but more importantly, it saves &lt;em&gt;re-derivation&lt;/em&gt; — the agent doesn't have to re-read the file and re-reason to the same conclusion next time.&lt;/p&gt;

&lt;p&gt;Against that, three real costs. The agent has to decide what's worth remembering, which is a judgment call it will sometimes get wrong — store noise and your recall gets diluted. The recall has to actually be relevant when it fires, which is a retrieval-quality problem in miniature. And there's a token cost to the recall itself, a fact I had to make peace with: store terse one-line notes and recall is cheap but thin; store full code blocks and recall is rich but heavier. There's no free lunch. There's a dial.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;It fails when recall isn't &lt;em&gt;automatic&lt;/em&gt;. If your architecture depends on the model choosing, of its own accord, to call the recall tool at the right moment, it will frequently just… not. The model has no reliable sense of what it stored three sessions ago. The fix is to stop relying on the model's initiative and inject the relevant notes into context deterministically — which, in Claude Code, means &lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;hooks, and which is a whole post of its own&lt;/a&gt;. The short version: an offload store the agent forgets to read is a filing cabinet in a locked room.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The note-taking engineer.&lt;/strong&gt; Working memory is the difference between an engineer who takes notes and one who doesn't. The note-taker doesn't hold the whole system in their head at once — they hold a pointer to where they wrote it down, and the act of writing it down is cheap insurance against the cost of re-discovering it. The catch is the same for both: a note you never look at again is just slower forgetting.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Family 3 — Retrieval over stuffing: fetch the 40 lines, not the file
&lt;/h2&gt;

&lt;p&gt;The third family attacks a different waste. The first two are about getting rid of information you already loaded. This one is about &lt;em&gt;never over-loading it in the first place&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The default way an agent explores an unfamiliar codebase is grep-and-read. Grep for a likely keyword, get forty hits, read the six files that look plausible, discard five of them. Every one of those reads lands the &lt;em&gt;entire file&lt;/em&gt; in context — a 400-line module of which the agent needed one function. The signal-to-noise ratio is brutal, and unlike a human skimming, the model pays full token price for every line whether it was useful or not.&lt;/p&gt;

&lt;p&gt;Retrieval-over-stuffing replaces the blunt read with a targeted fetch. Instead of loading whole files and letting the model sift, you run a ranked retrieval — semantic search over the codebase, ideally chunked at function and class boundaries so each result is a self-contained unit of meaning — and hand back the forty lines that actually match the query. "JWT validation logic" returns the &lt;code&gt;verify_token&lt;/code&gt; function directly, even though neither word appears in it, and it returns &lt;em&gt;that function&lt;/em&gt;, not the 400-line file it lives in.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;the search half of vectr&lt;/a&gt;, and the payoff on unfamiliar code is large: on a big Java codebase in &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-3-benchmark-methodology-results/" rel="noopener noreferrer"&gt;my own benchmarks&lt;/a&gt;, ranked retrieval cut the read-and-grep calls before the first edit by roughly three-quarters compared to the grep-and-read baseline. The mechanism is boring — embeddings plus a keyword index, merged — but the discipline is the point: &lt;strong&gt;the unit you put in context should be the unit of meaning, not the unit of storage.&lt;/strong&gt; A file is a storage unit. A function is a meaning unit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the bulk of exploratory token spend, and the turns that go with it. A search that returns the right function in one call replaces a grep-plus-four-reads sequence. In exchange, you need an index — which means an indexing step and the machinery to keep it fresh as files change — and retrieval quality becomes a first-class concern.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Confident-wrong retrieval is worse than a miss.&lt;/strong&gt; A search that returns the wrong forty lines is more dangerous than a grep that returns nothing, because the agent &lt;em&gt;trusts&lt;/em&gt; it more. I've watched an agent build a wrong mental model off a top-ranked result that was subtly off-topic, then reason confidently from that bad premise for a dozen turns. An honest empty result would have sent it looking again; a plausible wrong one didn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;It fails on questions retrieval is the wrong tool for. "Who calls this function?" is not a similarity question — the callers don't contain the callee's body, they contain a reference to it &lt;em&gt;by name&lt;/em&gt;. That's a graph traversal, not a search. Reach for semantic retrieval there and you'll get plausible-looking garbage. Part of doing this family well is knowing which questions are retrieval questions (concepts, patterns, "how does X work") and which are structural ones (definitions, call graphs) that want an exact lookup instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Family 4 — Subagent isolation: burn the tokens in someone else's window
&lt;/h2&gt;

&lt;p&gt;The fourth family is the cleverest and the easiest to get subtly wrong. The idea: when a subtask is going to generate a pile of context you'll never reference again — a research spike, a log-diving expedition, a broad search — you don't do it in your main conversation. You spawn a &lt;strong&gt;subagent&lt;/strong&gt;, let it do the messy work &lt;em&gt;in its own context window&lt;/em&gt;, and take back only the distilled answer.&lt;/p&gt;

&lt;p&gt;Claude Code's subagents work exactly this way. Each one runs in its own context window with a custom system prompt, does its work independently, and returns only the result — the docs frame it as keeping exploration and implementation out of your main conversation. The parent agent spends, say, 800 tokens receiving a clean summary of an investigation that cost the subagent 40,000 tokens of reading and reasoning. Those 40,000 tokens are burned in a window that gets discarded. The parent's context stays clean.&lt;/p&gt;

&lt;p&gt;There's a nice secondary benefit. Because a subagent has its own tool permissions and its own system prompt, you can also use it to &lt;em&gt;constrain&lt;/em&gt; work — a read-only research agent that literally cannot write files — and to route cheap work to a cheaper, faster model. Context isolation and cost control fall out of the same mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the largest single chunk of exploratory context there is. A well-scoped subagent is the difference between your main window holding a conclusion and holding the entire messy derivation of that conclusion. The cost is a framing-and-parsing tax at the boundary: you have to specify the subtask well enough that the subagent can run without hand-holding, and you have to trust the summary it returns without seeing its work. If the summary is lossy in exactly the way that matters, the parent proceeds on a bad abstraction — and it can't tell, because the detail that would have flagged the problem got left behind in the discarded window.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;Here's the failure mode nobody warns you about. Subagent isolation with &lt;em&gt;no shared memory&lt;/em&gt; means every subagent starts cold. It re-derives context the parent already had and the last subagent already found. Spawn three subagents to investigate three corners of the same system and, without a shared store between them, each one re-reads the same core files, re-learns the same architecture, and re-discovers the same gotcha — three times, in three separate windows, at full price each. You've isolated the context so well that you've also isolated the &lt;em&gt;learning&lt;/em&gt;. The isolation that saves the parent's window quietly taxes every child.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Isolation without a shared bus is a false economy.&lt;/strong&gt; Subagent isolation without a shared memory store is a false economy at scale. You save the parent's context by making the children re-derive everything from scratch. The fix is to give the subagents the same durable store from family 2 — so the first subagent's findings are recalled by the next instead of rediscovered. Isolation controls what flows &lt;em&gt;up&lt;/em&gt;; shared memory controls what flows &lt;em&gt;sideways&lt;/em&gt;. You want both.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The families compose — that's the whole point
&lt;/h2&gt;

&lt;p&gt;I've been dropping the composition hints deliberately, so let me make them explicit, because treating these four as a menu you pick one item from is the mistake I most want to talk you out of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eviction (1) is only safe on top of offload (2) or retrieval (3).&lt;/strong&gt; This is the load-bearing relationship. You can only throw information away cheaply if you can get it back cheaply — and "getting it back cheaply" is precisely what families 2 and 3 provide. Evict a tool result whose contents you already wrote to a memory note: safe, because recall restores it. Evict a file you can re-fetch with one targeted search: safe, because retrieval restores it. Evict something that exists nowhere else and you've just planted a bug that will surface three turns later as confident nonsense. The 29% → 39% jump from adding a memory tool to context editing &lt;em&gt;is&lt;/em&gt; this relationship, quantified — the memory tool is what makes the eviction safe to run harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval (3) keeps the working set small enough that eviction (1) rarely has to fire.&lt;/strong&gt; If you never stuffed the whole file in, there's less to evict later. The two attack the same waste from opposite ends — one at load time, one at cleanup time — and a system with good retrieval needs less aggressive eviction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subagents (4) need a shared store (2) or they re-derive context.&lt;/strong&gt; Covered above, but it's the composition people skip most often, because subagents &lt;em&gt;feel&lt;/em&gt; self-contained. They're self-contained in their context, not in their knowledge. Wire them to the same working-memory store and the isolation stops being a re-derivation tax.&lt;/p&gt;

&lt;p&gt;The unifying idea is almost embarrassingly simple once you see it: &lt;strong&gt;cheap restoration is the license to be aggressive about relief.&lt;/strong&gt; Every family is either a way to remove context (1, 4) or a way to make removal safe by guaranteeing you can get the important parts back (2, 3). Build only the removal half and you get an agent that forgets things it needed. Build only the restoration half and you get an agent that never frees anything and grinds to a halt at the context limit. You need the pair.&lt;/p&gt;

&lt;p&gt;This is also why I stopped thinking of vectr as "a search tool" or "a memory tool." It's families 2 and 3 in one MCP server, deliberately, because on their own each is half a solution. Search without memory re-explores every session. Memory without search has nothing good to store. And both of them exist, in the end, to make the harness's eviction — family 1, which I don't even own — safe to run.&lt;/p&gt;




&lt;h2&gt;
  
  
  A short field guide
&lt;/h2&gt;

&lt;p&gt;If you operate a coding agent and want to actually apply this, here's the compressed version I'd give a colleague over coffee.&lt;/p&gt;

&lt;p&gt;Start with &lt;strong&gt;retrieval (3)&lt;/strong&gt;, because it's the one that prevents the mess instead of cleaning it up, and it pays off immediately on any codebase you don't have memorized. Add &lt;strong&gt;offload-and-recall (2)&lt;/strong&gt; next, and make the recall &lt;em&gt;automatic&lt;/em&gt; rather than something the model has to remember to do — a store the agent forgets to read is worthless. Let the harness handle &lt;strong&gt;eviction (1)&lt;/strong&gt;, but check that it's evicting in big infrequent passes (mind the cache-invalidation math) and that everything it evicts is backed by 2 or 3. Reach for &lt;strong&gt;subagent isolation (4)&lt;/strong&gt; on genuinely large exploratory subtasks, and if you use more than one subagent on related work, give them a shared memory bus or accept that each is paying full freight to learn what the last one already knew.&lt;/p&gt;

&lt;p&gt;None of these is exotic. The compaction and context-editing pieces ship in the tools already. The retrieval and memory pieces are a weekend to prototype — I wrote up &lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;the honest numbers on how far mine actually got&lt;/a&gt; if you want the unvarnished version. What's rare is treating them as one system with a single governing rule — restore-ability licenses removal — instead of four disconnected tricks. Get the rule right and the context window stops being the thing you fight and starts being the thing you manage.&lt;/p&gt;

&lt;p&gt;Four problems in a trench coat, then — not one. And once you've split them apart, the thing that surprised me is how little the individual tricks matter next to the relationship between them. Any single family, run on its own, either forgets something it needed or refuses to let go of anything. What actually works is the pair: a way to remove context sitting on top of a guarantee that you can get the important parts back. Cheap restoration is what buys you the right to be ruthless. Wire that in and the context window quietly changes from the wall you keep hitting into a budget you spend on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code — Context window and compaction. &lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/context-window&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Context editing (&lt;code&gt;clear_tool_uses_20250919&lt;/code&gt;, &lt;code&gt;clear_at_least&lt;/code&gt;, &lt;code&gt;keep&lt;/code&gt;, &lt;code&gt;trigger&lt;/code&gt;, &lt;code&gt;exclude_tools&lt;/code&gt;). &lt;a href="https://platform.claude.com/docs/en/build-with-claude/context-editing" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/build-with-claude/context-editing&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Managing context on the Claude Developer Platform (29% / 39% performance figures for context editing alone vs. context editing plus the memory tool). &lt;a href="https://www.anthropic.com/news/context-management" rel="noopener noreferrer"&gt;https://www.anthropic.com/news/context-management&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Prompt caching (5-minute / 1-hour TTL, 1.25× / 2× write, 0.1× read, cumulative-hash invalidation). &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Claude Code — Subagents (per-subagent context window, returns only the summary). &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/sub-agents&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llmagents</category>
      <category>contextwindow</category>
      <category>promptcaching</category>
      <category>agentarchitecture</category>
    </item>
    <item>
      <title>Claude Code Hooks: A Practical Deep-Dive on Deterministic Agent Behavior</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Sun, 12 Jul 2026 22:36:47 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/claude-code-hooks-a-practical-deep-dive-on-deterministic-agent-behavior-2i7d</link>
      <guid>https://dev.to/swapnanilsaha/claude-code-hooks-a-practical-deep-dive-on-deterministic-agent-behavior-2i7d</guid>
      <description>&lt;p&gt;Here's a thing that took me embarrassingly long to accept about coding agents: you cannot instruct your way to reliability.&lt;/p&gt;

&lt;p&gt;I had a working-memory system — a semantic-search-plus-notes MCP (Model Context Protocol) server I've been building, and it's the case study for this whole post — and it worked beautifully in demos. The agent would discover something, store a note, recall it later, save itself a re-read. Then I'd watch a real session and the agent would just... not recall. It had notes sitting right there, one tool call away, verbatim, and it would instead re-read the same file it had already read two sessions ago, because nothing &lt;em&gt;made&lt;/em&gt; it check. My &lt;code&gt;CLAUDE.md&lt;/code&gt; said "call recall at the start of every task." The model read that instruction and ignored it, the way it ignores roughly anything that competes with the task actually in front of it.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past my project. Any behavior you need to happen &lt;em&gt;every single time&lt;/em&gt; — inject context, run a linter, block a dangerous command, snapshot state before it's destroyed — cannot depend on the model deciding to do it. The model is a probabilistic thing optimizing for the current turn. You need something outside the model, in the harness, that fires deterministically. In Claude Code, that thing is &lt;strong&gt;hooks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the practical guide I wanted when I started: what the events are and what each is genuinely good for, the exact configuration and I/O contract (which is fiddlier than the docs make it look), and then a real production hook pipeline — mine — walked through end to end, including the design calls and the parts that bit me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem hooks actually solve
&lt;/h2&gt;

&lt;p&gt;Before the plumbing, the point. There is a whole class of things you want an agent to do that instructions are simply the wrong tool for. Not because the instruction is badly worded — because instructions target the model, and the model is the part of the system you don't control.&lt;/p&gt;

&lt;p&gt;Think about what "the model complies with an instruction" actually means. On any given turn there's some probability the behavior happens, and that probability is well short of 1. It drops when the task gets absorbing, when the context is long, when an unusual prompt pulls attention elsewhere. That's fine for a preference — "prefer functional style," "keep commits small." It is a disaster for a guarantee. If the only thing standing between your agent and an &lt;code&gt;rm -rf&lt;/code&gt; on the wrong directory is a politely worded line in a config file, you don't have a control. You have a hope.&lt;/p&gt;

&lt;p&gt;Hooks move the decision out of the model and into the harness. The harness is deterministic: it runs code on a schedule, whether or not the model would have thought to. That single relocation — from "the model should" to "the harness will" — is the entire idea, and everything below is mechanics in service of it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The core distinction.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; is where you put things the model should &lt;em&gt;tend&lt;/em&gt; to do. Hooks are where you put things that must &lt;em&gt;deterministically&lt;/em&gt; happen. Confusing the two — trying to instruction-engineer a guarantee — is how you end up with a system that works in the demo and flakes in production.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What a hook actually is
&lt;/h2&gt;

&lt;p&gt;A hook is a shell command — or, increasingly, an HTTP call or an MCP tool invocation — that Claude Code runs automatically when a specific event fires in the session lifecycle. The event hands your command a JSON blob on stdin describing what's happening. Your command does whatever it wants and communicates back through two channels: its &lt;strong&gt;exit code&lt;/strong&gt; and its &lt;strong&gt;stdout&lt;/strong&gt;. That's the entire model. It's Unix-plumbing simple, which is exactly why it's reliable — there's no LLM in the loop deciding whether to honor it.&lt;/p&gt;

&lt;p&gt;The events cover the session from birth to death. When I first wrote my pipeline the list was short; by mid-2026 it has grown considerably — the reference now documents around thirty event types spanning session lifecycle, per-turn, per-tool-call, permissions, subagents, worktrees, and MCP elicitation. Most of them you'll never touch. The workhorses — the ones worth learning cold — are these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Fires&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session begins or resumes (matchers: &lt;code&gt;startup&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;clear&lt;/code&gt;, &lt;code&gt;compact&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inject state the agent needs before turn 1 — branch info, environment, recalled memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before Claude processes each user prompt&lt;/td&gt;
&lt;td&gt;Inject per-turn context keyed to what the user just asked; can also block the prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before a tool call runs (matches on tool name)&lt;/td&gt;
&lt;td&gt;Block dangerous calls, rewrite arguments, or surface a warning tied to the specific action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After a tool call succeeds&lt;/td&gt;
&lt;td&gt;React to results, replace tool output, add follow-up context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreCompact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before context compaction (matchers: &lt;code&gt;manual&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Persist anything that's about to be summarized away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Stop&lt;/code&gt; / &lt;code&gt;SubagentStop&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When the agent (or a subagent) finishes a turn&lt;/td&gt;
&lt;td&gt;Enforce "you're not done yet" — block the stop and send it back to work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session terminates&lt;/td&gt;
&lt;td&gt;Cleanup, flush, teardown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code emits a notification (permission prompt, idle, etc.)&lt;/td&gt;
&lt;td&gt;Route notifications to your own channels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental split that helps: &lt;strong&gt;session-scoped&lt;/strong&gt; events (&lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;) bracket the whole thing; &lt;strong&gt;per-turn&lt;/strong&gt; events (&lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;) fire once per user exchange; &lt;strong&gt;per-tool&lt;/strong&gt; events (&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;) fire around individual tool calls, potentially dozens of times a turn. Match the cadence of your hook to the cadence of the thing it's reacting to, or you'll either miss events or fire far too often.&lt;/p&gt;




&lt;h2&gt;
  
  
  The configuration surface
&lt;/h2&gt;

&lt;p&gt;Hooks live in &lt;code&gt;settings.json&lt;/code&gt;. There are three tiers, and the tier decides who the hook applies to and whether it's shared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/settings.json&lt;/code&gt; — all your projects, never checked in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.json&lt;/code&gt; — one project, checked in and shared with the team.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.local.json&lt;/code&gt; — one project, gitignored, personal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape is nested and, honestly, a little awkward until it clicks. Under a top-level &lt;code&gt;hooks&lt;/code&gt; key, each event name maps to a &lt;em&gt;list of groups&lt;/em&gt;. Each group has an optional &lt;code&gt;matcher&lt;/code&gt; and a list of &lt;code&gt;hooks&lt;/code&gt; to run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;matcher&lt;/code&gt; is the filter. For tool events it matches against the tool name — &lt;code&gt;"Bash"&lt;/code&gt;, &lt;code&gt;"Edit|Write"&lt;/code&gt; for either, or a regex like &lt;code&gt;"mcp__memory__.*"&lt;/code&gt; for a whole MCP server's tools. For non-tool events it matches against the event's reason: &lt;code&gt;SessionStart&lt;/code&gt; takes &lt;code&gt;startup|resume|clear|compact&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt; takes &lt;code&gt;manual|auto&lt;/code&gt;. Omit the matcher (or use &lt;code&gt;"*"&lt;/code&gt;) to fire on everything.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; used to be implicitly "command." It's now explicit and there are several — &lt;code&gt;command&lt;/code&gt; (shell), &lt;code&gt;http&lt;/code&gt; (POST to a URL), &lt;code&gt;mcp_tool&lt;/code&gt; (invoke an already-connected MCP tool), and two LLM-in-the-loop types (&lt;code&gt;prompt&lt;/code&gt;, and the experimental &lt;code&gt;agent&lt;/code&gt;) that let a hook ask a model to make a yes/no call. For anything latency-sensitive you want &lt;code&gt;command&lt;/code&gt;, because it's a local process with no network round trip. The useful knobs on a command hook are &lt;code&gt;timeout&lt;/code&gt; (seconds; the default is generous but &lt;code&gt;UserPromptSubmit&lt;/code&gt; is capped lower because it's on the critical path of every turn), and the &lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt; placeholder so your command path survives the user's working directory changing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The I/O contract, which is where people trip
&lt;/h2&gt;

&lt;p&gt;This is the part the quickstart glosses and the part that determines whether your hook works. A hook talks back through exit code and stdout, and the two are read differently depending on the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit code 0&lt;/strong&gt; — success. Claude Code parses stdout looking for JSON. If it's JSON, the fields are honored; if it's not, no decision is taken and the session proceeds normally. (Two events, &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;UserPromptSubmit&lt;/code&gt;, are more generous: they also fold plain, non-JSON stdout straight into the context. For every other event, if you want to inject something you emit the JSON form.) This is the channel you use to &lt;em&gt;inject context&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit code 2&lt;/strong&gt; — blocking error. stdout's JSON is ignored; instead, stderr is read as an error message, and for blockable events (&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;/&lt;code&gt;SubagentStop&lt;/code&gt;) &lt;strong&gt;the action is blocked.&lt;/strong&gt; This is how a &lt;code&gt;PreToolUse&lt;/code&gt; hook vetoes an &lt;code&gt;rm -rf&lt;/code&gt;. (For &lt;code&gt;PreToolUse&lt;/code&gt; specifically there's now a cleaner path too: emit &lt;code&gt;permissionDecision&lt;/code&gt; — &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;deny&lt;/code&gt;, or &lt;code&gt;ask&lt;/code&gt; — inside &lt;code&gt;hookSpecificOutput&lt;/code&gt; on exit 0, which is more expressive than the blunt exit-2 veto and lets you attach a reason the model reads. I still reach for exit 2 when I just want a hard no.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any other exit code&lt;/strong&gt; — non-blocking error. The session continues; the failure is surfaced in the transcript and logged, but nothing is blocked.&lt;/p&gt;

&lt;p&gt;The JSON you emit on stdout (with exit 0) has a couple of shapes. There's a set of top-level universal fields — &lt;code&gt;continue&lt;/code&gt; (set false to stop Claude entirely), &lt;code&gt;stopReason&lt;/code&gt;, &lt;code&gt;suppressOutput&lt;/code&gt;, &lt;code&gt;systemMessage&lt;/code&gt;. And there's an event-specific envelope, &lt;code&gt;hookSpecificOutput&lt;/code&gt;, which is where the good stuff lives. The single field I use most is &lt;code&gt;additionalContext&lt;/code&gt;: a string that Claude Code injects into the model's context at the point the hook fired.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"additionalContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Current branch: main&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Uncommitted: auth.ts, config.py"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole trick behind hook-injected memory. &lt;code&gt;additionalContext&lt;/code&gt; on a &lt;code&gt;SessionStart&lt;/code&gt; hook lands at the start of the conversation. The same field on &lt;code&gt;UserPromptSubmit&lt;/code&gt; lands next to the prompt the user just submitted. On &lt;code&gt;PreToolUse&lt;/code&gt;/&lt;code&gt;PostToolUse&lt;/code&gt; it lands next to the tool result. The docs render it as a system reminder and advise writing it as plain factual statements rather than instructions — the model treats "the deployment target is production" better than "remember to be careful about production." There's a cap on how much you can push through it (on the order of ten thousand characters), which is less a limit than a hint: injection is not a place to dump files.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one rule that fails silently.&lt;/strong&gt; If you emit JSON on stdout with exit 0, stdout must contain &lt;em&gt;only&lt;/em&gt; that JSON. A stray &lt;code&gt;echo&lt;/code&gt; from your shell profile, a debug print, a warning from a Python import — any of it corrupts the JSON and your injection silently does nothing. More than one of my early hooks failed for exactly this reason and gave no error, because a malformed stdout on exit 0 just means "no decision," not "error." Nothing tells you. The session simply proceeds as if the hook weren't there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;In the interactive version of this post there's a small explorer where you pick an event, an exit code, and a stdout shape and see exactly what Claude Code does — including how the "stray echo" case turns an injection into a silent no-op. It's on &lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A real pipeline: injecting working memory
&lt;/h2&gt;

&lt;p&gt;Now the case study. My tool is a working-memory MCP server: the agent stores notes during a session and recalls them later. The problem from the intro was that recall is a &lt;em&gt;tool the model has to choose to call&lt;/em&gt;, and it wouldn't, reliably. Hooks are how I took the choice away from the model and made recall happen deterministically.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;vectr init --hooks&lt;/code&gt;, the tool writes four hook groups into the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;. Every one of them calls back into the same CLI — &lt;code&gt;vectr hook &amp;lt;event&amp;gt;&lt;/code&gt; — which owns the output contract so the settings file stays a thin, stable pointer. Here's the shape it writes (the install code is idempotent — re-running never duplicates entries and leaves any hooks you added yourself untouched):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startup|resume|clear|compact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook session-start"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"UserPromptSubmit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook user-prompt-submit"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook pre-tool-use"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreCompact"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manual|auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook pre-compact"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four events, four jobs. Walk through why each one is the event it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  SessionStart — the boot set
&lt;/h3&gt;

&lt;p&gt;Before the agent's first turn, &lt;code&gt;vectr hook session-start&lt;/code&gt; fires. It resolves which of my running daemons serves this workspace, asks it for the &lt;em&gt;boot set&lt;/em&gt; — the must-see notes, meaning standing directives plus high-priority task context — and emits them as &lt;code&gt;additionalContext&lt;/code&gt;. This is the &lt;code&gt;MEMORY.md&lt;/code&gt;-equivalent: the handful of things that should be true in the agent's head from turn one, present with zero model agency. The matcher &lt;code&gt;startup|resume|clear|compact&lt;/code&gt; means it fires not just on a fresh start but also after a &lt;code&gt;/compact&lt;/code&gt; and after a &lt;code&gt;/clear&lt;/code&gt; — precisely the moments when the agent has just &lt;em&gt;lost&lt;/em&gt; its context and most needs the boot set re-injected.&lt;/p&gt;

&lt;h3&gt;
  
  
  UserPromptSubmit — per-turn recall
&lt;/h3&gt;

&lt;p&gt;This is the one that fixed the original problem. Every time the user submits a prompt, &lt;code&gt;vectr hook user-prompt-submit&lt;/code&gt; reads the prompt text off stdin, runs a semantic recall against the note store &lt;em&gt;keyed to that specific prompt&lt;/em&gt;, and injects the top matches next to the prompt before the model ever sees it. Ask about workspace locking and the locking notes are already there. The agent doesn't decide to recall — recall already happened, invisibly, on the way in.&lt;/p&gt;

&lt;p&gt;The tuning here matters because this hook is on the hot path of every single turn. I cap it hard: at most 3 notes, with a relevance floor (a minimum similarity of 0.35) so an off-topic prompt injects &lt;em&gt;nothing&lt;/em&gt; rather than dragging in vaguely-related noise. And it injects the terse one-line index form of each note, not the full body — enough for the model to know the note exists and decide whether to expand it, without spending a paragraph of tokens on every turn. An injection that fires every turn has to be miserly or it becomes the context bloat it was meant to prevent.&lt;/p&gt;

&lt;h3&gt;
  
  
  PreToolUse (Edit|Write) — the gotcha at the moment of the edit
&lt;/h3&gt;

&lt;p&gt;This one I'm quietly proud of. When the agent is about to edit or write a file, &lt;code&gt;vectr hook pre-tool-use&lt;/code&gt; pulls the &lt;code&gt;file_path&lt;/code&gt; out of the tool input and recalls any &lt;em&gt;gotcha&lt;/em&gt; recorded against that exact file — then injects it right there, at the instant of the edit. "This file's config is regenerated; edit &lt;code&gt;schema.ts&lt;/code&gt; instead." "This function looks unrelated but changing it breaks the lock invariant." Static path-scoped rules can't do this, because the gotcha is something an earlier session &lt;em&gt;learned and wrote down&lt;/em&gt;, and it surfaces exactly when it's actionable rather than sitting in a rules file the agent skimmed once.&lt;/p&gt;

&lt;h3&gt;
  
  
  PreCompact — save it before it's gone
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/compact&lt;/code&gt; replaces the conversation with a summary and, in doing so, throws away exact detail. So right before it runs, &lt;code&gt;vectr hook pre-compact&lt;/code&gt; snapshots the working-memory store — sealing the current notes as a named checkpoint. Notably this hook injects &lt;em&gt;nothing&lt;/em&gt; into context; compaction is about to discard context anyway, so there's no point. Its whole job is the side effect of persisting state, and the boot set gets re-injected on the other side by the &lt;code&gt;SessionStart&lt;/code&gt; &lt;code&gt;compact&lt;/code&gt; matcher. The two hooks are a matched pair around the compaction event: one saves, the other restores.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why hook-injected memory beats a recall tool
&lt;/h2&gt;

&lt;p&gt;Let me make the central design argument sharp, because it's the reason the pipeline exists in this shape.&lt;/p&gt;

&lt;p&gt;A recall &lt;em&gt;tool&lt;/em&gt; and a recall &lt;em&gt;hook&lt;/em&gt; retrieve the exact same notes from the exact same store. The only difference is who pulls the trigger. With a tool, the model decides — and "the model decides" means a probability, well short of 1, that it happens on any given turn, dropping further as the task gets absorbing. With a hook, the harness decides, and the harness is deterministic. It fires every time, on schedule, whether or not the model would have thought to.&lt;/p&gt;

&lt;p&gt;For a capability whose entire value proposition is &lt;em&gt;reliability across sessions&lt;/em&gt;, a probabilistic trigger is a contradiction in terms. Working memory you recall 60% of the time isn't 60% as good as working memory you recall always — it's worse than that, because the times it fails are unpredictable and the agent has no way to know it's operating on a stale or empty picture. Moving the trigger from the model into the harness is the difference between a feature that demos well and one that holds up.&lt;/p&gt;

&lt;p&gt;The arithmetic makes it concrete. If a recall tool fires with probability &lt;code&gt;p&lt;/code&gt; on each turn, the chance it fires on &lt;em&gt;every&lt;/em&gt; turn of an &lt;code&gt;N&lt;/code&gt;-turn session is &lt;code&gt;p^N&lt;/code&gt;. At &lt;code&gt;p = 0.60&lt;/code&gt; over 20 turns that's about 0.004% — a clean session is essentially impossible. Even a very obedient &lt;code&gt;p = 0.95&lt;/code&gt; gives you only about 36%. A hook is &lt;code&gt;p = 1&lt;/code&gt;, so &lt;code&gt;p^N = 1&lt;/code&gt;, every session, forever.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The general principle.&lt;/strong&gt; Anything that must happen every time belongs in a hook, not in an instruction. A guarantee cannot be prompt-engineered, because the thing you'd be prompting is the exact thing you don't control. Relocate the trigger, not the words.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a subtle second-order bug that falls out of doing this, and it's worth telling because it's the kind of thing you only find in real transcripts. Once &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;UserPromptSubmit&lt;/code&gt; are auto-injecting notes, the model — which has &lt;em&gt;also&lt;/em&gt; been told in &lt;code&gt;CLAUDE.md&lt;/code&gt; to recall notes — will sometimes call the recall tool &lt;em&gt;on top of&lt;/em&gt; the injection, paying for the same memory twice. I caught this in an eval transcript: the agent got its notes injected by the hook and then immediately called &lt;code&gt;recall&lt;/code&gt; for the same thing. The fix is a one-line notice prepended to the injected context: &lt;em&gt;"Your working-memory notes are auto-injected below — do not call recall to re-fetch them; call it only for something not shown here."&lt;/em&gt; It resolves the double-dip cleanly, but I'd never have known to write it without watching the failure happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three things that will hurt you
&lt;/h2&gt;

&lt;p&gt;Hooks are simple to write and easy to write &lt;em&gt;dangerously&lt;/em&gt;. Three concerns dominate, and they're all about what happens when a hook misbehaves.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A hook must never break the session
&lt;/h3&gt;

&lt;p&gt;This is the rule I hold most rigidly, and it shapes every line of my hook code. A hook runs on the critical path — &lt;code&gt;UserPromptSubmit&lt;/code&gt; fires before &lt;em&gt;every&lt;/em&gt; prompt the user sends. If that hook throws, hangs, or crashes, it degrades or breaks the user's session. So the hook code is paranoid by construction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The recall function that feeds the injection catches &lt;em&gt;every&lt;/em&gt; exception and returns an empty string on any failure. Daemon down, slow, error, malformed response — doesn't matter, it yields nothing and the session proceeds.&lt;/li&gt;
&lt;li&gt;The top-level hook handler wraps its entire body in a try/except and &lt;strong&gt;always exits 0.&lt;/strong&gt; There is no code path where my hook returns a non-zero exit and accidentally blocks a prompt or a tool call.&lt;/li&gt;
&lt;li&gt;If there's no memory to inject — a brand-new workspace with zero notes — the hook emits &lt;em&gt;nothing at all&lt;/em&gt;, not an empty JSON envelope. A fresh project should feel exactly like no hook is installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design stance is that the memory injection is a &lt;em&gt;bonus&lt;/em&gt;, never a &lt;em&gt;dependency&lt;/em&gt;. The session must work identically whether the daemon is up, down, or on fire. If your hook can make the agent worse when it fails, you've built a liability, not a feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Latency is a tax on every turn
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt; sits between the user hitting enter and the model starting to think. Whatever your hook spends there, the user waits. Claude Code caps this event's hook timeout lower than others for exactly this reason, but a timeout is a backstop, not a budget — you want to be &lt;em&gt;nowhere near&lt;/em&gt; it. My recall is designed to return in well under 50 milliseconds, and the hook does the absolute minimum: read stdin, one local HTTP call to an already-running daemon, print, exit. No model loading, no indexing, no network beyond localhost. If your per-turn hook does anything that can take a second, move it off the hot path — make it &lt;code&gt;async&lt;/code&gt;, or attach it to a less frequent event.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Hooks run arbitrary shell — treat them as such
&lt;/h3&gt;

&lt;p&gt;The official docs are blunt about this and they're right: &lt;strong&gt;hooks execute arbitrary shell commands with your full user permissions, automatically.&lt;/strong&gt; They can read your files and your environment variables. A malicious or careless hook in a shared &lt;code&gt;.claude/settings.json&lt;/code&gt; is a genuine attack surface — someone commits a hook, you pull the repo, and now their command runs on your machine the next time you start a session.&lt;/p&gt;

&lt;p&gt;Practical defenses: review hook configs before committing to shared repos, exactly as you'd review a &lt;code&gt;Makefile&lt;/code&gt; or a git hook; keep personal hooks in the gitignored &lt;code&gt;settings.local.json&lt;/code&gt; so they can't leak; and know that enterprise setups can lock this down with &lt;code&gt;allowManagedHooksOnly&lt;/code&gt;. In my own design I lean on a smaller mitigation — the settings file never contains logic, only &lt;code&gt;vectr hook &amp;lt;event&amp;gt;&lt;/code&gt;, a call into a versioned, inspectable CLI. There's no shell one-liner in the JSON to audit; the behavior lives in code you can read. And the CLI only ever talks to a localhost daemon, so a hook firing in the wrong directory can't reach across to another workspace's memory. That last point is deliberate: the resolver walks up from the current directory to find the daemon that serves &lt;em&gt;this&lt;/em&gt; workspace and refuses to fall back to a default — because a default port could belong to an unrelated project and leak its notes into your session.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;p&gt;A few things I've hit that the enthusiastic tutorials leave out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Injected context is still context.&lt;/strong&gt; Every note a hook injects costs tokens, every turn, forever. The &lt;code&gt;UserPromptSubmit&lt;/code&gt; hook is genuinely helpful &lt;em&gt;because&lt;/em&gt; it's disciplined — 3 notes, relevance floor, terse index form. An undisciplined version that injected ten full notes per turn would reintroduce the exact context bloat the memory system exists to fight. Hook injection is a budget you're spending; spend it like one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging is opaque by design.&lt;/strong&gt; Because a malformed stdout on exit 0 means "no decision" rather than "error," a broken hook fails &lt;em&gt;silently&lt;/em&gt;. The session just proceeds as if the hook weren't there. When an injection isn't landing, my first move is always to run the exact command by hand, pipe a sample event JSON into its stdin, and stare at stdout for the one stray character breaking the JSON. There's no substitute; the harness won't tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's Claude Code-shaped.&lt;/strong&gt; This whole mechanism is specific to one harness. My pipeline's determinism comes from Claude Code's hook system, and other agent environments have different injection points, or none. If you want the same deterministic-injection behavior elsewhere, you're re-implementing against a different (or absent) surface, and in the worst case you fall back to the very thing hooks let you escape — hoping the model calls the tool. That portability gap is real and I don't have a clean answer to it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compaction timing isn't fully in your hands.&lt;/strong&gt; &lt;code&gt;PreCompact&lt;/code&gt; fires before compaction, which is great, but auto-compaction triggers on the harness's schedule, near the context limit — not necessarily at a clean task boundary. My snapshot is a safety net, not a substitute for the agent proactively writing important findings to memory as it goes. The hook catches what the agent forgot to save; it works best when there's little to catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What hooks are really for
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and hooks are one idea: &lt;strong&gt;a place to put behavior that must not depend on the model's cooperation.&lt;/strong&gt; Injection, enforcement, persistence, cleanup — anything where "usually" isn't good enough and you need "always." The model is brilliant at the open-ended, judgment-heavy work in the middle of a turn. It is not the thing you want deciding whether the guardrail runs.&lt;/p&gt;

&lt;p&gt;For my working-memory tool, hooks are what turned a good idea that demoed well into something that actually holds across sessions. The notes were always there. What was missing was a guarantee that the agent would &lt;em&gt;look&lt;/em&gt; — and that guarantee cannot come from the agent. It comes from four small shell commands wired into the right four events, each one paranoid about never breaking the session, each one doing exactly one deterministic job. That's the whole art of it: not clever hooks, but reliable ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  Companion posts
&lt;/h2&gt;

&lt;p&gt;This post is part of a series on the working-memory tool the pipeline is built on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Building vectr, Part 1: Semantic Code Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-2-working-memory-compact-survival/" rel="noopener noreferrer"&gt;Building vectr, Part 2: Working Memory &amp;amp; Compact Survival&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-3-benchmark-methodology-results/" rel="noopener noreferrer"&gt;Building vectr, Part 3: Benchmark Methodology &amp;amp; Results&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/four-families-llm-context-relief-eviction/" rel="noopener noreferrer"&gt;Four Families of LLM Context Relief&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;vectr v1 Release Gate: The Honest Numbers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt; (event list, &lt;code&gt;settings.json&lt;/code&gt; structure, matchers, exit-code protocol, &lt;code&gt;hookSpecificOutput&lt;/code&gt;/&lt;code&gt;additionalContext&lt;/code&gt;, security warning, &lt;code&gt;allowManagedHooksOnly&lt;/code&gt;). Accessed 2026-07-07.&lt;/li&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Automate actions with hooks&lt;/a&gt; (worked configuration examples).&lt;/li&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Context window, compaction, and &lt;code&gt;/compact&lt;/code&gt;&lt;/a&gt;. Accessed 2026-07-07.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>llmagents</category>
      <category>agentmemory</category>
    </item>
    <item>
      <title>Building Vectr, Part 2: What /compact Destroys and How to Survive It</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:28:51 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/building-vectr-part-2-what-compact-destroys-and-how-to-survive-it-hml</link>
      <guid>https://dev.to/swapnanilsaha/building-vectr-part-2-what-compact-destroys-and-how-to-survive-it-hml</guid>
      <description>&lt;p&gt;Session three of a bug hunt in CPython's garbage collector. Two sessions in, I had what felt like a solid map: the exact call chain from &lt;code&gt;PyObject_GC_Del&lt;/code&gt; through the generational collector, the non-obvious invariant around finalizer ordering, the three files where the relevant logic lived. Then &lt;code&gt;/compact&lt;/code&gt; fired.&lt;/p&gt;

&lt;p&gt;The summary said something like: "we were investigating CPython's garbage collector, specifically the interaction between finalizers and the generational GC." Accurate. Useless. The exact function signatures were gone. The specific line numbers were gone. The invariant that took two sessions to understand — compressed to one sentence that had lost all the nuance. The next 20 minutes: re-reading files to rebuild what I already knew.&lt;/p&gt;

&lt;p&gt;This post is about what I learned from that, and from the working memory system I built to prevent it. &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; covered the indexing layer — how &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt; finds things in a codebase semantically. This part covers what happens after you find something: how to keep the knowledge alive across session boundaries, why my initial design was wrong in a fundamental way, and what actually works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem With /compact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What /compact Actually Destroys
&lt;/h3&gt;

&lt;p&gt;Most people treat &lt;code&gt;/compact&lt;/code&gt; as "clear the context to keep going." That framing is roughly correct but understates the damage. The issue isn't just that context gets shorter — it's that the compression is lossy in exactly the cases where being wrong is most expensive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/compact&lt;/code&gt; works by asking the AI to summarize the current conversation, then replacing the full history with that summary. Token count drops from (say) 180,000 to 12,000. Here's what the summary doesn't preserve:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact function signatures.&lt;/strong&gt; A summary might say "the function takes a path and a flag." The conversation had &lt;code&gt;def process_workspace_changes(path: Path, db: Database, *, force: bool = False) -&amp;gt; list[ChangeResult]&lt;/code&gt;. The difference between those two descriptions is the difference between a valid call site and a runtime error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specific line numbers.&lt;/strong&gt; "The resolver module" and &lt;code&gt;/src/workspace/resolver.rs:214&lt;/code&gt; are not the same precision. You can reconstruct the file path, but it costs you a tool call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-obvious behavioral invariants.&lt;/strong&gt; If you spent three turns establishing that &lt;code&gt;acquire_lock()&lt;/code&gt; must be called &lt;em&gt;before&lt;/em&gt; touching workspace metadata because there's a race condition with the filesystem watcher, that three-turn understanding might survive as "be careful with locking." The exact invariant — the one that matters when you're writing the code — is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reasoning chain.&lt;/strong&gt; Sometimes the value of an exploration session isn't the final answer but the chain of observations that produced it. Summaries discard chains. They keep endpoints.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Summaries are fine for preserving topics and general direction. They fail specifically at exact signatures, line numbers, and subtle behavioral invariants — which is also where being wrong is most expensive. A summary of "be careful with locking" covers the topic. It doesn't tell you which function must be called first, or why, or what breaks if you get it wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the CPython scenario, re-establishing the finalizer ordering invariant from scratch means re-reading several files and re-following a non-obvious call chain — roughly 15–20 minutes of work that was already done. A note stored at the end of session two takes about a minute to write and ten milliseconds to retrieve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why You Can't Tell the AI to Just Forget Things
&lt;/h3&gt;

&lt;p&gt;When I started building Vectr's memory layer, I had a clean model: the AI finds something useful, stores it with &lt;code&gt;vectr_remember&lt;/code&gt;, then &lt;em&gt;drops the file from its context window&lt;/em&gt;. The note is 50 tokens. The file was 800 tokens. Net gain: 750 tokens freed for new content. I called this "context offload."&lt;/p&gt;

&lt;p&gt;I built it this way. I wrote documentation describing it this way. I designed &lt;code&gt;vectr_evict_hint&lt;/code&gt; entirely around it.&lt;/p&gt;

&lt;p&gt;It doesn't work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KV cache is append-only.&lt;/strong&gt; Think of the transformer's memory as a lookup table it builds as it reads each token. For each token it processes, it computes a key-value representation that gets stored at each attention layer. Every subsequent token attends back to every previous token through these cached representations — that's how earlier context influences later output.&lt;/p&gt;

&lt;p&gt;Once a token's representation is computed and cached, it stays until the context is cleared. There is no mechanism to evict specific tokens by instruction. "You can drop chunk X from your context window" is itself processed as tokens — added to the cache, not used to remove other entries from it.&lt;/p&gt;

&lt;p&gt;A subtlety worth naming: the KV cache is maintained server-side by the inference provider. What you see as "context window usage" is a count of tokens in the current conversation, not a direct readout of GPU memory. The principle holds regardless: every token in the conversation occupies a slot in the cache, and you cannot remove individual tokens from a running session without ending or compressing the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KV cache memory cost formula:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV cache size = 2 × L × n_heads × d_head × T × bytes_per_float
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a representative mid-size model: L=32 layers, n_heads=32, d_head=128, T=50,000 tokens at fp16 (2 bytes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 × 32 × 32 × 128 × 50,000 × 2 = 13.1 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache grows &lt;em&gt;linearly&lt;/em&gt; with sequence length T. No selective removal. The operations that genuinely reduce context are: end the session (total loss), use /compact (precision loss), or rely on provider-side prefix caching — which stores stable prefix representations like system prompts to avoid recomputing them, but doesn't remove anything from your active context budget.&lt;/p&gt;

&lt;p&gt;I measured context window usage before and after sequences of &lt;code&gt;vectr_remember&lt;/code&gt; + &lt;code&gt;vectr_evict_hint&lt;/code&gt; calls: essentially unchanged. The hint was adding tokens to the cache while accomplishing nothing at the context management level. In some cases it made things marginally worse.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Any tool or documentation claiming "store to external memory to free context budget" is describing something the system cannot deliver. Tokens in a live context window cannot be selectively evicted. Working memory tools are genuinely valuable — but not for freeing active context. Building around that claim confuses your benchmarks and misleads anyone using the tool.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2: What Working Memory Actually Does
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three Tiers of Value
&lt;/h3&gt;

&lt;p&gt;Once I dropped the context-offload framing, the actual value of &lt;code&gt;vectr_remember&lt;/code&gt; became clear. It operates on three time horizons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — In-session re-read avoidance.&lt;/strong&gt; Within a single session, before any /compact: recalling a stored note costs ~50 tokens instead of re-reading the original file at ~600 tokens. Real savings, but the file is still sitting in your context window anyway. Genuinely useful, but not the reason to build this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — /compact survival.&lt;/strong&gt; When /compact compresses the conversation, notes stored on disk (SQLite + ChromaDB) are untouched. Exact signatures and behavioral invariants survive verbatim. The session resumes from actual precision. This is where the system earns its cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — Cross-session persistence.&lt;/strong&gt; Between separate sessions — the editor closed and reopened — the AI starts with nothing. Notes survive. A new session calling &lt;code&gt;vectr_status()&lt;/code&gt; + &lt;code&gt;vectr_recall()&lt;/code&gt; recovers findings from sessions ago without re-reading a single file. Each session builds on the ones before it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy — The surgeon's notes:&lt;/strong&gt; A surgeon takes detailed notes before starting a complex procedure. Halfway through, an emergency calls them away for two hours. When they return: (a) their notes are on the desk — exact measurements, named vessels, where they left off; or (b) a colleague wrote a summary: "patient is partially through a vascular procedure, some complications noted." Option (b) is dangerous. Option (a) lets you continue precisely. &lt;code&gt;vectr_remember&lt;/code&gt; is option (a). /compact without notes is option (b).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tier 3 compounds in a way that's easy to underestimate. The first session on a complex codebase pays the discovery cost. The second benefits from the first session's notes. By the tenth session, a well-maintained note store is a persistent model of the codebase that makes every session faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to Store and How
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Don't store file pointers.&lt;/strong&gt; "See &lt;code&gt;resolver.rs:214&lt;/code&gt; for the lock implementation" is a bad note. File paths change during refactoring. Line numbers drift with every edit. A pointer hasn't captured what you &lt;em&gt;learned&lt;/em&gt; — it's a reference. When you recall it, you still have to read the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store the finding itself:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;WorkspaceLock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;defined&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;06&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="py"&gt;.vectr_lock&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;writes&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;deleting&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;Err&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mismatch&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;intentional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;bug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="n"&gt;BEFORE&lt;/span&gt; &lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="n"&gt;workspace&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="py"&gt;. The&lt;/span&gt; &lt;span class="n"&gt;filesystem&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="n"&gt;reads&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;
  &lt;span class="n"&gt;without&lt;/span&gt; &lt;span class="n"&gt;holding&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="n"&gt;fires&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="py"&gt;.
  This&lt;/span&gt; &lt;span class="n"&gt;caused&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;race&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt; &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="mf"&gt;1247.&lt;/span&gt;

&lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="n"&gt;callsites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;89&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;203&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This note is ~120 tokens. Reading the relevant files to reconstruct this knowledge would cost 600+ tokens plus two turns. The note captures the actual insight — the non-obvious invariant about lock order — not just a pointer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority and tags are not cosmetic.&lt;/strong&gt; &lt;code&gt;priority&lt;/code&gt; affects recall ordering: high-priority notes rank higher when multiple notes match a query with similar scores. &lt;code&gt;tags&lt;/code&gt; enable filtered recall — &lt;code&gt;vectr_recall(query="locking", tags=["concurrency"])&lt;/code&gt; returns only notes tagged with "concurrency" that semantically match the query. In a large note store accumulated over months, filtering by subsystem makes recall precise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Bugs That Shaped the Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The B9 Bug: When Recall Doesn't Recall
&lt;/h3&gt;

&lt;p&gt;For several early benchmark runs, &lt;code&gt;vectr_recall&lt;/code&gt; was firing in implementation sessions but returning nothing useful — 0 relevant results across 5 separate sessions on CPython tasks, even though the research session had stored detailed notes about exactly the functions being modified.&lt;/p&gt;

&lt;p&gt;Root cause: recall was using SQL LIKE queries, not semantic search.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The broken implementation (pre-B9)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM notes WHERE content LIKE ? LIMIT 20&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQL LIKE is substring matching. &lt;code&gt;vectr_recall("garbage collector finalizer ordering")&lt;/code&gt; would only return notes containing that exact string. A note about &lt;code&gt;PyObject_GC_Del&lt;/code&gt; describing finalizer behavior — stored with different wording in a different session — wouldn't match.&lt;/p&gt;

&lt;p&gt;The fix: use the ChromaDB vector store for recall. Notes are embedded when stored, retrieved by semantic similarity when recalled.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The correct implementation (post-B9)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chroma_collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_texts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;n_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tags&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;$in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_chroma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Impact was immediate: &lt;code&gt;vectr_recall&lt;/code&gt; fired with relevant results in 4 of 6 implementation sessions in the CPython re-run, compared to 0 of 6 before. This bug sat undetected because the initial benchmark design didn't make empty recalls visible. Per-tool logging — "vectr_recall called 5 times, 5 empty responses" — made it obvious.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; SQL LIKE requires the query string to be a literal substring of the stored content. For anything more than exact-match lookup, it's not just suboptimal — it's functionally broken for most real queries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  vectr_evict_hint: What It Actually Does After the Reframe
&lt;/h3&gt;

&lt;p&gt;After fixing the context-offload misconception, I kept &lt;code&gt;vectr_evict_hint&lt;/code&gt; but reframed it completely. What it actually does: it tracks the cumulative token cost of all code chunks Vectr has retrieved in the current session. When this cost crosses a threshold (40K tokens &lt;em&gt;or&lt;/em&gt; 20 tool calls — whichever fires first), it appends a hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[vectr_evict_hint] You've retrieved ~42,000 tokens of indexed chunks
this session. The following chunks are fully indexed and re-retrievable
in &amp;lt;50ms — no need to re-read these files later:

  - resolver.rs:214  WorkspaceLock::acquire  (retrieved 8 turns ago)
  - resolver.rs:267  WorkspaceLock::release  (retrieved 8 turns ago)
  - workspace.rs:89  init call site          (retrieved 5 turns ago)

Consider calling vectr_remember now if you have key findings you
haven't stored yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word is "re-retrievable," not "droppable." The hint doesn't claim to free tokens. It tells the AI: these files are in the index, you can get them back in under 50ms if you need them — don't re-read out of caution when you already have what you need or could re-search instantly. It's a behavioral nudge, not a memory management operation.&lt;/p&gt;

&lt;p&gt;The threshold values come from MemGPT (arXiv:2310.08560), which found models begin exhibiting "lost in the middle" degradation at roughly 70% context fill. Using a disjunction (first threshold reached triggers the hint) keeps it from firing too late on sessions that accumulate few large files but many small searches.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lost in the middle:&lt;/strong&gt; LLM performance on retrieval tasks follows a U-shaped curve over context position — accuracy highest at the beginning and end, degrading for content in the middle. The evict_hint threshold is set to fire before relevant information drifts into that degraded zone.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 4: The Mechanics of Actually Using It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Save-Moment Problem
&lt;/h3&gt;

&lt;p&gt;Knowing notes are valuable doesn't make the AI store them. In early sessions, &lt;code&gt;vectr_remember&lt;/code&gt; call rates were low — not because the AI couldn't see the tool, but because there was no clear trigger for "now is the moment to save this."&lt;/p&gt;

&lt;p&gt;Saving notes is a habit humans develop from experiencing loss. An AI editor in session 1 has never lost anything to /compact here — it's optimizing for the task in front of it, not a compression event that might happen three hours from now.&lt;/p&gt;

&lt;p&gt;The solution: making the save-moment explicit and concrete in the &lt;code&gt;CLAUDE.md&lt;/code&gt; template that Vectr writes into a workspace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**The moment you find a key definition, pattern, or non-obvious detail:**&lt;/span&gt;
call vectr_remember(content, tags=[...], priority="high"|"medium"|"low")
— store the actual code block or finding, not a file pointer.

Treat every vectr_search or vectr_locate call as a &lt;span class="gs"&gt;**pair**&lt;/span&gt;: search,
then immediately save the key finding before your next retrieval.

If /compact runs later, the conversation summary loses exact signatures
and line numbers — your note does not.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Pair every search with a save" turned out to be the most effective framing. Not "save when it feels important" (too vague), but "pair every retrieval with a note" (concrete, immediate trigger). Sessions that stored the most notes also had the lowest re-discovery costs in subsequent tasks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to search: the SR-RAG finding.&lt;/strong&gt; The pair pattern addresses when to save. There's a complementary question that ended up in the same CLAUDE.md template: when to search at all. Before calling &lt;code&gt;vectr_search&lt;/code&gt; on a well-known API or framework, the AI should first write out what it already knows and only search if genuine gaps remain.&lt;/p&gt;

&lt;p&gt;This comes from SR-RAG (arXiv:2504.01018). The finding: models often retrieve information already baked in from training, adding token cost without improving answer quality. Writing out what you already know before searching reduces unnecessary calls by 26–40% on familiar codebases. On an unfamiliar codebase, the AI's training knowledge rarely applies — every search turns up something new. On well-known frameworks, training knowledge is often more accurate than indexed documentation. The verbalization step surfaces which situation you're actually in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snapshots: Checkpointing an Investigation
&lt;/h3&gt;

&lt;p&gt;Beyond individual notes, there's a use case for checkpointing entire session states. &lt;code&gt;vectr_snapshot("lock-subsystem-mapped")&lt;/code&gt; seals the current note set under a named label with a timestamp. &lt;code&gt;vectr_snapshot_list()&lt;/code&gt; at session start shows all checkpoints.&lt;/p&gt;

&lt;p&gt;Typical multi-session workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exploration sessions:&lt;/strong&gt; explore, call &lt;code&gt;vectr_remember&lt;/code&gt; on each key finding. Pair every search with a save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration complete:&lt;/strong&gt; &lt;code&gt;vectr_snapshot("exploration-complete")&lt;/code&gt;. Seals the note state for this phase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation sessions:&lt;/strong&gt; &lt;code&gt;vectr_status()&lt;/code&gt; → &lt;code&gt;vectr_recall(query)&lt;/code&gt; → build on the snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation done:&lt;/strong&gt; &lt;code&gt;vectr_snapshot("implementation-done")&lt;/code&gt;. Two named checkpoints marking the arc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revisiting months later:&lt;/strong&gt; &lt;code&gt;vectr_snapshot_list()&lt;/code&gt; shows the investigation history. The snapshot timestamp tells you which notes were established before a given change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  When Notes Are Wrong: vectr_forget
&lt;/h3&gt;

&lt;p&gt;Notes can be wrong. A note about function behavior written before a refactor may describe the old behavior. Stale notes are worse than no notes — false confidence in outdated information.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vectr_forget(note_id)&lt;/code&gt; deletes it. Every &lt;code&gt;vectr_recall&lt;/code&gt; response includes note IDs alongside the content so you can act on them inline. The workflow: recall → verify against current code → forget the stale note → store the updated one.&lt;/p&gt;

&lt;p&gt;Vectr also appends a &lt;code&gt;[STALE]&lt;/code&gt; marker automatically when a file path extracted from a note's content no longer exists in the workspace. The extraction is a regex scan for path-like strings — when those paths disappear from the file tree, the note gets flagged. It only catches path-level staleness, not behavioral changes in files that kept their names.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; The [STALE] marker fires when a referenced file path disappears. It does NOT fire when file content changes. A note about function behavior after a refactor that renamed the file gets flagged; a note about function behavior after a refactor that changed the logic without renaming gets no warning. Always verify behavioral notes against current code before acting on them for implementation work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Design Principle I'd Rephrase
&lt;/h3&gt;

&lt;p&gt;Looking back at the original Vectr documentation for working memory, almost every sentence led with the wrong framing. "Store to vectr, then drop from context." "Offload findings to free context budget." "Context offload layer." Every one of these is technically false, and I shipped all of them.&lt;/p&gt;

&lt;p&gt;The correct version is shorter: store findings now so you can recall them precisely later. Through /compact. Through a new session. Through however many turns separate the discovery from the moment you need to use it. The value is in the later. The storing is cheap. The recalling is where you get the hours back.&lt;/p&gt;

&lt;p&gt;If I were writing the documentation from scratch I'd lead with the /compact scenario — with the specific moment when a detailed understanding of a complex system compresses into a three-sentence summary that can't be acted on. That's the moment where a stored note is worth exactly what it cost to write it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The part I haven't answered yet: does any of this actually save time? Not in the abstract — in real benchmarks, on real codebases, compared against an AI editor with no indexing and no memory. The number I care about is not total session cost (which includes upfront research overhead that inflates the naive comparison) but re-discovery cost per task across repeated sessions on the same codebase.&lt;/p&gt;

&lt;p&gt;Part 3 covers that measurement — including why the total sprint cost comparison is almost exactly the wrong metric to report, and what the data from CPython, Django, and Apache Camel actually showed once I separated research overhead from implementation savings.&lt;/p&gt;

&lt;p&gt;If you want to try &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt; now, the tool page has setup instructions. The full working memory layer — &lt;code&gt;vectr_remember&lt;/code&gt;, &lt;code&gt;vectr_recall&lt;/code&gt;, &lt;code&gt;vectr_snapshot&lt;/code&gt;, &lt;code&gt;vectr_forget&lt;/code&gt; — is in the current release alongside the semantic search tools from &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Packer et al., &lt;em&gt;MemGPT: Towards LLMs as Operating Systems&lt;/em&gt;, arXiv:2310.08560, 2023&lt;/li&gt;
&lt;li&gt;Liu et al., &lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;, arXiv:2307.03172, 2023&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Self-Routing RAG: Binding Selective Retrieval with Knowledge Verbalization&lt;/em&gt;, arXiv:2504.01018, 2025&lt;/li&gt;
&lt;li&gt;Vaswani et al., &lt;em&gt;Attention Is All You Need&lt;/em&gt;, NeurIPS 2017&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A Survey on LLM Acceleration Based on KV Cache Management&lt;/em&gt;, arXiv:2412.19442, 2024&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llmtools</category>
      <category>mcp</category>
      <category>developertools</category>
      <category>workingmemory</category>
    </item>
    <item>
      <title>Building Vectr, Part 1: Why grep Fails When You Don't Know the Keywords</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 09 Jun 2026 14:10:06 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/building-vectr-part-1-why-grep-fails-when-you-dont-know-the-keywords-17e7</link>
      <guid>https://dev.to/swapnanilsaha/building-vectr-part-1-why-grep-fails-when-you-dont-know-the-keywords-17e7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 1 of the Building Vectr series (1 of 3).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You get dropped into an unfamiliar codebase. Not a toy project — real production code, 8,000 files, three years of accumulated complexity and clever abstractions. Your job is to fix a bug in the request validation pipeline. What does an AI code editor do next?&lt;/p&gt;

&lt;p&gt;This post is about a problem I kept running into, a tax I kept paying, and the indexing system I built to eliminate it. It covers the technical decisions behind &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt;'s search layer: why naive chunking produces bad embeddings, how tree-sitter solves the code-parsing problem, what BM25 does that vector search can't, and why you need a symbol graph for questions that text search cannot answer at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1 — The Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Re-discovery Tax
&lt;/h3&gt;

&lt;p&gt;If you're a human engineer navigating an unfamiliar codebase, here's what you probably do: you ask someone who knows it, or you grep for the error message, or you open the entry point and follow imports until you find the thing. Your brain does semantic compression the whole way — building a model of the system, discarding noise, following intuitions about where complexity tends to live. By the time you've read 20 files, you have a rough map that persists across days and sessions.&lt;/p&gt;

&lt;p&gt;An AI code editor has the same tools — read files, run shell commands, grep — but completely different economics. Every &lt;code&gt;Read&lt;/code&gt; call costs tokens. Every &lt;code&gt;Bash&lt;/code&gt; call for grep costs a turn. Unlike a human who can skim-read at 1,000 words per minute and discard irrelevant content almost for free, an AI editor pays full price for every character it reads: it sits in the context window whether or not it was useful. Read the wrong 500-line file and you've burned context that could have held the answer.&lt;/p&gt;

&lt;p&gt;The result, on unfamiliar codebases, is what I started calling the &lt;strong&gt;re-discovery tax&lt;/strong&gt;: a cluster of navigation calls at the start of every session, before any actual implementation begins, spent on figuring out where things are. And because AI editors have no persistent memory between sessions, they pay this tax again and again — every session, on the same codebase.&lt;/p&gt;

&lt;p&gt;In benchmarks I ran against real open-source codebases (more detail in Part 3), the re-discovery tax on CPython internals ranged from &lt;strong&gt;6 to 23 tool calls per task&lt;/strong&gt; before the first file write. Some sessions spent more turns navigating than implementing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key observation:&lt;/strong&gt; The re-discovery tax is paid every session, not once. A human engineer's mental map of a codebase accumulates and compounds. An AI editor's map is fully rebuilt from scratch at the start of each session. The economic gap widens as the codebase grows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why grep Fails at the Boundary of Your Knowledge
&lt;/h3&gt;

&lt;p&gt;Before explaining what I built, I want to be precise about where grep breaks down — because "just use grep" is the natural reaction, and it's not obviously wrong until you try to use it systematically on unfamiliar code.&lt;/p&gt;

&lt;p&gt;grep is a brilliant tool for confirming hypotheses you already have. If you know what you're looking for, it's nearly perfect. The problem is the case that isn't really an edge case: &lt;em&gt;you don't know what you're looking for.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Say you're trying to understand how a Django application validates incoming JSON payloads before they hit the ORM layer. You might grep for &lt;code&gt;validate&lt;/code&gt;. You'll get 200 results across 40 files — field validators, form validators, configuration validators, test fixtures. None of them are obviously the thing you want. You grep for &lt;code&gt;json.loads&lt;/code&gt;. You get 30 results. You grep for &lt;code&gt;request.data&lt;/code&gt;. That gets you closer, maybe. But you spent four greps and 15 minutes before you found the right file.&lt;/p&gt;

&lt;p&gt;The deeper problem: grep requires you to already have a mental model of the codebase's naming conventions. An AI editor running on an unfamiliar codebase doesn't know whether payload validation is called &lt;code&gt;validate_payload&lt;/code&gt;, &lt;code&gt;check_request&lt;/code&gt;, &lt;code&gt;parse_input&lt;/code&gt;, or &lt;code&gt;_pre_process&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy:&lt;/strong&gt; Think of keyword search as asking for directions by street name in a city you've never visited. "Where is Maple Street?" gets a precise answer. But "where is the street with the good coffee shop near the park?" — keyword search has nothing to offer. You need a different kind of index: one that understands &lt;em&gt;what places are for&lt;/em&gt;, not just what they're called.&lt;/p&gt;

&lt;p&gt;Semantic search inverts this. It maps your query and every code chunk into the same high-dimensional vector space, then finds the chunks closest to your query by meaning — regardless of whether they share any words. "JWT validation logic" finds &lt;code&gt;verify_token&lt;/code&gt; even if neither of those words appears in the function body.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2 — Building the Index
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Chunking Problem: Why Line Windows Break on Code
&lt;/h3&gt;

&lt;p&gt;Prose text has a natural unit of meaning: the paragraph. You can split a Wikipedia article into 200-word chunks, embed each one, and get a reasonable search system. Code doesn't work this way.&lt;/p&gt;

&lt;p&gt;The standard naive approach for code indexing is the same line-window strategy borrowed from document search: take a sliding window of N lines with M lines of overlap, create a chunk, embed it, move the window. A common default might be 150-line windows with 50 lines of overlap. Simple, language-agnostic, works on any file format.&lt;/p&gt;

&lt;p&gt;The problem is what happens at the window boundaries. Consider this function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_workspace_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Process all pending changes in a workspace, optionally forcing re-indexing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pending_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DELETED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove_chunks_for_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;removed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATED&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;language_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_changes_processed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a 150-line window happens to cut through this function, neither resulting chunk is independently meaningful. The chunk with just the body is missing the parameter names and return type. The chunk with just the signature has no implementation context. The embedding of a half-function is significantly worse than the embedding of the complete thing.&lt;/p&gt;

&lt;p&gt;The fix: split at semantic boundaries. Functions should be complete units. Classes should contain their methods, or each method should be its own chunk with the class header prepended for context.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why completeness matters:&lt;/strong&gt; An embedding model compresses everything in its context into a single fixed-size vector. A complete function gives the model everything it needs to capture the function's purpose, parameters, return behavior, and side effects in that vector. A half-function forces the model to compress an ambiguous fragment — the resulting vector is a blurred average of possible interpretations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Parsing Code with tree-sitter
&lt;/h3&gt;

&lt;p&gt;tree-sitter is a parser library that produces concrete syntax trees for source code — every construct in the language has a named node with exact byte boundaries in the source. Unlike a regex-based approach, tree-sitter actually parses the grammar and handles edge cases correctly: nested functions, decorators on multiple lines, multiline function signatures, arrow functions in JavaScript, generic bounds in Rust.&lt;/p&gt;

&lt;p&gt;For Python, the tree-sitter query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scheme"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function_definition&lt;/span&gt;
  &lt;span class="nv"&gt;name:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@name&lt;/span&gt;
  &lt;span class="nv"&gt;parameters:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@params&lt;/span&gt;
  &lt;span class="nv"&gt;body:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@function&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;class_definition&lt;/span&gt;
  &lt;span class="nv"&gt;name:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@name&lt;/span&gt;
  &lt;span class="nv"&gt;superclasses:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;argument_list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;@bases&lt;/span&gt;
  &lt;span class="nv"&gt;body:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@class&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches any function or class definition anywhere in the file and captures the name, parameters, and body as named nodes with precise byte-range positions. You can then slice the original source file at those byte positions to extract complete, syntactically valid chunks.&lt;/p&gt;

&lt;p&gt;For classes, Vectr attaches the full class signature — including the base class list captured by &lt;code&gt;@bases&lt;/code&gt; — as a header to each method chunk. So the chunk for &lt;code&gt;WorkspaceLock.acquire()&lt;/code&gt; includes its inheritance context. A method of &lt;code&gt;AuthenticatedView(LoginRequiredMixin, View)&lt;/code&gt; has a meaningfully different semantic context than a method of a plain &lt;code&gt;View&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A subtlety: very large functions.&lt;/strong&gt; AST-aware chunking breaks down for functions that are genuinely enormous — 500+ lines. Vectr handles this by further splitting large functions at their major control-flow boundaries (default threshold: 200 lines). The resulting sub-chunks each include the function signature as a header to preserve context. Their embedding quality is better than one giant embedding, though still lower than a naturally small function.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Code-Specific Embeddings Running Locally
&lt;/h3&gt;

&lt;p&gt;Not all embedding models are equally good at code. Models trained primarily on prose text have learned representations of natural language semantics. Code has different regularities: symbol names, type signatures, control flow patterns, API call chains. Code-aware models routinely outperform general-purpose models by 10–20% on tasks like "find the function that handles X."&lt;/p&gt;

&lt;p&gt;Vectr uses &lt;code&gt;Snowflake/snowflake-arctic-embed-m-v1.5&lt;/code&gt;, a 110-million-parameter model that produces 768-dimensional embedding vectors and runs in under 100ms per batch on a modern laptop CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why local inference instead of an API?&lt;/strong&gt; Two practical constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cost: a tool that fires 20–50 search calls per session would accumulate non-trivial API costs quickly. Local inference is free at query time after the one-time model download.&lt;/li&gt;
&lt;li&gt;Data privacy: many codebases cannot be sent to third-party APIs. Internal tools, proprietary algorithms, customer data models — many organizations have policies or contractual obligations that prohibit sending source code to external services.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff: the model weighs roughly 440MB and needs to be downloaded on first run. This is a real friction point.&lt;/p&gt;

&lt;p&gt;One critical detail: queries and chunks are embedded with different input prefixes. Queries use &lt;code&gt;Represent this query for searching relevant code:&lt;/code&gt;, chunks use &lt;code&gt;Represent this code snippet:&lt;/code&gt;. arctic-embed-m is a single encoder, but it was trained with different prefixes for query-side and document-side inputs. Using the wrong prefix reduces the cosine similarity between semantically related query-chunk pairs — the vectors for "user authentication" and &lt;code&gt;verify_token&lt;/code&gt; end up further apart in embedding space than they should be. Getting this wrong costs 5–15% in retrieval quality.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3 — The Search Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hybrid Search: Why BM25 and Vector Search Need Each Other
&lt;/h3&gt;

&lt;p&gt;Vector search handles concept queries well. But if you search for &lt;code&gt;_handle_workspace_lock_conflict&lt;/code&gt; — an exact function name — a vector search might not rank it first. The embedding is just one point in a crowded neighborhood of similar-looking function names. BM25, on the other hand, will find it immediately: exact string matches get the highest possible score.&lt;/p&gt;

&lt;p&gt;The inverse is also true: BM25 cannot find "retry logic with exponential backoff" if the function is called &lt;code&gt;_schedule_attempt_with_delay&lt;/code&gt; and its docstring says nothing about backoff. Zero keyword overlap means zero BM25 score. Vector search finds it because the semantic cluster it belongs to is close to the query in embedding space.&lt;/p&gt;

&lt;p&gt;The right system uses both. Every query in Vectr runs both a vector search and a BM25 search in parallel, then combines the two ranked lists using a weighted formula.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 scoring formula:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(D, Q) = Σᵢ IDF(qᵢ) · [ tf(qᵢ, D) · (k₁ + 1) ] / [ tf(qᵢ, D) + k₁ · (1 − b + b · |D| / avgdl) ]

IDF(qᵢ) = log( (N − nᵢ + 0.5) / (nᵢ + 0.5) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tf(qᵢ, D)&lt;/code&gt; — term frequency of qᵢ in document D&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;N&lt;/code&gt; — total documents; &lt;code&gt;nᵢ&lt;/code&gt; — documents containing qᵢ&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;|D|&lt;/code&gt; — document length in tokens; &lt;code&gt;avgdl&lt;/code&gt; — average document length&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k₁ = 1.5&lt;/code&gt; (term-frequency saturation), &lt;code&gt;b = 0.75&lt;/code&gt; (length normalization)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the Robertson–Sparck Jones variant. Some implementations add +1 inside the IDF log to prevent negative values for very common terms.&lt;/p&gt;

&lt;p&gt;The weight assigned to each approach depends on codebase familiarity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;BM25 weight&lt;/th&gt;
&lt;th&gt;Vector weight&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Large unfamiliar codebase&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small familiar codebase&lt;/td&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit symbol name in query&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural language concept query&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These weights are the actual values used in Vectr's implementation, tuned against the benchmark dataset.&lt;/p&gt;

&lt;p&gt;The benchmark on Apache Camel (58,000+ Java files) showed a &lt;strong&gt;73% reduction in Read+Bash navigation calls&lt;/strong&gt; compared to the baseline AI editor with no index.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Symbol Graph: What Text Search Cannot Answer
&lt;/h3&gt;

&lt;p&gt;Semantic search and BM25 handle "find me the code for this concept" well. But there's a different navigation pattern that neither handles: "find me everything that calls this function."&lt;/p&gt;

&lt;p&gt;Vectr builds a symbol graph during indexing. For each file, tree-sitter extracts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Definitions&lt;/strong&gt; — every function, class, method, and module-level constant with name and line number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call edges&lt;/strong&gt; — every call site, mapping callee name to the calling function's context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import edges&lt;/strong&gt; — every import statement, mapping the imported symbol to its likely source module&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP routes&lt;/strong&gt; — Flask/FastAPI &lt;code&gt;@router.get()&lt;/code&gt;, Express &lt;code&gt;app.post()&lt;/code&gt;, Spring &lt;code&gt;@GetMapping&lt;/code&gt; — extracted as named symbols&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting graph enables exact lookups. &lt;code&gt;vectr_locate("WorkspaceLock")&lt;/code&gt; returns a file path and line number in under 10ms — no embedding, no ranking, pure symbol table lookup. &lt;code&gt;vectr_trace("acquire_lock")&lt;/code&gt; returns all callers and all callees in one round-trip. These are not search results — they are graph traversals, and they produce exact answers rather than relevance rankings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Text search vs. graph traversal:&lt;/strong&gt; These are not competing approaches — they answer different questions. "Find code that does X" is a search problem. "Find who calls Y" or "find where Z is defined" is a graph traversal problem. Relying only on text search for definition lookups is like looking up a phone number by describing the person rather than looking them up by name.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Six Fallback Strategies in vectr_locate
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;vectr_locate&lt;/code&gt; runs six fallback strategies in sequence, stopping at the first match:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exact match&lt;/strong&gt; — direct lookup in the symbol table. Sub-millisecond. Highest confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suffix match&lt;/strong&gt; — &lt;code&gt;Lock&lt;/code&gt; matches &lt;code&gt;WorkspaceLock&lt;/code&gt;, &lt;code&gt;AcquireLock&lt;/code&gt;, &lt;code&gt;LockManager&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same-module priority&lt;/strong&gt; — if a caller file is provided, search definitions within the same module first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique name&lt;/strong&gt; — if there is exactly one symbol across the entire codebase whose name contains your query string, return it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import chain follow&lt;/strong&gt; — follow import statements from a given file to find where the name likely comes from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuzzy (Levenshtein ≤ 2)&lt;/strong&gt; — edit distance ≤ 2 across all symbol names. Catches typos. Lowest confidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each strategy produces a &lt;code&gt;LocateResult&lt;/code&gt; with a &lt;code&gt;resolution_strategy&lt;/code&gt; field. An exact match means you can act on the result immediately. A fuzzy match with edit distance 2 means you should verify before relying on it. A silent wrong navigation is worse than no navigation at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4 — The Runtime Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  mtime Cache and Incremental Re-indexing
&lt;/h3&gt;

&lt;p&gt;The first time you run &lt;code&gt;vectr start&lt;/code&gt; on a large codebase, indexing takes time. CPython's 4,000+ files: about 8 minutes. Django's ~1,800 Python files: about 2 minutes. Apache Camel's 58,000+ Java files: closer to 45 minutes.&lt;/p&gt;

&lt;p&gt;During initial indexing, Vectr writes a file at &lt;code&gt;~/.cache/vectr/{hash}/index_cache.json&lt;/code&gt; that stores the modification timestamp of every indexed file. The &lt;code&gt;{hash}&lt;/code&gt; is a short SHA-256 hash of the absolute workspace root path. On subsequent runs, only files whose mtime has changed are re-indexed. On a typical active session where you've modified 5–10 files, subsequent re-indexing takes under 5 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handling deletions:&lt;/strong&gt; Vectr also stores the complete set of indexed file paths. At startup, it diffs this set against the current file tree and removes all chunks belonging to deleted files before re-indexing modified ones. Process deletions first, then updates, then new files — this prevents a renamed file from leaving orphaned chunks in the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The watchdog listener:&lt;/strong&gt; During an active session, Vectr runs a watchdog filesystem listener on the workspace root. When a file is saved, the listener queues it for re-indexing in the background. Events are debounced at 300ms — only the last write in a burst counts. Without debouncing, a single save in a project using aggressive auto-formatting would trigger 3–5 redundant re-index operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  .vectrignore: Keeping the Index Clean
&lt;/h3&gt;

&lt;p&gt;Vectr reads a &lt;code&gt;.vectrignore&lt;/code&gt; file from the workspace root using glob patterns. The syntax follows &lt;code&gt;.gitignore&lt;/code&gt; conventions — trailing slash for directories, &lt;code&gt;*&lt;/code&gt; for single-level wildcard, &lt;code&gt;**&lt;/code&gt; for recursive match (via Python's &lt;code&gt;pathlib.Path.match()&lt;/code&gt;) — but Vectr does not implement the full gitignore specification: the &lt;code&gt;!&lt;/code&gt; negation prefix is not supported.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vendor/
node_modules/
dist/
*.pb.go        # generated protobuf Go files
*.min.js       # minified JavaScript
__pycache__/
.venv/
coverage/
*.snap         # Jest snapshots
migrations/    # Django database migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A codebase with &lt;code&gt;node_modules/&lt;/code&gt; will typically contain 5–20x more code from installed packages than from the project itself. Excluding vendor directories before the initial index run is the single most impactful configuration change most users can make.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Happens When You Call vectr_search
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Query string is embedded using arctic-embed-m with query prefix
   → 768-dimensional float vector, ~15ms on CPU

2. Vector similarity search against ChromaDB store
   → Top-20 chunks by cosine similarity, with scores

3. Same query runs through BM25 index (rank-bm25, in-memory)
   → Top-20 chunks by BM25 score, with scores

4. Two ranked lists are merged
   → Weight BM25/vector based on codebase characterization
   → Normalized scores combined; top-N results selected (default N=5)

5. Symbol names in the query are detected (camelCase, snake_case, PascalCase)
   → If found: also run vectr_locate as a side channel
   → Merge symbol lookup results into final output if relevant

6. Final top-N chunks returned with:
   file path, start line, end line, matched text, search method
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result for &lt;code&gt;vectr_search("workspace lock acquisition and release")&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] resolver.rs:214 — WorkspaceLock::acquire()
    Acquires the workspace-scoped lock. Blocks if another process holds it.

[2] resolver.rs:267 — WorkspaceLock::release()
    Releases the workspace-scoped lock. Validates that the current process
    holds the lock before releasing (returns Err if not held).

[3] workspace.py:89 — _acquire_workspace_lock(path)
    Context manager: acquires, yields, releases on exit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of reading 15 files to find these three functions, the AI editor reads one search result.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5 — Design Decisions I'd Make Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Python 3.14 requirement.&lt;/strong&gt; The codebase uses &lt;code&gt;match/case&lt;/code&gt; pattern matching extensively and some &lt;code&gt;asyncio&lt;/code&gt; patterns that behave differently in earlier versions. In retrospect, 3.11 would probably work with a few hours of refactoring. The 3.14 requirement has been the single biggest adoption friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChromaDB as the vector store.&lt;/strong&gt; A vector store handles embedding persistence and similarity search. ChromaDB works, but the full HNSW index with persistence, the Python client layer, and the inter-process communication overhead add about 200ms specifically to ChromaDB's startup contribution — not total Vectr startup (~280ms including mtime diffing and watchdog initialization). For v2, I'd consider a lighter in-process option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The BM25 implementation.&lt;/strong&gt; The &lt;code&gt;rank-bm25&lt;/code&gt; library is pure Python and fast enough for codebases under 50,000 chunks. Beyond that, it starts to show latency. The right long-term solution is integrating BM25 scoring directly into the vector store query pipeline. For current use cases (most codebases are under 20K chunks), it's fine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The indexing layer is the foundation, not the product. What it enables is an AI code editor that can navigate a large unfamiliar codebase as efficiently as a human engineer who has worked in it for months — finding the right functions in one or two calls instead of fifteen.&lt;/p&gt;

&lt;p&gt;But the index tells you &lt;em&gt;where things are&lt;/em&gt;. It doesn't tell you &lt;em&gt;why things are the way they are&lt;/em&gt; — the non-obvious invariants, the patterns that emerge from reading 50 files, the bugs that were fixed by changing two lines in a place that looks completely unrelated.&lt;/p&gt;

&lt;p&gt;That's what Part 2 addresses: a note store where an AI editor can save findings in structured, tagged form — "the lock acquisition logic is at resolver.rs:214, and it acquires an exclusive file lock using fcntl.flock, not a threading primitive" — and retrieve them in under 50ms at the start of any future session. When &lt;code&gt;/compact&lt;/code&gt; runs and replaces the conversation with a summary, exact signatures and line numbers evaporate — but notes don't. The indexer tells you where to look. The working memory layer tells you what you already know about what you found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary of core decisions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Rationale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AST-aware chunking via tree-sitter&lt;/td&gt;
&lt;td&gt;Complete functions as the unit of meaning. Biggest quality improvement over naive line windows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local embeddings (arctic-embed-m)&lt;/td&gt;
&lt;td&gt;No API cost, no data leaving the machine. One-time 440MB download.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid BM25 + vector search&lt;/td&gt;
&lt;td&gt;Concept queries route to vector. Exact symbol names route to BM25.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symbol graph&lt;/td&gt;
&lt;td&gt;Definitions, call edges, import edges, HTTP routes — exact graph traversal for questions text search cannot answer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Six fallback strategies in vectr_locate&lt;/td&gt;
&lt;td&gt;Exact → suffix → same_module → unique_name → import_chain → fuzzy. Each result carries its resolution strategy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mtime cache + watchdog&lt;/td&gt;
&lt;td&gt;Sub-5-second re-indexing on subsequent runs. In-session saves trigger background re-indexing automatically.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>mcp</category>
      <category>semanticsearch</category>
      <category>developertools</category>
      <category>codeindexer</category>
    </item>
    <item>
      <title>LLM Context Window Token Budget: Why Your Window Fills Up Fast</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 26 May 2026 19:59:18 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/llm-context-window-token-budget-why-your-window-fills-up-fast-4c05</link>
      <guid>https://dev.to/swapnanilsaha/llm-context-window-token-budget-why-your-window-fills-up-fast-4c05</guid>
      <description>&lt;p&gt;You build something with GPT-4o. The model supports 128,000 tokens. You think: that's enough for a full novel. Then, four or five conversation turns in, the model starts forgetting things that were said earlier. Eight turns in, you hit an error. You check the token count — you've used over 100,000 tokens, and you've typed maybe 400 words.&lt;/p&gt;

&lt;p&gt;This isn't a bug. It's the predictable consequence of not accounting for where those tokens actually go. A context window isn't blank space waiting to be filled with your words. By the time the first user message arrives, it is already partially consumed — by system instructions, by tool definitions, by retrieved documents, by the tokens the model itself generated in earlier turns. In a production AI agent, 30–60% of the context window is gone before a user types anything.&lt;/p&gt;

&lt;p&gt;What follows is a precise accounting of where those tokens go — the four layers that consume the window before users say anything, why the effective limit is substantially lower than the advertised one, what happens to response quality as the window approaches capacity, and which engineering patterns actually manage it at production scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Illusion of Abundance
&lt;/h3&gt;

&lt;p&gt;GPT-4o supports 128K tokens. Claude 3.5 supports 200K. Gemini 1.5 Pro has been demonstrated at a million tokens — roughly 750,000 words, about ten average novels. The numbers sound absurdly generous. How could you possibly run out?&lt;/p&gt;

&lt;p&gt;Start with a calibration exercise. What is 128,000 tokens, actually?&lt;/p&gt;

&lt;p&gt;In English prose, one token is roughly four characters — about three-quarters of a word. A 1,000-word article runs to around 1,300 tokens, so 128K tokens can hold close to 96,000 words of clean text. That genuinely is a lot.&lt;/p&gt;

&lt;p&gt;But text in an LLM application is rarely clean English prose. It is JSON payloads from tool calls. It is API responses full of structured data. It is code. It is URLs. It is conversation history with speaker labels, timestamps, and formatting. All of these serialize into tokens at rates much higher than 4 characters per token.&lt;/p&gt;

&lt;p&gt;Then there is the question of performance. The advertised number represents a technical limit — the longest sequence the model can physically process. It does not represent the length at which the model operates at peak accuracy. Research has repeatedly found a significant gap between the two. Long-context benchmarks like RULER (2024) and HELMET (2024) found that in adversarial multi-document tasks, most frontier LLMs showed accuracy drops well before 32K tokens — GPT-4o fell from near-perfect baseline scores to the high-60s percentage range at 32K in some configurations. The technical limit says 128K. The accuracy cliff arrives much earlier.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Effective Limit Is Not the Advertised Limit&lt;/strong&gt;&lt;br&gt;
Models claiming 200K context windows show measurable quality degradation around 130K tokens in practice. Treating the advertised number as your operating budget is how production systems quietly degrade without triggering any explicit error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cost is the third angle. Every token in the context is a token billed. At GPT-4o's pricing, 128K tokens of input costs several dollars per call — and agents often make dozens of calls per session, each with the full accumulated context. The monthly bill from a badly-managed context window can surprise you well before any error appears in the logs.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. How Tokens Are Counted — and Why the Count Surprises You
&lt;/h3&gt;

&lt;p&gt;An LLM does not read text. It reads a sequence of integers. Before any word reaches the model, it passes through a tokenizer that converts characters into integer IDs from a vocabulary of roughly 50,000–200,000 entries. The tokenizer used by GPT-4 and GPT-4o is called &lt;code&gt;cl100k_base&lt;/code&gt;; it has about 100,000 vocabulary entries. OpenAI's newer models use &lt;code&gt;o200k_base&lt;/code&gt;, with about 200,000.&lt;/p&gt;

&lt;p&gt;The vocabulary is built using &lt;strong&gt;BPE&lt;/strong&gt; — Byte Pair Encoding. The name comes from the construction: you start with individual characters, then repeatedly merge the pair of adjacent symbols that appears most often in your training corpus, replacing each occurrence of that pair with a new combined token. Do this enough times and common English words end up as single tokens. The algorithm learns what to merge entirely from what was common in the training text — mostly English prose on the internet. That's why "the", "is", "running" each become a single token, while "tokenization" becomes &lt;code&gt;["token", "ization"]&lt;/code&gt; — less common as a whole word, so BPE never fully merged it. Characters and raw bytes are the fallback for anything the vocabulary doesn't cover. The consequence is simple: anything that wasn't well-represented in training data — JSON brackets, URL slashes, code indentation — never got merged aggressively, so those sequences remain expensive in tokens relative to the characters they contain.&lt;/p&gt;

&lt;p&gt;The rule-of-thumb of 1 token ≈ 4 characters holds for clean English prose — decent enough for napkin estimates. It falls apart under several conditions that appear constantly in real applications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers tokenize unexpectedly.&lt;/strong&gt; BPE learns tokens from frequency in training data. The number "2023" is common in training data — it became a single token. But "2026" is less common, and "19847" is rare — these get split into per-digit or per-pair tokens. The price "USD 1,234,567.89" produces approximately 10–12 tokens, because the commas, period, digits, and currency symbol may each claim separate tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URLs are disproportionately expensive.&lt;/strong&gt; A URL like &lt;code&gt;https://api.example.com/v2/users/12345&lt;/code&gt; looks compact — 38 characters, which by the prose rule should be about 9–10 tokens. In practice it is closer to 15–20 tokens. Slashes, dots, hyphens, underscores, and alphanumeric path segments each claim their own tokens or merge into small fragments, because URLs are structurally uncommon in prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON and structured data use roughly 2x the token count of plain text.&lt;/strong&gt; Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plain text: The user's name is Alice, she is 28 years old, and her account is active.
JSON:       {"user": {"name": "Alice", "age": 28, "status": "active"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plain text version: approximately 18 tokens. The JSON version: approximately 22 tokens — and this is a trivially small object. Real API responses with deeply nested keys, repeated field names, and verbose formatting can be far more expensive. Every brace, colon, and comma is a token or part of a token. A 500-word JSON payload can use 800+ tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code tokenizes inefficiently in some languages.&lt;/strong&gt; Research found that Python uses roughly 46% more tokens than equivalent Haskell to express the same computational idea. Python's indentation-based structure requires whitespace tokens, and Python's identifiers were less densely represented in the pre-GPT-4 training corpora.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy: The Luggage Weight Problem&lt;/strong&gt;&lt;br&gt;
Think of the context window as checked baggage with a weight limit, not a size limit. A suitcase full of dense sweaters weighs less than one with foam packing material filling the same volume. Plain prose is the dense sweaters — you pack a lot of meaning into few tokens. JSON, URLs, and code are the foam — structurally bulky, meaning-sparse, yet they count toward the same limit.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2: The Consumers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3. The Four Layers That Eat Your Context Window
&lt;/h3&gt;

&lt;p&gt;Every LLM API call is a full context payload assembled from four distinct layers. Most developers think about only one: the user's current message. The other three arrive already loaded — silent costs that accumulate before the user types anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: The System Prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The system prompt is the foundational layer. It is always present, on every API call. A minimal system prompt — "You are a helpful assistant" — costs about 7 tokens. But real production system prompts are not minimal.&lt;/p&gt;

&lt;p&gt;A typical customer-facing chatbot system prompt contains: the model's persona and tone guidelines, a list of topics it should and should not address, instructions about response format, domain-specific knowledge, legal disclaimers, and formatting instructions. Measured in practice, these range from 800 to 2,500 tokens. They are charged on every single API call. A 1,500-token system prompt running 1,000 calls per day costs you 1.5 million input tokens per day before a user says anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Tool Schemas&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you give an LLM access to external tools, you must describe each tool to the model in the context window. These descriptions are written in JSON and can be verbose. A single moderately documented tool schema costs roughly 200 tokens. An agent with five tools carries around 1,000 tokens of tool descriptions on every call, before any user input. The JSON structure alone — all those braces, colons, and quoted keys — is part of why the token cost is higher than reading the description would suggest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Retrieved Context (RAG)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Many production LLM applications retrieve relevant documents from a database and inject them as supporting material. A typical RAG retrieval returns 3–8 document chunks, each 300–600 tokens. Three chunks at 400 tokens each: 1,200 tokens. Eight chunks at 500 tokens each: 4,000 tokens. In a research assistant with a generous retrieval budget, you might inject 8,000–12,000 tokens of context per query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Hidden Fixed Cost&lt;/strong&gt;&lt;br&gt;
System prompt + tool schemas is your fixed cost floor. It doesn't change turn-to-turn. It can easily reach 2,000–4,000 tokens in a real agent — charged on every single API call in your fleet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Layer 4: Conversation History&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model has no persistent memory. You create the illusion of memory by re-sending the full conversation history on every API call. Every turn appends two new entries (a user message and a model response) to a history that is re-sent in its entirety. Model responses can be long — a detailed answer with a code snippet might be 600–800 tokens. After ten exchanges, the conversation history alone can be 8,000–12,000 tokens.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Context Creep — Watching the Window Fill
&lt;/h3&gt;

&lt;p&gt;The process by which a context window fills over a conversation has a name in production systems: &lt;strong&gt;context creep&lt;/strong&gt;. Consider a realistic customer support agent: 1,200-token system prompt, three tool schemas totaling 600 tokens, RAG retrieval returning two chunks (~800 tokens per turn), user messages averaging 60 tokens, model responses averaging 350 tokens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Context budget:
  Fixed overhead: 1,200 + 600 = 1,800 tokens
  Per-turn RAG:   800 tokens
  Per-turn history growth: 60 (user) + 350 (model) = 410 tokens

  Turns until 80% of 128K:
    (1,800 + n × 800 + n × 410) ≥ 102,400
    n × 1,210 ≥ 100,600
    n ≈ 84 turns

  If model reply averages 800 tokens instead:
    Per-turn growth: 60 + 800 = 860
    n × 1,660 ≥ 100,600
    n ≈ 60 turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the model reply length to 800 tokens — a detailed-answer agent — and the window hits 80% around turn 60 rather than 84. Quality degradation begins before you hit the hard limit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Physics
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5. KV Cache Memory — Why Context Has a Physical Cost
&lt;/h3&gt;

&lt;p&gt;The context window limit is not an arbitrary policy. It is enforced by physics — GPU memory.&lt;/p&gt;

&lt;p&gt;The transformer's attention mechanism works by comparing every token in the context with every other token. For each token, the model creates a query ("what am I looking for?"), and every other token offers a key ("what do I contain?"). A third vector — the value — carries the actual information that gets passed when attention is high. Assembled across all tokens, these become the matrices &lt;strong&gt;Q&lt;/strong&gt;, &lt;strong&gt;K&lt;/strong&gt;, and &lt;strong&gt;V&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The QKᵀ product is an n × n matrix where n is the sequence length. Doubling n quadruples this computation.&lt;/p&gt;

&lt;p&gt;There are two distinct computational phases in LLM inference. &lt;strong&gt;Prefill&lt;/strong&gt; processes the entire input prompt at once — O(n²) per attention layer. Implementations like FlashAttention reduce the memory bandwidth pressure dramatically via tiled computation, but the asymptotic complexity doesn't change. &lt;strong&gt;Decode&lt;/strong&gt; generates one token at a time, attending only to the current token against the cached history — O(n) per step with the KV cache. Without caching, decode would also be O(n²). The KV cache converts decode from O(n²) to O(n) at the cost of memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV Cache Memory Formula (Multi-Head Attention):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV_memory = 2 × n_layers × n_heads × d_head × seq_len × bytes_per_param
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 7B-parameter model with standard MHA (32 layers, 32 heads, head_dim 128) at bfloat16 (2 bytes):&lt;/p&gt;

&lt;p&gt;KV_memory per token ≈ 2 × 32 × 32 × 128 × 1 × 2 = 524,288 bytes ≈ 0.5 MB&lt;/p&gt;

&lt;p&gt;At 128K context: 0.5 MB × 128,000 = &lt;strong&gt;64 GB&lt;/strong&gt; of KV cache alone — more than the model weights at bfloat16 (~14 GB).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note on GQA and MLA:&lt;/strong&gt; Most modern models (Llama 3, Mistral, GPT-4o) use Grouped-Query Attention (GQA), which reduces the KV cache by sharing key-value heads across groups of query heads. A model with 32 query heads and 8 KV heads (4× reduction) brings the per-token cache from ~0.5 MB to ~0.125 MB — about 16 GB at 128K context. Still the dominant memory consumer at long contexts. DeepSeek-class models use Multi-head Latent Attention (MLA), which compresses the K and V projections into a low-rank latent space before storing them, achieving 5–10× memory reduction over standard MHA.&lt;/p&gt;

&lt;p&gt;A 70B MHA model (80 layers, 64 heads, head_dim 128, bfloat16) runs to roughly &lt;strong&gt;2.5 MB per token&lt;/strong&gt;: 2 × 80 × 64 × 128 × 2 bytes = 2,621,440 bytes. At 128K context that's ~320 GB — which is why providers either cap context length aggressively for large models, or charge steeply for long-context calls. GQA with 8 KV heads drops it to ~40 GB, still substantial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt caching&lt;/strong&gt; (available from OpenAI, Anthropic, Google) caches the computed KV activations for repeated prompt prefixes. Subsequent calls beginning with the same prefix pay 50–75% less for those cached tokens and benefit from lower latency because the prefill phase for the cached portion is skipped. A stable system prompt is an ideal caching candidate. One practical constraint: both OpenAI and Anthropic require a minimum prefix length of at least 1,024 tokens before caching activates. A 200-token system prompt won't benefit — another reason to consolidate instructions into one substantial block rather than spreading them across multiple small messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV cache quantization&lt;/strong&gt; is an active area of production optimization: storing the K and V tensors in lower-precision formats (int8 or int4) cuts KV cache memory by 2–4× with modest accuracy penalties. Research like KVQuant explores going to 2-bit precision for certain layers while targeting 10M-token contexts on commodity hardware.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Lost in the Middle — Why Performance Collapses Before You Hit the Limit
&lt;/h3&gt;

&lt;p&gt;Memory is the first constraint. Attention quality is the second — and it bites you even when your window is half-empty.&lt;/p&gt;

&lt;p&gt;In 2023, researchers at Stanford and UC Berkeley published "Lost in the Middle." They gave LLMs a task requiring them to find a specific document from a set of twenty documents, all injected into the context window. The position of the relevant document was varied systematically.&lt;/p&gt;

&lt;p&gt;When the relevant document was first or last, models retrieved it accurately. When it was in the middle positions, accuracy dropped by more than 30%. Newer models — Claude 3.5, GPT-4o — have partially mitigated this bias through long-context fine-tuning. "Partially" is doing a lot of work there: independent evaluations continue to find meaningful position-dependent performance gaps in all current models, even at lengths well within their advertised limits.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy: The Lecture Hall Effect&lt;/strong&gt;&lt;br&gt;
Students reliably remember a lecture's opening and closing. What happened in the middle of hour one is murky. LLMs have an analogous concentration pattern: strong attention to the beginning and end of the context, with a trough in the middle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The mechanism is structural. RoPE (Rotary Position Embedding), used in most modern architectures, encodes position as a rotation applied to query and key vectors. The mathematical property of this rotation is that the similarity score between two vectors naturally decreases as the distance between their positions increases. At short contexts, the decay is a feature. At long contexts, it becomes a bug: tokens in the middle of a 100K-token window are thousands of positions away from both the beginning and from where the model is currently generating, so their similarity scores are systematically suppressed.&lt;/p&gt;

&lt;p&gt;A separate effect, &lt;strong&gt;context dilution&lt;/strong&gt;, compounds this: longer surrounding irrelevant context degrades performance even when the relevant content is guaranteed present. The model's attention distributes across noise, reducing effective attention for the signal — like finding one red marble in a bag of ten thousand, even knowing it's there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A Subtle RAG Bug&lt;/strong&gt;&lt;br&gt;
If your RAG system retrieves 8 documents and inserts them in the middle of a long conversation history, the most relevant chunks may be in the attention trough. The model generates a response, you see no error, but the answer doesn't reflect those documents. The failure is silent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 4: Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  7. Token Budget Math — Calculating Your Real Available Space
&lt;/h3&gt;

&lt;p&gt;Every LLM application needs an explicit token budget with five zones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Typical token range&lt;/th&gt;
&lt;th&gt;Fixed or variable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System Prompt&lt;/td&gt;
&lt;td&gt;500–2,500&lt;/td&gt;
&lt;td&gt;Fixed per application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Schemas&lt;/td&gt;
&lt;td&gt;200–400 per tool&lt;/td&gt;
&lt;td&gt;Fixed per agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG Context&lt;/td&gt;
&lt;td&gt;0–12,000&lt;/td&gt;
&lt;td&gt;Variable per turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation History&lt;/td&gt;
&lt;td&gt;0 → grows&lt;/td&gt;
&lt;td&gt;Grows each turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation Reserve&lt;/td&gt;
&lt;td&gt;500–2,000&lt;/td&gt;
&lt;td&gt;Reserved explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The generation reserve must be reserved explicitly — if your prompt consumes the entire window, the model either generates nothing or truncates its response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A worked example.&lt;/strong&gt; Customer support agent, GPT-4o (128K):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total window:          128,000 tokens
System prompt:          -1,400 tokens  (measured)
Tool schemas (4 tools):   -800 tokens  (measured)
Generation reserve:     -1,500 tokens  (set by us)
─────────────────────────────────────────
Available for dynamic:  124,300 tokens

  Of that:
    RAG budget:           20,000 tokens  (5 chunks × 4,000 avg)
    History budget:       ~104,300 tokens (fills over time)

  ─────────────────────────────────────────
  Turns until 80% full:
    80% of 128K = 102,400 prompt tokens
    Fixed overhead = 1,400 + 800 = 2,200
    Per-turn RAG = 800
    Per-turn growth = user avg (60) + model avg (350) = 410
    Turns until (2,200 + n × 800 + n × 410) ≥ 102,400
    n × 1,210 ≥ 100,200
    n ≈ 82 turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;82 turns sounds comfortable. But this assumes constant 350-token model replies. A user who triggers several detailed answers can double the history growth rate, cutting that to ~41 turns before the 80% threshold.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Measure, Don't Estimate&lt;/strong&gt;&lt;br&gt;
The system prompt and tool schema token counts must be measured with the actual tokenizer, not estimated from character counts. Log &lt;code&gt;prompt_tokens&lt;/code&gt; and &lt;code&gt;completion_tokens&lt;/code&gt; from every API response. The distribution of &lt;code&gt;prompt_tokens&lt;/code&gt; over time is your context growth curve.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  8. Four Strategies for Managing Context Window Limits
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strategy 1: Sliding Window&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep only the most recent turns of conversation verbatim. In production, truncate by token count, not turn count — a 5-turn history could range from 500 to 8,000 tokens depending on response lengths.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Turn-count version — simple, good enough for prototyping
&lt;/span&gt;&lt;span class="n"&gt;MAX_HISTORY_TURNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;trimmed_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;MAX_HISTORY_TURNS&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Context:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context_block&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed_history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;

&lt;span class="c1"&gt;# Production version — truncate by token count, not turn count
# HISTORY_TOKEN_BUDGET = context_limit - fixed_costs - generation_reserve
# Example for 128K window: 128000 - 2200 (sys+tools) - 1500 (reserve) - 20000 (RAG) ≈ 104000
&lt;/span&gt;&lt;span class="n"&gt;HISTORY_TOKEN_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt;  &lt;span class="c1"&gt;# adjust for your application
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_messages_token_bounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fixed_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;new_msg_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HISTORY_TOKEN_BUDGET&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fixed_tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;new_msg_tokens&lt;/span&gt;

    &lt;span class="c1"&gt;# Walk history from newest to oldest, keep what fits
&lt;/span&gt;    &lt;span class="n"&gt;trimmed_rev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;turn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;turn_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;turn_tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;trimmed_rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;turn_tokens&lt;/span&gt;
    &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed_rev&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Context:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drawback of the sliding window is abrupt forgetting: when turn 1 drops, any fact established there is simply gone. For short-lived task-completion agents, this is fine. For long-running conversational assistants, it creates visible gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 2: Hierarchical Summarization&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep recent turns verbatim; compress older turns into a rolling summary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_compress_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;verbatim_turns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;turns_to_summarize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;turns_to_summarize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;

    &lt;span class="n"&gt;new_summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Existing summary: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;New exchanges to incorporate:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;format_turns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turns_to_summarize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Update the summary to include these exchanges. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Preserve all concrete facts, decisions, and commitments. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drop conversational filler. Be dense. Max ~400 tokens.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;verbatim_turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_summary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap the summary at 200–400 tokens. Run summarization asynchronously — don't make the user wait for the compression cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 3: Token Compression (LLMLingua)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use a compression model to identify and remove low-entropy tokens from prompts, achieving 2–3× compression with minor accuracy loss. The most effective targets are verbose system prompts, RAG context chunks, and few-shot examples.&lt;/p&gt;

&lt;p&gt;Never apply compression to the current user message — compressing user input changes their meaning before the model sees it. Test in your specific domain for tasks where precision matters (legal, medical, code).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 4: Embedding-based Retrieval Over History&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Store each conversation turn as a dense vector. At each new turn, embed the current user message and retrieve the most relevant prior turns by similarity. Concretely: as each turn completes, embed the user + assistant text and store it in a vector store alongside the full text. On the next user message, embed it, search for top-k similar turns, inject those into context. Keep only 2–3 verbatim recent turns for coherence.&lt;/p&gt;

&lt;p&gt;The effect: only the conversation history relevant to the current question enters the context window. A user asking "what was the budget we discussed?" triggers retrieval of those turns — even if they happened fifty exchanges ago. This requires an embedding model, a vector store, and a retrieval call per user message (adding roughly 50–150ms round-trip with a managed API, under 10ms with a self-hosted model).&lt;/p&gt;

&lt;p&gt;The four strategies are not mutually exclusive. Production systems often combine them: a sliding window of 5–8 verbatim turns + rolling summary + retrieval from older history covers all distance scales simultaneously.&lt;/p&gt;




&lt;h3&gt;
  
  
  9. The Practical Playbook
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Short task-completion agents (under 20 turns):&lt;/strong&gt; Use a sliding window of 10–15 turns. Reserve optimization effort for fixed-cost reduction: audit your system prompt for redundant language, consider dynamic tool registration (load only the tools relevant to the current turn).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running conversational assistants:&lt;/strong&gt; Implement hierarchical summarization with 8–12 verbatim turns. Cap summaries at 400 tokens. Run asynchronously. Periodically audit system prompt size — prompt creep through edits is real. A prompt that started at 600 tokens can quietly grow to 3,000 across six months of product changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document-heavy research assistants (heavy RAG):&lt;/strong&gt; Limit retrieval to 3–5 top chunks. Apply token compression to chunks before injection. Sort retrieved chunks so the most relevant appears last in the injected block — adjacent to the user question, within the recency attention peak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production agents with many tools:&lt;/strong&gt; Use dynamic tool registration. A routing classifier (even a keyword matcher) identifies which tools are needed before the main model call and includes only those schemas — reducing 2,000 tokens of tool overhead to ~400 on most turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context ordering (exploit the attention curve):&lt;/strong&gt; Instead of the framework default (system → history → RAG → user), use: system → recent history (most-recent last) → RAG chunks (most relevant last, adjacent to the user message) → current user message. The most relevant content sits at the end of the context, within the recency attention peak. Older history — the least relevant content — occupies the lower-attention middle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to monitor:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prompt_tokens / context_limit&lt;/code&gt; — alert above 70%, act above 80%&lt;/li&gt;
&lt;li&gt;Token count by zone per call — when total grows, know which zone is responsible&lt;/li&gt;
&lt;li&gt;Quality signals segmented by context utilization — you may find degradation starts at 60% in your application&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion: The Window Is a System Resource
&lt;/h2&gt;

&lt;p&gt;A context window isn't a document store you fill until it overflows. It's a compute and memory resource with hard physical limits, a quality curve that degrades well before those limits, and an inference cost that grows with every token you put in it.&lt;/p&gt;

&lt;p&gt;In a typical agent, the window is 30–60% consumed before the first user message lands. The fix isn't a bigger context window, though headroom helps. It's building a real budget: measure each zone with an actual tokenizer, set hard limits per zone, implement a context manager that enforces those limits on every call, and track utilization in production dashboards the same way you'd track memory or CPU.&lt;/p&gt;

&lt;p&gt;The attention degradation problem — "lost in the middle" — adds a second dimension: even when your window is not full, quality depends on where in the window the important information sits. The primacy bias and recency bias are real, measurable effects that application design can exploit or fall victim to.&lt;/p&gt;

&lt;p&gt;The four strategies aren't competitors — most production systems end up combining them. Sliding window for the recent turns, rolling summary for the older ones, compression for the RAG chunks, and retrieval for anything that needs to survive beyond the window. Start with the simplest thing that doesn't break your use case, and add layers as your traffic and conversation length grow.&lt;/p&gt;

&lt;p&gt;Context engineering doesn't have the glamour of prompt engineering, but it's where most production LLM failures actually live. Missed retrievals, incoherent multi-turn conversations, bloated inference bills — these trace back to context mismanagement more often than they trace back to the wrong model. It fails silently, which is exactly why it's easy to ignore until you can't.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Research Papers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Liu et al., &lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;"Lost in the Middle: How Language Models Use Long Contexts"&lt;/a&gt; — Stanford / Berkeley / Samaya AI, 2023. The original paper quantifying the U-shaped attention bias across context positions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2406.16008" rel="noopener noreferrer"&gt;"Found in the Middle: Calibrating Positional Attention Bias"&lt;/a&gt; — 2024. Proposes an architectural fix to the lost-in-the-middle problem, recovering up to 15pp accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2510.05381" rel="noopener noreferrer"&gt;"Context Length Alone Hurts LLM Performance Despite Perfect Retrieval"&lt;/a&gt; — 2025. Demonstrates context dilution: longer irrelevant context degrades performance even when the relevant content is guaranteed present.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2401.18079" rel="noopener noreferrer"&gt;"KVQuant: Towards 10M Context Length LLM Inference with KV Cache Quantization"&lt;/a&gt; — 2024. Explores per-channel quantization of the KV cache to enable extreme context lengths on commodity hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Technical References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.openai.com/docs/guides/conversation-state" rel="noopener noreferrer"&gt;OpenAI — Managing Conversation State&lt;/a&gt; — Official docs on conversation history management and token counting.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/context-windows" rel="noopener noreferrer"&gt;Anthropic — Context Window Documentation&lt;/a&gt; — Claude context limits, caching strategies, and best practices.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/LLMLingua" rel="noopener noreferrer"&gt;LLMLingua — Prompt Compression&lt;/a&gt; — Microsoft Research open-source project for token-level prompt compression.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mbrenndoerfer.com/writing/kv-cache-memory-calculation-llm-inference-gpu" rel="noopener noreferrer"&gt;KV Cache Memory: Calculating GPU Requirements for LLM Inference&lt;/a&gt; — Interactive calculator for KV cache memory requirements given model architecture parameters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Background Reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tianpan.co/blog/2025-11-11-managing-token-budgets-production-llm-systems" rel="noopener noreferrer"&gt;The Hidden Costs of Context: Managing Token Budgets in Production LLM Systems&lt;/a&gt; — TianPan.co, 2025. Production-focused survey of context management challenges.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://redis.io/blog/context-window-management-llm-apps-developer-guide/" rel="noopener noreferrer"&gt;Context Window Management for LLM Apps: Developer Guide&lt;/a&gt; — Redis, 2025. Practical implementation patterns for context management in production.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;The Complete Guide to Text Embeddings, Vector Databases &amp;amp; LLMs&lt;/a&gt; — Swapnanil Saha, 2026. Deep background on tokenization, BPE, transformer attention, and RAG pipelines referenced throughout this post.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why AI Code Assistants Waste Context — and How RAG Fixes It</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 26 May 2026 19:25:33 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/why-ai-code-assistants-waste-context-and-how-rag-fixes-it-1gej</link>
      <guid>https://dev.to/swapnanilsaha/why-ai-code-assistants-waste-context-and-how-rag-fixes-it-1gej</guid>
      <description>&lt;p&gt;Open a large file in your AI code assistant and ask it to refactor a function buried three hundred lines down. Watch it confidently produce something plausible but wrong — using an interface that was deprecated last sprint, calling a helper that doesn't exist in this service, ignoring a constraint in the module-level docstring that it technically "saw." The model didn't forget. The information was technically present in the prompt, but the transformer's attention mechanism never meaningfully focused on it. That's a different kind of failure, and it doesn't get better with a bigger context window.&lt;/p&gt;

&lt;p&gt;There's a persistent intuition in this industry that more context is always better. Send the whole file. Send the whole codebase. This intuition breaks in a specific and measurable way. The mechanism is called &lt;strong&gt;attention dilution&lt;/strong&gt; — softmax normalization means that every token in the context competes for a fixed budget of attention weight, and as the sequence grows longer, any given piece of information gets a smaller share of that budget.&lt;/p&gt;

&lt;p&gt;This post walks through the transformer attention math to explain exactly why the naive approach fails, then covers how RAG (Retrieval-Augmented Generation) addresses it — by retrieving only the specific code chunks relevant to the current task and injecting those into the context window instead of dumping everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem with Stuffing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Naive Approach: Just Send Everything
&lt;/h3&gt;

&lt;p&gt;The first instinct when building a code assistant is to send as much context as possible. Your project has a utility module? Include it. There's a shared type definitions file? Throw that in too. If the model's context window is 128,000 tokens, fill it to the brim — more information has to be better, right?&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;context window stuffing&lt;/strong&gt;. Three things go wrong with it, and each gets worse as the codebase grows. The first is attention dilution — the focus of this section. The second is position bias (Section 3). The third is raw cost (Section 4). To understand why these happen, you need a concrete model of how a transformer actually reads a prompt.&lt;/p&gt;

&lt;p&gt;A transformer does not read a prompt sequentially, the way a human reads a page from left to right. Instead, it processes all tokens &lt;em&gt;simultaneously&lt;/em&gt;, and every token attends to every other token in the sequence. The attention mechanism is the machine that computes how much each token should "look at" every other token when forming its representation.&lt;/p&gt;

&lt;p&gt;The output of attention for a single token is a weighted average of all the other tokens' value vectors. The weights are computed by comparing the current token's &lt;em&gt;query&lt;/em&gt; vector against every other token's &lt;em&gt;key&lt;/em&gt; vector. When you add more tokens to the context, you are not adding more information to a receptive mind — you are adding more competitors for a fixed budget of attention weight.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy:&lt;/strong&gt; Imagine you are in a room full of people, all talking at once. You can only pay 100 percent of your attention total — it does not grow with the number of people. With 5 people in the room, each gets roughly 20% of your focus. With 500, each gets 0.2%. When the relevant person finally says something, their share of your attention has collapsed to noise. That is what happens to code buried in a long prompt.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2. Why Attention Dilutes: The Math
&lt;/h3&gt;

&lt;p&gt;The attention mechanism was introduced in the paper "Attention Is All You Need" (Vaswani et al., 2017). Its core computation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attention(Q, K, V) = softmax( QK^T / √d_k ) · V
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Q&lt;/code&gt; — the query matrix (what each token is "asking for")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;K&lt;/code&gt; — the key matrix (what each token "offers" for comparison)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;V&lt;/code&gt; — the value matrix (the actual content passed forward if selected)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d_k&lt;/code&gt; — the dimension of the key vectors (scales to prevent extreme dot products)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;softmax&lt;/code&gt; — converts a vector of raw scores into a probability distribution that sums to 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The notation &lt;code&gt;QK^T&lt;/code&gt; means: for each token, compute a dot product between its query vector and every other token's key vector. The dot product is large when two vectors point in the same direction (high relevance between the pair), and near zero when they are orthogonal (unrelated). Multiplying by the transposed key matrix &lt;code&gt;K^T&lt;/code&gt; does all N×N such comparisons in a single matrix operation. The result is a matrix of raw relevance scores. Dividing by &lt;code&gt;√d_k&lt;/code&gt; prevents those scores from becoming so large that softmax saturates.&lt;/p&gt;

&lt;p&gt;The softmax step is the dilution mechanism. Because softmax always outputs a probability distribution — all values sum to exactly 1 — attention weights are a zero-sum resource. When there are N tokens in the context, the &lt;em&gt;average&lt;/em&gt; attention weight is 1/N, regardless of what any individual token does. The total budget is fixed at 1.0.&lt;/p&gt;

&lt;p&gt;This does not mean every token gets exactly equal attention — the model can still concentrate on a small subset if the dot-product scores separate those tokens sharply from the rest. Softmax is non-linear and can be quite aggressive when there is a large score gap between relevant and irrelevant tokens. But in a real codebase, that gap is rarely clean. Hundreds of unrelated function definitions produce hundreds of tokens with moderately non-zero dot products — they're not completely irrelevant, they just aren't what you need right now. These tokens collectively consume most of the softmax budget. The useful signal must compete against this crowd, and as N grows, the signal's share degrades continuously. It isn't a cliff; it's a steady erosion that compounds with each additional file you stuff in.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; The context window limit is not just a practical engineering constraint — it reflects a genuine quality degradation. The problem is not that the model &lt;em&gt;cannot read&lt;/em&gt; long inputs. It is that as context grows, every individual piece of information receives proportionally less attention weight. More input does not mean more comprehension; it means each fact competes harder for finite attentional resources.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3. Lost in the Middle: Position Bias
&lt;/h3&gt;

&lt;p&gt;Attention dilution is one problem. A second, independent problem compounds it: &lt;strong&gt;position bias&lt;/strong&gt;. Modern language models do not attend to all positions in their context with equal reliability. They preferentially attend to tokens at the beginning and end of the sequence, and perform significantly worse on information placed in the middle.&lt;/p&gt;

&lt;p&gt;This phenomenon was studied in a 2023 paper by Nelson Liu et al. titled &lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;. The researchers tested models on multi-document question answering, varying the position of the document containing the answer. When the answer document was at position 1 or last, accuracy was high. When it was at position 10 of 20 documents, accuracy dropped by more than 30 percentage points — even though the information was technically within the model's context window.&lt;/p&gt;

&lt;p&gt;Two mechanisms contribute. The first is &lt;strong&gt;RoPE&lt;/strong&gt; (Rotary Position Embeddings), the positional encoding scheme in most modern open-source language models (LLaMA, Mistral, GPT-NeoX). RoPE encodes position by rotating the query and key vectors by angles proportional to their positions. The dot product between a query at position &lt;em&gt;m&lt;/em&gt; and a key at position &lt;em&gt;n&lt;/em&gt; includes a term that decays with relative distance (m−n) — semantically relevant tokens far from the query position must overcome a rotational penalty to receive attention weight. Tokens near the start of the sequence are close to almost every other position, giving them a structural advantage.&lt;/p&gt;

&lt;p&gt;The second mechanism is &lt;strong&gt;causal training recency bias&lt;/strong&gt;. Language models are trained to predict the next token given all previous tokens. This reward signal pushes models to weight recent tokens heavily — the immediately preceding context is almost always the most relevant signal for next-token prediction during training. The middle of a long context rarely dominated training gradients, so models systematically underweight it. This effect was documented in GPT-3.5 era models well before RoPE became standard — it isn't purely an artifact of positional encoding, it's baked into causal pretraining. Both effects run in the same direction: the middle of a long context is structurally disadvantaged.&lt;/p&gt;

&lt;p&gt;A 2024 paper from UW, MIT, and Google (&lt;em&gt;Found in the Middle&lt;/em&gt;) demonstrated that this bias can be partially corrected by calibrating attention weights at inference time — but this requires modifying the model's internals, which is not available when calling an API.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common Mistake:&lt;/strong&gt; Many teams inject retrieved chunks at the &lt;em&gt;end&lt;/em&gt; of the prompt, after a long system prompt and conversation history. This lands retrieved content in a position that gets the worst of both worlds: far from the beginning (losing the primacy advantage) and not at the very end (which is reserved for the generation target itself). The safest placement for retrieved code context is &lt;strong&gt;immediately before the user's specific question&lt;/strong&gt;, near the end but not buried in the middle of a long history.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  4. The Quadratic Cost Problem
&lt;/h3&gt;

&lt;p&gt;Even if you were willing to accept degraded attention quality, there is a third reason not to stuff context: the compute cost of attention scales &lt;strong&gt;quadratically&lt;/strong&gt; with sequence length.&lt;/p&gt;

&lt;p&gt;To compute the full attention matrix, the model must compare every token's query against every other token's key. If your sequence has N tokens, this requires N × N comparisons. Doubling the context length quadruples the compute required for attention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time complexity of full self-attention: O(N² · d)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 4× increase in context length → 16× increase in attention compute. A 10× increase → 100×.&lt;/p&gt;

&lt;p&gt;FlashAttention (Dao et al., 2022) improves the &lt;em&gt;memory&lt;/em&gt; profile to O(N) via tiling — it never writes the full N×N matrix to GPU memory. But the number of floating-point operations is still O(N²). Latency and cost still scale quadratically with sequence length.&lt;/p&gt;

&lt;p&gt;In production, a code assistant filling 100,000 tokens of context is not just 10× slower than one filling 10,000 tokens — it is closer to 100× more expensive in attention compute alone. You are paying more to get worse results.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: How RAG Fixes It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5. RAG at a Glance: The Core Idea
&lt;/h3&gt;

&lt;p&gt;Retrieval-Augmented Generation reframes the problem. Instead of asking "how can we give the model the whole codebase?", it asks: "how do we figure out which parts of the codebase are relevant to this specific completion request, and send only those?"&lt;/p&gt;

&lt;p&gt;The answer has two phases. First, an &lt;strong&gt;offline indexing phase&lt;/strong&gt; where the codebase is processed, divided into chunks, and each chunk is converted into a vector representation (an embedding) that captures its semantic meaning. These vectors are stored in an index optimized for fast similarity search. Second, an &lt;strong&gt;online retrieval phase&lt;/strong&gt; that happens at query time: the developer's current context is converted into a query vector, and the most similar chunks from the index are retrieved and injected into the prompt.&lt;/p&gt;

&lt;p&gt;The model then receives a context window that is not a random cross-section of the codebase — it is the small set of pieces most likely to be relevant to the task at hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse &amp;amp; Chunk&lt;/strong&gt; — split at function/class boundaries, not arbitrary token counts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed Chunks&lt;/strong&gt; — convert each chunk to a vector with a code embedding model&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Search Index&lt;/strong&gt; — ANN index for dense retrieval + BM25 index for lexical retrieval&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed the Query&lt;/strong&gt; — convert current cursor context to a query vector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieve Top-k&lt;/strong&gt; — run hybrid search (dense + BM25), fuse results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject &amp;amp; Generate&lt;/strong&gt; — inject top 3–5 chunks into the LLM prompt, immediately before the user's request&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–3 happen once (or on incremental file changes). Steps 4–6 happen on every completion request. The parts where most implementations go wrong: chunking (using fixed-size splits instead of AST boundaries), retrieval (using only dense search and missing exact identifier queries), and injection order (burying retrieved context in the middle of the prompt).&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Chunking for Code: Why Fixed-Size Fails
&lt;/h3&gt;

&lt;p&gt;Code has structure that text does not. A function is a unit of meaning. &lt;strong&gt;Fixed-size chunking&lt;/strong&gt; — splitting every file every 256 tokens — splits in the middle of functions, destroying logical units.&lt;/p&gt;

&lt;p&gt;Consider a Python function that is 80 lines long. With a 50-token chunk size, it gets split into chunks that look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk A: def process_payment(order_id, amount, currency="USD"):
    """Process a payment..."""
    conn = get_db_connection()
    try:
        txn = conn.begin_transaction(

Chunk B:   order_id=order_id,
  amount=amount,
  currency=currency
)    except DatabaseError as e:
        log_error(e)
        raise PaymentError(str(e))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither chunk represents the function accurately. The embedding of Chunk A does not represent "a payment processing function" — it represents a truncated fragment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AST-based chunking&lt;/strong&gt; uses tree-sitter to parse each file and extract logical units at language-defined boundaries: function definitions, class bodies, method groups. Each chunk's metadata includes file path, start line, end line, and node type. This metadata is as important as the chunk text itself — it tells the retrieval system where in the codebase this chunk lives.&lt;/p&gt;

&lt;p&gt;One practical addition: each chunk can be augmented with a small surrounding context for embedding purposes — the preceding import block, the class it belongs to, or the file's module-level docstring. This gives the embedding model enough context to produce a vector that reflects the chunk's role in the larger structure. The key is that this surrounding context is used only for embedding, not retrieved as part of the chunk text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The overlap trap in code:&lt;/strong&gt; Sliding window overlap (copying N tokens from one chunk into the next) is useful in prose. In code it often makes things worse: the overlap introduces duplicate logic into separate chunks, making embedding space crowded with near-identical vectors. For code, the recommended approach is to store a "parent context" chunk separately — always inject the enclosing class signature alongside any function chunk, rather than copying the previous function's body into the current chunk. The &lt;code&gt;Continue&lt;/code&gt; open-source IDE extension uses this approach.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  7. Retrieval Strategies: Dense, Sparse, and Why Code Needs Both
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Dense retrieval&lt;/strong&gt; converts query and each chunk to vectors, then finds the most similar by cosine similarity. It can match meaning even when exact words differ — "how do we handle rate limit errors?" surfaces functions named &lt;code&gt;throttle_on_429&lt;/code&gt; or &lt;code&gt;backoff_retry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The embedding model used matters significantly. Code-specialized models like &lt;code&gt;voyage-code-3&lt;/code&gt; — purpose-built for code retrieval, top-ranked on code retrieval benchmarks (2025) — produce substantially better representations for function bodies, type signatures, and API calls than general-purpose models. &lt;code&gt;text-embedding-3-large&lt;/code&gt; is a strong general-purpose embedding model suited for mixed code + documentation retrieval, but it wasn't specifically designed around code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 (lexical/keyword retrieval)&lt;/strong&gt; counts words. It excels at exact matches — a developer looking for &lt;code&gt;PaymentGateway.process_refund&lt;/code&gt; will find it immediately. Error codes, configuration key names, and exact API method names are better retrieved lexically than semantically. For code, the asymmetry is important: queries for exact identifiers favor BM25. Queries for concepts and behaviors favor dense retrieval. The right system runs both.&lt;/p&gt;




&lt;h3&gt;
  
  
  8. Hybrid Search and Reciprocal Rank Fusion
&lt;/h3&gt;

&lt;p&gt;Running both methods produces two ranked lists that need combining. BM25 scores and cosine similarity scores live in completely different numerical ranges — you cannot add them directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; avoids the normalization problem entirely by ignoring raw scores and working only with ranks. The word "reciprocal" means 1/x — the score assigned to a document is the reciprocal of its rank in each list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF_score(d) = Σ_{r ∈ R} 1 / (k + rank_r(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;R&lt;/code&gt; = set of ranked lists (BM25 list, dense list)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rank_r(d)&lt;/code&gt; = position of document &lt;code&gt;d&lt;/code&gt; in list &lt;code&gt;r&lt;/code&gt; (1-indexed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k&lt;/code&gt; = smoothing constant (default 60, from Cormack, Clarke &amp;amp; Buettcher 2009 — empirically robust across many retrieval tasks). Increasing k makes the formula more conservative, rewarding consistent mid-rank appearances over a single strong rank.&lt;/li&gt;
&lt;li&gt;If a document does not appear in a list, its contribution from that list is 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A document ranked #1 in both lists scores ≈ 0.033. A document ranked #1 in one list but #100 in the other scores ≈ 0.022. Candidates that both BM25 and semantic search agree on float to the top.&lt;/p&gt;




&lt;h3&gt;
  
  
  9. Reranking: The Final Sorting Pass
&lt;/h3&gt;

&lt;p&gt;After hybrid search and RRF, you have ~20 candidate chunks. A &lt;strong&gt;cross-encoder reranker&lt;/strong&gt; takes both the query and a candidate chunk as a single concatenated input and produces a relevance score. Because both texts pass through the model together, the model can attend to query-document relationships that a bi-encoder cannot — query and document never interact during bi-encoder encoding.&lt;/p&gt;

&lt;p&gt;The practical architecture: use fast bi-encoder retrieval (dense + BM25 + RRF) to get the top 20 candidates, then run a cross-encoder on those 20 for final ordering. The top 5 go into the prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cross-encoder context window limits:&lt;/strong&gt; Cross-encoders are themselves transformer models with context window limits. General-purpose reranker models like &lt;code&gt;ms-marco-MiniLM-L-12-v2&lt;/code&gt; support 512 subword tokens — which is often enough for a single short function, but not for large class bodies. For retrieval pipelines that surface larger chunks, use a reranker with a larger window: Cohere Rerank 3 supports 4,096 tokens; voyage-rerank-2 supports 16K. If the combined chunk+query still exceeds the limit, truncate the chunk from the bottom — the function signature and docstring are more informative for reranking than the implementation tail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For code with strong AST chunking and a good code embedding model, hybrid bi-encoder retrieval is often sufficient for most queries. Reranking becomes most valuable when queries are ambiguous or when the codebase has many semantically similar functions. It adds 50–200ms of latency, so benchmark before committing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: In Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  10. How Cursor Does It: A Reference Architecture
&lt;/h3&gt;

&lt;p&gt;When you open a project in Cursor, it chunks local files and sends them to its servers, where they are embedded (via OpenAI's API or a custom model) and stored in &lt;strong&gt;Turbopuffer&lt;/strong&gt; — its vector store of choice. File paths are obfuscated client-side before any data leaves your machine. Embeddings are cached by chunk hash, making incremental re-indexing fast.&lt;/p&gt;

&lt;p&gt;At query time, Cursor monitors the active cursor position and constructs a composite signal: the current file's surrounding code, any open editor tabs, and recent edit history. This signal is embedded into a query vector, sent to Turbopuffer for ANN search, and the top-k results are retrieved. The actual code is read from local disk; the model only sees the retrieved text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@Codebase&lt;/code&gt; in Cursor's chat is the explicit trigger for a full retrieval pass over the indexed codebase. Without it, Cursor uses a lighter heuristic based on open tabs and file imports. &lt;code&gt;@Docs&lt;/code&gt; and &lt;code&gt;@Web&lt;/code&gt; extend the same pipeline beyond the local codebase.&lt;/p&gt;

&lt;p&gt;One important architectural note: the embedding model used to index the codebase is separate from the generative model used to produce completions. Cursor uses a lightweight, fast embedding model for indexing (optimized for latency and throughput over millions of chunks) and a larger, slower generative model for the actual completion. When building a similar system, these two components have independent optimization concerns — do not assume the same model serves both roles.&lt;/p&gt;

&lt;p&gt;GitHub Copilot's context construction follows a similar pattern. For inline completion, it uses the current file content around the cursor plus a Jaccard similarity heuristic to find other open tabs that share significant token overlap with the current file. The &lt;code&gt;@workspace&lt;/code&gt; symbol in VS Code triggers a more thorough indexing-based search, analogous to Cursor's &lt;code&gt;@Codebase&lt;/code&gt;. Copilot's default inline completion mode is a fast, low-latency path that does not run full vector retrieval on every keystroke — full retrieval is reserved for explicit chat interactions.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Tradeoffs and Limits of Code RAG
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;RAG Behavior&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cross-file dependency reasoning&lt;/td&gt;
&lt;td&gt;Each retrieved chunk is a fragment; the model may not understand how three retrieved functions compose at the call site&lt;/td&gt;
&lt;td&gt;Include file path + line range metadata; retrieve parent class or module-level imports alongside function bodies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly created files not yet indexed&lt;/td&gt;
&lt;td&gt;Invisible to retrieval until the index is rebuilt&lt;/td&gt;
&lt;td&gt;Incremental indexing on file-save events; maintain a pending index queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query is too vague&lt;/td&gt;
&lt;td&gt;"fix the bug" → retrieves generic results&lt;/td&gt;
&lt;td&gt;Use cursor position + surrounding error message as primary query signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minified or generated code&lt;/td&gt;
&lt;td&gt;Lock files, protobuf generated code pollute the index&lt;/td&gt;
&lt;td&gt;Maintain a .gitignore-style exclude list for the RAG indexer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very large monorepos&lt;/td&gt;
&lt;td&gt;Recall degrades; indexing is slow&lt;/td&gt;
&lt;td&gt;Scope index to current working subdirectory or per-service sub-indices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema/type changes&lt;/td&gt;
&lt;td&gt;Stale embeddings give the model outdated type signatures&lt;/td&gt;
&lt;td&gt;Invalidate embeddings on file write by chunk content hash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Does a larger context window make RAG obsolete?&lt;/strong&gt; As context windows grow to 1M and beyond — Llama 4 Scout hit 10M tokens in 2025, Gemini 1.5 Pro supported 1M — this question keeps coming up. The practical answer is no, though the reasoning matters. A 200,000-line Python codebase easily exceeds 2 million tokens. Most production monorepos are far larger. More importantly, the attention quality degradation described in Sections 2 and 3 doesn't disappear with a larger nominal window. Those long-context models achieve their range through techniques like NTK-aware RoPE scaling (which extends the effective frequency range of positional encodings) and sparse attention patterns (which skip computation on distant token pairs) — these help with extrapolation but don't eliminate the position bias at extremely long ranges. And practically: a 1M-token prompt is expensive and slow even on state-of-the-art hardware. For interactive code assistance, stuffing the full codebase is off the table regardless of window size.&lt;/p&gt;

&lt;p&gt;Large context windows and RAG do different jobs. RAG decides &lt;em&gt;what&lt;/em&gt; deserves to be in the context window. The context window determines &lt;em&gt;how much&lt;/em&gt; you can fit once you've been selective. A well-tuned system retrieves the right 5,000 tokens from a 10M-token codebase and puts them in a 128K window with room left for conversation history and tool outputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Building Your Own Code RAG Pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Parsing:&lt;/strong&gt; Use tree-sitter with a recursive AST walk — iterating only over &lt;code&gt;root_node.children&lt;/code&gt; misses deeply nested functions and class methods.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Pseudo-code: AST chunk extraction with tree-sitter (v0.21+ API)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tree_sitter_python&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tspython&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tree_sitter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Parser&lt;/span&gt;

&lt;span class="n"&gt;PY_LANGUAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tspython&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;language&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PY_LANGUAGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;TARGET_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function_definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class_definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursively walk the AST to catch nested definitions
    (methods inside classes, functions inside functions, etc.)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TARGET_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_byte&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_byte&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;start_point&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end_line&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;end_point&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="c1"&gt;# For class_definition, continue recursing to capture methods.
&lt;/span&gt;        &lt;span class="c1"&gt;# For function_definition, stop — we want the whole function,
&lt;/span&gt;        &lt;span class="c1"&gt;# not its nested helpers as separate chunks.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class_definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;extract_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root_node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Embedding models:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Context window&lt;/th&gt;
&lt;th&gt;Strengths&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-code-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16K tokens&lt;/td&gt;
&lt;td&gt;Purpose-built for code; top-ranked on code retrieval benchmarks (2025)&lt;/td&gt;
&lt;td&gt;Production code assistant, maximum retrieval quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;text-embedding-3-large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8K tokens&lt;/td&gt;
&lt;td&gt;Strong general performance; well-supported; large community&lt;/td&gt;
&lt;td&gt;Mixed code + documentation retrieval; existing OpenAI integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nomic-embed-code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8K tokens&lt;/td&gt;
&lt;td&gt;Open-weight; can run locally; no API cost&lt;/td&gt;
&lt;td&gt;Air-gapped environments; cost-sensitive deployments; on-prem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Vector store:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pgvector in Postgres — sufficient for single-developer or small-team tools&lt;/li&gt;
&lt;li&gt;Qdrant — supports both dense and sparse vectors in a single collection, enabling native hybrid search without maintaining two separate stores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection template:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a coding assistant for this codebase.

## Relevant context from the codebase:

### [payments/gateway.py · lines 42–87]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_1_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
### [payments/exceptions.py · lines 1–24]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_2_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
### [payments/models.py · lines 88–112]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_3_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
## Current task:
{user_request}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include file path and line numbers in each chunk header. These cost very few tokens but give the model the module structure needed to generate correct imports and references.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Do not retrieve more than you need.&lt;/strong&gt; It is tempting to inject 10–15 chunks to "give the model more information." Resist this. Each additional chunk increases context size (paying the quadratic cost from Section 4), increases attention dilution, and reduces the proportion of the context that is highly relevant. In practice, 3–5 high-quality chunks typically outperform 15 lower-quality ones. Invest in retrieval quality, not retrieval quantity.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Through-Line
&lt;/h2&gt;

&lt;p&gt;The surprising thing about attention dilution is that it isn't a bug you can patch. It's a structural property of softmax normalization — the total attention weight sums to 1.0 regardless of sequence length, so every token you add is competing with every other for a share of that budget. More context doesn't mean more understanding; it means each fact gets a smaller slice. The lost-in-the-middle position bias makes it worse: code injected into the middle of a long prompt is structurally disadvantaged by both RoPE's distance decay and the recency bias that causal pretraining instills. Knowing this changes how you think about the whole problem.&lt;/p&gt;

&lt;p&gt;RAG doesn't solve attention dilution — it sidesteps it. Instead of sending everything and hoping the model finds what's relevant, it figures out what's relevant first and sends only that. The context window ends up containing what actually matters for the task: the right type definitions, the right helper functions, the right error handling patterns.&lt;/p&gt;

&lt;p&gt;In practice: below roughly 3,000–5,000 lines, context stuffing usually works well enough. Above that, the problems stack up fast. At 50,000+ lines, naive stuffing reliably hurts. At 500,000+ lines, AST chunking, hybrid BM25 + dense retrieval, RRF fusion, and careful prompt injection aren't premature optimization — they're the baseline.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Foundational Papers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vaswani et al. (2017) — &lt;em&gt;Attention Is All You Need&lt;/em&gt;. NeurIPS. The original transformer paper introducing scaled dot-product attention.&lt;/li&gt;
&lt;li&gt;Liu et al. (2023) — &lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;&lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;&lt;/a&gt;. Stanford / Berkeley. Empirical study of U-shaped attention bias and the 30% accuracy drop at mid-context positions.&lt;/li&gt;
&lt;li&gt;He et al. (2024) — &lt;a href="https://arxiv.org/abs/2406.16008" rel="noopener noreferrer"&gt;&lt;em&gt;Found in the Middle: Calibrating Positional Attention Bias&lt;/em&gt;&lt;/a&gt;. UW / MIT / Google. Proposed calibration method that partially corrects RoPE position bias at inference time.&lt;/li&gt;
&lt;li&gt;Survey (2025) — &lt;a href="https://arxiv.org/abs/2510.04905" rel="noopener noreferrer"&gt;&lt;em&gt;Retrieval-Augmented Code Generation: A Survey&lt;/em&gt;&lt;/a&gt;. Comprehensive survey of RAG approaches specifically for code generation and repository-level tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;RAG &amp;amp; Retrieval&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cormack, Clarke &amp;amp; Buettcher (2009) — &lt;em&gt;Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Dao et al. (2022) — &lt;em&gt;FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://redis.io/blog/rag-vs-large-context-window-ai-apps/" rel="noopener noreferrer"&gt;RAG vs Large Context Window: Real Trade-offs for AI Apps&lt;/a&gt; — Redis Engineering Blog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.shaped.ai/blog/context-window-optimization-why-ranking-not-stuffing-is-the-scaling-law-for-agents" rel="noopener noreferrer"&gt;Context Window Optimization: Why Ranking, Not Stuffing, Is the Scaling Law for Agents&lt;/a&gt; — Shaped AI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://medium.com/@vishnudhat/rag-for-llm-code-generation-using-ast-based-chunking-for-codebase-c55bbd60836e" rel="noopener noreferrer"&gt;RAG for LLM Code Generation using AST-Based Chunking&lt;/a&gt; — Vishnudhat Natarajan&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sderosiaux.substack.com/p/better-retrieval-beats-better-models" rel="noopener noreferrer"&gt;Better Retrieval Beats Better Models for Large Codebases&lt;/a&gt; — Stéphane Derosiaux&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code Assistants &amp;amp; Architecture&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://towardsdatascience.com/how-cursor-actually-indexes-your-codebase/" rel="noopener noreferrer"&gt;How Cursor Actually Indexes Your Codebase&lt;/a&gt; — Towards Data Science&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.quastor.org/p/github-copilot-works" rel="noopener noreferrer"&gt;How GitHub Copilot Works&lt;/a&gt; — Quastor Engineering&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.blog/ai-and-ml/generative-ai/what-is-retrieval-augmented-generation-and-what-does-it-do-for-generative-ai/" rel="noopener noreferrer"&gt;What is Retrieval-Augmented Generation?&lt;/a&gt; — GitHub Blog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hybrid Search &amp;amp; Ranking&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ranjankumar.in/bm25-vs-dense-retrieval-for-rag-engineers" rel="noopener noreferrer"&gt;BM25 vs Dense Retrieval for RAG: What Actually Breaks in Production&lt;/a&gt; — Ranjan Kumar&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mbrenndoerfer.com/writing/hybrid-search-bm25-dense-retrieval-fusion" rel="noopener noreferrer"&gt;Hybrid Search: BM25 and Dense Retrieval Combined&lt;/a&gt; — Michael Brenndoerfer&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>codeassistants</category>
      <category>llm</category>
      <category>rag</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
