<?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: Pranab Sarkar</title>
    <description>The latest articles on DEV Community by Pranab Sarkar (@spranab).</description>
    <link>https://dev.to/spranab</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%2F3785623%2F84db641c-9546-4aa5-8312-46f45b965e45.png</url>
      <title>DEV Community: Pranab Sarkar</title>
      <link>https://dev.to/spranab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/spranab"/>
    <language>en</language>
    <item>
      <title>49 Minutes Chasing a Silent SSE Hang in mcpier, and the CRLF Regex Behind It</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 05 Nov 2026 16:02:56 +0000</pubDate>
      <link>https://dev.to/spranab/49-minutes-chasing-a-silent-sse-hang-in-mcpier-and-the-crlf-regex-behind-it-1bl7</link>
      <guid>https://dev.to/spranab/49-minutes-chasing-a-silent-sse-hang-in-mcpier-and-the-crlf-regex-behind-it-1bl7</guid>
      <description>&lt;p&gt;At 21:30:27 UTC on 2026-04-17 I committed what I thought was the fix, and v0.1.7 was on npm thirty seconds later. The SSE stream still didn't move. No error, no stack trace, just a client waiting on frames that never arrived. Finding the actual problem took another 49 minutes and five more commits, and it turned out to be a regex that assumed the wrong line ending.&lt;/p&gt;

&lt;p&gt;To be precise about that number: the hang had been in the code since 13:25 that afternoon and on npm since 21:18. The 49 minutes is how long the hunt took once I was actually hunting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What mcpier is
&lt;/h2&gt;

&lt;p&gt;mcpier ("Pier," tagline "MCPs, simpler") is a self-hosted control plane for MCP servers. If you run Model Context Protocol servers on more than one machine, you end up with your OpenAI, Gemini, and GitHub keys in plaintext in &lt;code&gt;~/.claude.json&lt;/code&gt; on every box, and rotating one means editing N files by hand. Pier keeps one YAML manifest on a homelab server, encrypts secrets at rest with AES-256-GCM, and hands clients a URL instead of a key. The CLI is &lt;code&gt;npm i -g mcpier&lt;/code&gt; (binary &lt;code&gt;pier&lt;/code&gt;); the server runs from &lt;code&gt;ghcr.io/spranab/mcpier&lt;/code&gt; on port 8420. MIT, one maintainer, me. Code is at &lt;a href="https://github.com/spranab/mcpier" rel="noopener noreferrer"&gt;github.com/spranab/mcpier&lt;/a&gt;, the CLI at &lt;a href="https://www.npmjs.com/package/mcpier" rel="noopener noreferrer"&gt;npmjs.com/package/mcpier&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The night before
&lt;/h2&gt;

&lt;p&gt;The first session started at 20:34:48 UTC on 2026-04-16 with v0.1.1 and ran until 01:50 the next morning. v0.1.2 went out at 20:49. v0.1.3, at 20:59:45, fixed a bug where every unmatched route, including a plain 404 on &lt;code&gt;/api/whatever&lt;/code&gt;, came back as:&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="nl"&gt;"statusCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"Internal Server Error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"reply.sendFile is not a function"&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;I'd registered &lt;code&gt;fastifyStatic&lt;/code&gt; with &lt;code&gt;decorateReply: false&lt;/code&gt;, so Fastify never attached &lt;code&gt;sendFile&lt;/code&gt; to the reply, and then my 404 handler called &lt;code&gt;reply.sendFile("index.html")&lt;/code&gt; anyway. I found it by running the published CLI against the public ghcr image.&lt;/p&gt;

&lt;p&gt;After midnight, the commit message for the 01:12:13 fix calls it the "first production brainstorm-mcp call." brainstorm-mcp declares four optional API-key secrets (&lt;code&gt;required: false&lt;/code&gt;), the user had only set a subset, and Pier answered with &lt;code&gt;500 {"error":"missing secrets: gemini_key, anthropic_key"}&lt;/code&gt;. The proxy's secret check didn't carry the required/optional metadata at all. Now it injects an empty string for anything missing and lets the MCP subprocess decide what it needs, which is what the local-install path already did.&lt;/p&gt;

&lt;p&gt;v0.1.4 was a real feature, not just a fix. Commit &lt;code&gt;89fbad505&lt;/code&gt; at 01:21:06 added an activation resolver: workspace config beats user profile, which beats a formula's advisory auto-activate triggers, so Pier stops dumping every installed MCP's tools into the agent's context. The commit message frames it as fixing "the token-budget explosion from a dynamic catalog," and it came with 11 new tests, 27 passing total. It also says the design was "validated by a real GPT-5.4 + DeepSeek brainstorm through this very Pier," so Pier helped design part of itself.&lt;/p&gt;

&lt;p&gt;The publish didn't go through. &lt;code&gt;tsup&lt;/code&gt; had bundled &lt;code&gt;yaml&lt;/code&gt; straight into the CLI, &lt;code&gt;yaml&lt;/code&gt; does &lt;code&gt;require('process')&lt;/code&gt; internally, and the binary crashed on startup with &lt;code&gt;Dynamic require of "process" is not supported&lt;/code&gt;, pointing at &lt;code&gt;../../node_modules/yaml/dist/compose/composer.js&lt;/code&gt;. CI's tarball check (install the packed tarball fresh, run the binary) caught it before anything reached npm. Commit &lt;code&gt;e2b7bf3d0&lt;/code&gt; at 01:23:20 made &lt;code&gt;yaml&lt;/code&gt; a real dependency, I moved the &lt;code&gt;v0.1.4&lt;/code&gt; tag onto that commit, and 0.1.4 published at 01:24:34. Nothing on the npm registry shows the first attempt ever happened.&lt;/p&gt;

&lt;p&gt;v0.1.5 (&lt;code&gt;c58220f87&lt;/code&gt;, published 01:39:28) came from trying to sync a config into a live Claude Code session. The writer emitted &lt;code&gt;"transport"&lt;/code&gt;; Claude Code reads &lt;code&gt;"type"&lt;/code&gt;. Before: &lt;code&gt;{"transport": "sse", "url": ".../mcp/foo"}&lt;/code&gt;. After: &lt;code&gt;{"type": "sse", "url": ".../mcp/foo/sse"}&lt;/code&gt;, because the URL also needed a &lt;code&gt;/sse&lt;/code&gt; suffix. Separately, spawning a Python MCP server in the k8s pod failed with &lt;code&gt;spawn uvx ENOENT&lt;/code&gt; since the runtime image only had Node. Installing &lt;code&gt;uv&lt;/code&gt; fixed it and added roughly 40MB I didn't love adding.&lt;/p&gt;

&lt;p&gt;The last commit of that session was &lt;code&gt;63c6d0bba&lt;/code&gt; at 01:50:09, a cache pre-warming change. Then nothing for about eleven and a half hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The afternoon feature
&lt;/h2&gt;

&lt;p&gt;At 13:25:33, commit &lt;code&gt;f3a1226f2&lt;/code&gt; added the thing that broke. Some upstream MCP servers send an &lt;code&gt;event: endpoint&lt;/code&gt; SSE frame carrying an upstream-relative path for the client's follow-up POSTs. Pier forwarded that path verbatim, so the POST went around the proxy instead of through it. I wrote a &lt;code&gt;rewriteSseEndpoint&lt;/code&gt; stream transformer to fix the path in flight. It went out as v0.1.6 at 21:18:28, almost eight hours after the commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hunt
&lt;/h2&gt;

&lt;p&gt;My first guess (&lt;code&gt;47147871f&lt;/code&gt;, 21:30:27) was framework plumbing. Node's native &lt;code&gt;fetch&lt;/code&gt; hands back &lt;code&gt;Response.body&lt;/code&gt; as a WHATWG &lt;code&gt;ReadableStream&lt;/code&gt;, Fastify's &lt;code&gt;reply.send()&lt;/code&gt; doesn't know what to do with one, so maybe the headers just never flushed. I wrapped it in &lt;code&gt;Readable.fromWeb()&lt;/code&gt; and published v0.1.7. Same silence.&lt;/p&gt;

&lt;p&gt;Second guess, also plumbing (&lt;code&gt;8f366d724&lt;/code&gt;, 21:41:21). I kept the &lt;code&gt;fromWeb&lt;/code&gt; wrap and added a hijack of &lt;code&gt;reply.raw&lt;/code&gt;, Fastify's escape hatch to the socket, piping straight into it. That's the pattern the stdio-SSE path already used, and that path worked. Server-only change, so no version bump, just a new ghcr image.&lt;/p&gt;

&lt;p&gt;Then three commits of looking instead of guessing. One of them, &lt;code&gt;ffd7301c7&lt;/code&gt;, moved the hijack ahead of &lt;code&gt;writeHead&lt;/code&gt; as well as adding logging, so it wasn't pure instrumentation. At 21:59:57 I turned the rewriter off to check whether the raw plumbing worked on its own, and at 22:09:16 I turned it back on with logging on the read side.&lt;/p&gt;

&lt;p&gt;At 22:19:19, &lt;code&gt;8c8fec5d7&lt;/code&gt; had the answer. The upstream I was testing against, my own yantrikdb deploy, sends CRLF. The rewriter assumed LF. &lt;code&gt;[^\n]+&lt;/code&gt; captured the data value up to the &lt;code&gt;\n&lt;/code&gt;, which dragged a trailing &lt;code&gt;\r&lt;/code&gt; into the capture, and the terminator pattern &lt;code&gt;\n\n&lt;/code&gt; never matched the real terminator, &lt;code&gt;\r\n\r\n&lt;/code&gt;. A failed match isn't an exception. The transformer just never emitted anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# frame shape on the wire from a CRLF upstream
event: endpoint\r\ndata: &amp;lt;path&amp;gt;\r\n\r\n

# before                      # after
data value:  [^\n]+           data value:  [^\r\n]+
terminator:  \n\n             terminator:  \r?\n\r?\n
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix also preserves whichever line ending the upstream actually used, and the debug logging came out in the same commit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whose name is on which commit
&lt;/h2&gt;

&lt;p&gt;Four commits in this story carry &lt;code&gt;Co-Authored-By: Claude Opus 4.7 (1M context)&lt;/code&gt;: &lt;code&gt;f3a1226f2&lt;/code&gt; (the rewriter), &lt;code&gt;47147871f&lt;/code&gt; and &lt;code&gt;8f366d724&lt;/code&gt; (both wrong guesses), and &lt;code&gt;8c8fec5d7&lt;/code&gt; (the real fix). The plain bugfixes from the night before (&lt;code&gt;88a35376f&lt;/code&gt;, &lt;code&gt;e38692c9b&lt;/code&gt;, &lt;code&gt;e2b7bf3d0&lt;/code&gt;, &lt;code&gt;c58220f87&lt;/code&gt;) don't have it. Neither do the three debug commits.&lt;/p&gt;

&lt;p&gt;So the AI pair shows up on the feature that broke and on every attempt to fix it, and it's missing from the stretch where I was reading logs. I don't think the lesson is that the model wrote a bad regex. I approved it. My read is that the two co-authored fix attempts were both confident theories about Fastify and web streams, written before anyone had looked at the bytes coming in, and that order of operations is what cost the time. Log the input first, then theorize. I'd hold any pair to that, human or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still on 0.1.7
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm view mcpier version&lt;/code&gt; still says 0.1.7. &lt;code&gt;publish.yml&lt;/code&gt; only runs on a published GitHub Release, and the workflow comment says it plainly: "Only a GitHub Release publishes to npm. Tags on their own do nothing." The ghcr job in &lt;code&gt;ci.yml&lt;/code&gt; runs on every push to main and tags each image twice, &lt;code&gt;latest&lt;/code&gt; plus a short git SHA. So the server does get an identifier per commit, just not a number anyone chose on purpose, and the CRLF fix lives in the server image under one of those SHAs.&lt;/p&gt;

&lt;p&gt;That's fine by me. The npm package has no proxy code in it at all; its dependencies are commander, kleur, inquirer, and yaml. CLI 0.1.7 isn't half-fixed, it just tells you nothing about the state of the server, and cutting a CLI release for a server bug would have been noise.&lt;/p&gt;

&lt;p&gt;What I think was actually sloppy is the testing. The repo had tests before any of this: &lt;code&gt;config.test.mjs&lt;/code&gt;, &lt;code&gt;crypto.test.mjs&lt;/code&gt;, &lt;code&gt;manifest.test.mjs&lt;/code&gt;, smoke-test scripts, and a CI job running &lt;code&gt;npm test&lt;/code&gt; plus a Docker smoke test. The activation resolver got 11 new tests the same night. The SSE rewriter got none. I checked the full tree today, 2026-11-05, and nothing covers &lt;code&gt;rewriteSseEndpoint&lt;/code&gt; or the CRLF case, then or since. The one piece of code in this whole stretch that fails without saying anything is the one piece with no test. The only thing that proved the fix was a manual run that night against my own deploy. Config parsing has fixtures. A regex sitting between every SSE upstream and every client doesn't.&lt;/p&gt;

&lt;p&gt;For scale: as of today mcpier has 1 GitHub star, 0 forks, 0 open issues, and 24 npm downloads in the last 30 days, 17 of them in the past week. The last push was 2026-09-05.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>debugging</category>
      <category>node</category>
      <category>opensource</category>
    </item>
    <item>
      <title>A contributor found the live twin of a namespace bug I'd filed as low priority</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 29 Oct 2026 15:16:07 +0000</pubDate>
      <link>https://dev.to/spranab/a-contributor-found-the-live-twin-of-a-namespace-bug-id-filed-as-low-priority-4kb5</link>
      <guid>https://dev.to/spranab/a-contributor-found-the-live-twin-of-a-namespace-bug-id-filed-as-low-priority-4kb5</guid>
      <description>&lt;p&gt;The test store had two namespaces. Into "alice" went &lt;code&gt;ALICE-SECRET der Server steht in Berlin.&lt;/code&gt; Into "bob" went &lt;code&gt;BOB-SECRET der Server steht in Wien.&lt;/code&gt; The test then handed both memories the same hand-built embedding vector, which pins their cosine similarity at 1.0 whatever the words say, and let the engine run its trigger checks. The trigger that came back named both namespaces, and its payload read &lt;code&gt;"text_a":"BOB-SECRET ...","text_b":"ALICE-SECRET ...","similarity":1.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Forcing the vectors takes the embedding model out of the experiment. What's left is the question the check should have been asking and wasn't: do these two memories belong to the same owner? It never looked. Any cross-namespace pair that cleared the threshold got written down with both texts in full, next to each other.&lt;/p&gt;

&lt;p&gt;Hugo Gundlach (Huy3ko on GitHub) wrote that test against engine 0.23.3 and posted its output on an issue I had opened six weeks earlier about almost exactly this shape of bug. I had marked my issue low priority.&lt;/p&gt;

&lt;p&gt;I maintain &lt;a href="https://github.com/yantrikos/yantrikdb" rel="noopener noreferrer"&gt;YantrikDB&lt;/a&gt;, a Rust memory engine that AI agents embed so facts and decisions survive between sessions. The core README describes the embedded engine as single-tenant, one file per user, and that is still its main shape. Every memory row also carries a &lt;code&gt;namespace&lt;/code&gt; column, though, and integrations like the Hermes plugin use it to keep several agents or users apart inside one store. The engine treats that column as a hard boundary in several places. &lt;code&gt;consolidate_cluster&lt;/code&gt; errors out with &lt;code&gt;"consolidate_cluster: members span namespaces ('{}' vs '{}' at {rid}) — a cluster must be consolidated within one namespace"&lt;/code&gt;, and the claim-conflict scans join on &lt;code&gt;e1.namespace = e2.namespace&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;check_redundancy&lt;/code&gt; lives in &lt;code&gt;crates/yantrikdb-core/src/cognition/triggers.rs&lt;/code&gt;. It runs by default as part of &lt;code&gt;think()&lt;/code&gt;, the background pass that does consolidation plus redundancy and conflict detection. Before the fix, its query was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;consolidation_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the 30 most recent active memories in the whole store, each compared against every other. A pair above the threshold became a &lt;code&gt;redundancy&lt;/code&gt; trigger, or a &lt;code&gt;potential_conflict&lt;/code&gt; trigger through a sibling branch when the two shared an entity. Both kinds carry &lt;code&gt;context.text_a&lt;/code&gt; and &lt;code&gt;context.text_b&lt;/code&gt; holding the full source text with no length cap, and both get persisted to &lt;code&gt;trigger_log&lt;/code&gt;. So a cross-namespace match is one person's memory sitting in a table row beside another person's.&lt;/p&gt;

&lt;p&gt;The threshold has its own, older bug. It's meant to come from &lt;code&gt;config.consolidation_sim_threshold&lt;/code&gt;, default 0.6, and the function ignored that and used a hardcoded &lt;code&gt;let threshold = 0.85;&lt;/code&gt;. The fix PR left it alone and listed it as out of scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Issue #242, which I wrote
&lt;/h2&gt;

&lt;p&gt;I'd come at this from a different direction in September. A user filed &lt;a href="https://github.com/yantrikos/yantrikdb-hermes-plugin/issues/87" rel="noopener noreferrer"&gt;yantrikdb-hermes-plugin #87&lt;/a&gt;: on the embedded backend, &lt;code&gt;think()&lt;/code&gt; silently never did any maintenance. The plugin always passes a &lt;code&gt;namespace&lt;/code&gt; key in the &lt;code&gt;think()&lt;/code&gt; config dict, &lt;code&gt;think()&lt;/code&gt; whitelists its config keys, and &lt;code&gt;namespace&lt;/code&gt; is only on &lt;code&gt;recall()&lt;/code&gt;'s whitelist. Recall and remember worked; think did nothing, and the only evidence was a DEBUG-level &lt;code&gt;think(): unknown config key "namespace"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While tracing that I read &lt;code&gt;find_clusters()&lt;/code&gt; and noticed it never checks namespace at all. On 2026-09-17, on engine 0.23.0, I opened &lt;a href="https://github.com/yantrikos/yantrikdb/issues/242" rel="noopener noreferrer"&gt;issue #242&lt;/a&gt;: "find_clusters() has no namespace check; cross-tenant merging is prevented only by the caller's grouping." Its caller, &lt;code&gt;find_consolidation_candidates_page&lt;/code&gt;, groups memories by namespace before handing them over, and I confirmed that grouping held on 0.23.0. I proposed enforcing the rule in &lt;code&gt;commit_consolidation&lt;/code&gt;, the chokepoint every consolidation path goes through, so it wouldn't depend on callers remembering. Then I wrote: "Low priority — no user is affected today. Filing it so the invariant is written down somewhere other than the caller's control flow."&lt;/p&gt;

&lt;p&gt;The diagnosis was right as far as it went. Where I went wrong was treating &lt;code&gt;find_clusters&lt;/code&gt; as the whole problem. I had just found a function that reads &lt;code&gt;memories&lt;/code&gt; with no namespace predicate and is safe only by accident of its caller, and I didn't go looking for other functions that read the table the same way without any caller protecting them. &lt;code&gt;check_redundancy&lt;/code&gt; was one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Late on the 27th
&lt;/h2&gt;

&lt;p&gt;I shipped v0.23.3 at 20:38 UTC on 2026-10-27, closing out a partial fix for the consolidation-seed bug (#253) and a long-pending bundled-embedder cache-dir fix (#252). About three hours later, at 23:49 UTC, Huy3ko committed his fix. He opened &lt;a href="https://github.com/yantrikos/yantrikdb/pull/256" rel="noopener noreferrer"&gt;PR #256&lt;/a&gt;, "fix(triggers): keep redundancy pairs inside one namespace," eight minutes after that, and two minutes later commented on #242: "Related, but on a different path — and this one was not latent." The PR body says "Refs #242, does not close it." He wasn't new to the project; he'd had a PR open against the Hermes plugin since 2026-10-17, adding correction chains and historical recall, and it's still open.&lt;/p&gt;

&lt;p&gt;The fix has two layers. &lt;code&gt;check_redundancy&lt;/code&gt; now first runs &lt;code&gt;SELECT DISTINCT namespace FROM memories WHERE consolidation_status = 'active' AND embedding IS NOT NULL&lt;/code&gt;, then calls a new private &lt;code&gt;check_redundancy_in_namespace&lt;/code&gt; once per namespace, each with &lt;code&gt;AND namespace = ?1&lt;/code&gt; bound through &lt;code&gt;rusqlite::params![namespace]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Behind that sits &lt;code&gt;drop_cross_namespace_triggers&lt;/code&gt;, called inside &lt;code&gt;filter_and_persist_triggers&lt;/code&gt; just before anything reaches &lt;code&gt;trigger_log&lt;/code&gt;. It resolves the namespace of every &lt;code&gt;source_rid&lt;/code&gt; on each trigger and drops, with a &lt;code&gt;tracing::warn!&lt;/code&gt;, any trigger whose rids land in more than one namespace. If a rid can't be resolved because its memory has since been deleted, the trigger passes through untouched; the code comment says "guessing ownership there would drop legitimate single-rid triggers for forgotten memories." The commit message gives the reason for the second layer: "the boundary does not depend on every future check remembering to scope itself."&lt;/p&gt;

&lt;p&gt;My opinion, which not everyone will share: that second gate matters more than the query fix. The query fix closes this function. The gate at the write path covers whichever trigger check someone adds next year without thinking about namespaces, which is the kind of mistake I'd just demonstrated I make.&lt;/p&gt;

&lt;p&gt;The diff was 2 files, 288 insertions, 2 deletions. A new test file, &lt;code&gt;crates/yantrikdb-core/tests/trigger_namespace_isolation.rs&lt;/code&gt;, has three tests. &lt;code&gt;no_trigger_payload_carries_two_peoples_text&lt;/code&gt; is the Berlin/Wien one above. &lt;code&gt;identical_text_in_two_namespaces_yields_no_cross_namespace_trigger&lt;/code&gt; is the plainer case: the same sentence, &lt;code&gt;"Der Mailserver laeuft auf Debian 12 mit Postfix."&lt;/code&gt;, with the same vector, in both namespaces. Both fail without the fix. The third, &lt;code&gt;near_identical_text_in_one_namespace_still_raises_redundancy&lt;/code&gt;, has to pass before and after, so the fix can't get the first two green by switching redundancy detection off. 2207 tests passed, &lt;code&gt;cargo fmt --check&lt;/code&gt; was clean, and clippy reported no new warnings.&lt;/p&gt;

&lt;p&gt;The commit message does get one detail wrong. It says &lt;code&gt;think()&lt;/code&gt; reaches this check twice per run, once in Phase 1 and again through &lt;code&gt;check_all_triggers&lt;/code&gt;. &lt;code&gt;think()&lt;/code&gt; calls &lt;code&gt;check_redundancy&lt;/code&gt; once, in Phase 1. &lt;code&gt;check_all_triggers&lt;/code&gt; is a separate public function, also exposed to Python, that &lt;code&gt;think()&lt;/code&gt; never calls. It doesn't affect the fix. Nobody caught it in review, and I didn't catch it when I merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review and merge
&lt;/h2&gt;

&lt;p&gt;The review on the PR came from the delegated reviewer that posts under my &lt;code&gt;spranab&lt;/code&gt; account. My local checkout was behind main, so it checked the change through &lt;code&gt;gh pr diff&lt;/code&gt; and &lt;code&gt;gh api&lt;/code&gt; against the fork branch without running the suite. It confirmed the old query had no namespace predicate and that &lt;code&gt;text_a&lt;/code&gt;/&lt;code&gt;text_b&lt;/code&gt; carry full source text, calling a cross-namespace row in &lt;code&gt;trigger_log&lt;/code&gt; "a genuine disclosure." It checked both out-of-scope claims in the PR: the aggregate trigger checks (&lt;code&gt;check_valence_trend&lt;/code&gt;, &lt;code&gt;check_relationship_insight&lt;/code&gt;, &lt;code&gt;check_entity_anomaly&lt;/code&gt;, &lt;code&gt;check_consolidation_triggers&lt;/code&gt;) still compute store-wide but carry no text, and the 0.85 threshold predates the PR. Then it approved and stopped: "Since it's a logic change to the core engine (not docs/data), merge is Pranab's call rather than something I'll action directly — flagging it to him now."&lt;/p&gt;

&lt;p&gt;That's the rule I set for it, and this PR is the case the rule exists for. It can check a diff line by line against the code; a change to what the core engine writes to disk is still mine to merge.&lt;/p&gt;

&lt;p&gt;I merged at 02:19 UTC on 2026-10-28, 2 hours 22 minutes after the PR opened, and published v0.23.4 at 02:44, about six hours after v0.23.3. From Huy3ko's comment on #242 to a shipped release took under three hours. My release notes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Behavior change: the redundancy scan now covers up to 30 records per namespace instead of 30 per store. On stores with many namespaces, think() does proportionally more work there and can raise more redundancy triggers.&lt;/p&gt;

&lt;p&gt;Existing stores: this release prevents new cross-namespace triggers but does not remove ones already written. If you run multiple namespaces in one store and want to clear old ones, inspect &lt;code&gt;trigger_log&lt;/code&gt; for &lt;code&gt;redundancy&lt;/code&gt; / &lt;code&gt;potential_conflict&lt;/code&gt; rows whose &lt;code&gt;source_rids&lt;/code&gt; resolve to different namespaces.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Some people will think a privacy fix should clean up after itself on upgrade. I'd rather a patch release didn't delete rows from someone's database without asking, and tell them exactly which rows to look at instead.&lt;/p&gt;

&lt;p&gt;I thanked him on the PR: "the write-up made it quick to review: the control test, and the out-of-scope items measured and named rather than left out." The fix commit's author field reads "Vela &lt;a href="mailto:vela@localhost"&gt;vela@localhost&lt;/a&gt;", an address not linked to any GitHub account, so GitHub doesn't credit him. A same-night commit to CONTRIBUTORS.md records the credit by hand, along with wolf-crazy and antonius888. I don't know where the name Vela comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still open
&lt;/h2&gt;

&lt;h1&gt;
  
  
  242 is still open. &lt;code&gt;find_clusters&lt;/code&gt; still relies on its caller's grouping, and the four aggregate checks still compute across the whole store. They carry no text, so they can't leak the way &lt;code&gt;check_redundancy&lt;/code&gt; did, but the boundary isn't enforced everywhere yet. PR #256 fixed one function and its payload, and added a gate in front of the log.
&lt;/h1&gt;

&lt;p&gt;I'd like to say a quick grep would have found &lt;code&gt;check_redundancy&lt;/code&gt; in September. It wouldn't have. Searching the core engine's source for &lt;code&gt;FROM memories&lt;/code&gt; lines with no &lt;code&gt;namespace&lt;/code&gt; on the same line returns roughly 277 of 319, most of them fine, scoped by a bound parameter a line further down or correctly store-wide by design. Finding the bad ones means reading each query and asking who owns the rows it returns. That reading is what I skipped when I verified one caller and wrote "no user is affected today."&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>rust</category>
      <category>security</category>
      <category>opensource</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The seed is just the oldest record, and other things I got wrong about a YantrikDB merge bug</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:09:07 +0000</pubDate>
      <link>https://dev.to/spranab/the-seed-is-just-the-oldest-record-and-other-things-i-got-wrong-about-a-yantrikdb-merge-bug-527k</link>
      <guid>https://dev.to/spranab/the-seed-is-just-the-oldest-record-and-other-things-i-got-wrong-about-a-yantrikdb-merge-bug-527k</guid>
      <description>&lt;p&gt;On 2026-10-16, antonius888 opened &lt;a href="https://github.com/yantrikos/yantrikdb/issues/253" rel="noopener noreferrer"&gt;issue #253&lt;/a&gt; on &lt;a href="https://github.com/yantrikos/yantrikdb" rel="noopener noreferrer"&gt;YantrikDB&lt;/a&gt;, the Rust memory engine for AI agents I maintain. In one &lt;code&gt;think()&lt;/code&gt; consolidation pass, five memories from four unrelated topics (two delegated-task prompts, a fact about DPI community tools, two Bitwarden facts) were folded into one consolidated record whose text began "Hermes dashboard autostart: TWO mechanisms...". None of the five had anything to do with dashboard autostart.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yantrikos/yantrikdb-hermes-plugin" rel="noopener noreferrer"&gt;yantrikdb-hermes-plugin&lt;/a&gt; runs the engine in-process as a memory backend for the Hermes agent framework, and its &lt;code&gt;on_pre_compress&lt;/code&gt; hook writes a "compression gist" into the store before a conversation chunk gets trimmed: importance 0.75, metadata &lt;code&gt;{"source": "compression_summary", "pre_compression": true, ...}&lt;/code&gt;. antonius888's gists ran over 1,300 characters, up to about 1,400.&lt;/p&gt;

&lt;p&gt;What they posted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consolidation multiplies an absorbed record's importance by 0.3 (&lt;code&gt;consolidate.rs:1191&lt;/code&gt;), so a 0.6 record comes out at 0.18. Their absorbed records landed between 0.18 and 0.24.&lt;/li&gt;
&lt;li&gt;Over many &lt;code&gt;think()&lt;/code&gt; passes (the engine only commits a handful of consolidations per pass by default), the store had built up 84 distinct consolidation targets: &lt;code&gt;{2: 31, 10: 22, 3: 8, 1: 5, 6: 5, 4: 4, 5: 3, 8: 3, 7: 2, 9: 1}&lt;/code&gt;. The 22 at exactly 10 are sitting on the &lt;code&gt;max_cluster_size&lt;/code&gt; hardcoded at the call site, &lt;code&gt;consolidate.rs:774&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ten pending "redundancy" triggers, urgency 0.988 to 0.997. Of the three quoted with scores, the 0.9973 one paired a gist with an unrelated 1C ERP fact written in Russian. The 0.9915 one was two different "Strata install" gists, which might honestly be near-duplicates, and the 0.9890 one was also gist against gist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They proposed four fixes (exclude summaries from clustering, cap a gist's similarity influence, promote the &lt;code&gt;pre_compression&lt;/code&gt; tag to a column, cap gist length) and had already added a local env var, &lt;code&gt;YANTRIKDB_COMPRESSION_GIST&lt;/code&gt;, to turn the write off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I had wrong
&lt;/h2&gt;

&lt;p&gt;I replied the same day, on engine 0.23.2, with a theory for why the entity-overlap guard in &lt;code&gt;find_clusters&lt;/code&gt; didn't stop this. That guard has been in the engine since April (commit &lt;code&gt;a27ab9f&lt;/code&gt;, the v0.4.2 to v0.4.4 core sync). It skips a candidate when both records have extracted entities and the sets share nothing, which keeps "Alice is CEO" from merging with "Sarah is CTO". My theory was that a gist's entity set covers everything it summarized, so it's never disjoint from anything and the guard can't fire.&lt;/p&gt;

&lt;p&gt;That describes a record the plugin doesn't write. Here's the end of &lt;code&gt;_compose_compression_gist&lt;/code&gt; on the plugin's &lt;code&gt;main&lt;/code&gt;:&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;intent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_user&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last_assistant&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;n&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;middle&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;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &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;[compression-summary, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; turns] &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;intent: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;intent&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; | outcome: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;outcome&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First 200 characters of the first user turn, first 200 of the last assistant turn, inside a fixed template. Nothing from the middle of the conversation gets read. That tops out around 450 characters on &lt;code&gt;main&lt;/code&gt;, and v0.27.0, the release antonius888 was running, has the same cap. So 1,300-character gists match neither. I asked in the thread whether their copy of the hook had been modified. I still don't know.&lt;/p&gt;

&lt;p&gt;The second mistake was one I'd been carrying around in my head, and I only caught it re-reading the Rust for this post. I'd been picturing the engine choosing the gist as a cluster's seed because it sits near so many other vectors. Seed choice has nothing to do with similarity:&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="c1"&gt;// Sort by creation time (return indices)&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;usize&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt;&lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="nf"&gt;.len&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="nf"&gt;.sort_by&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.created_at&lt;/span&gt;&lt;span class="nf"&gt;.total_cmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="py"&gt;.created_at&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;HashSet&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed_pos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="nf"&gt;.iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="nf"&gt;.enumerate&lt;/span&gt;&lt;span class="p"&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;used&lt;/span&gt;&lt;span class="nf"&gt;.contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="k"&gt;mut&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nd"&gt;vec!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="nf"&gt;.insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... candidates are later records in time order, checked against&lt;/span&gt;
    &lt;span class="c1"&gt;// time window, entity-overlap guard, then cosine similarity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The earliest unused record becomes the next seed. A gist seeds a cluster only when it happens to be the oldest unused record in its neighborhood.&lt;/p&gt;

&lt;p&gt;Where the merged text comes from matters more. The consolidation target is a brand-new record built by &lt;code&gt;extractive_summary&lt;/code&gt;, which sorts members by importance, highest first, and leads with the top one's text. Ordinary facts in a store like this sit around 0.5 to 0.6. A gist is 0.75, so whenever one lands in a cluster, as seed or member, its text will very likely open the merged record.&lt;/p&gt;

&lt;p&gt;The "Hermes dashboard autostart" record from the top isn't a gist (gists always start with the literal &lt;code&gt;[compression-summary,&lt;/code&gt;), so I'm not pinning that one on gists. Same mechanism, though: the highest-importance member named a cluster that was mostly about other things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The embedder
&lt;/h2&gt;

&lt;p&gt;On 2026-10-17, a day after my first reply, I posted a partial correction: the gist is a real but secondary factor, and I think the main cause is the embedder.&lt;/p&gt;

&lt;p&gt;I'd installed the published &lt;code&gt;yantrikdb==0.23.2&lt;/code&gt; wheel into a clean venv and, on a fresh store, scored text modeled on the report by calling the engine directly, once with the default embedder and once with &lt;code&gt;potion-multilingual-128M&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pair&lt;/th&gt;
&lt;th&gt;default embedder&lt;/th&gt;
&lt;th&gt;potion-multilingual-128M&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unrelated Russian↔Russian (1C/Bitwarden/gist/FoodTorg)&lt;/td&gt;
&lt;td&gt;0.83–0.93&lt;/td&gt;
&lt;td&gt;0.14–0.47&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;unrelated English↔English&lt;/td&gt;
&lt;td&gt;0.04–0.23&lt;/td&gt;
&lt;td&gt;0.21–0.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Russian gist ↔ English gist&lt;/td&gt;
&lt;td&gt;0.17&lt;/td&gt;
&lt;td&gt;0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The default, &lt;code&gt;potion-base&lt;/code&gt;, is the static, English-only model bundled in the wheel. Unrelated Cyrillic text scores 0.83 to 0.93 against other unrelated Cyrillic text, well over the 0.6 clustering threshold. That alone is enough to explain the gist-to-1C trigger, clusters filling to the size-10 cap, and the importance loss.&lt;/p&gt;

&lt;p&gt;The last row is where the gist does contribute. Under the multilingual model, a Russian gist and an English gist still score 0.46, and the only thing they share is the &lt;code&gt;[compression-summary, N turns] intent: ... | outcome: ...&lt;/code&gt; scaffolding. Every gist carries it, which gives gists a similarity floor with each other that ordinary facts don't have, and is a likelier story for the two gist-to-gist triggers than anything in their content.&lt;/p&gt;

&lt;p&gt;Switching embedders has its own wall. On a store that already holds data, &lt;code&gt;set_embedder_named()&lt;/code&gt; refuses and points you at &lt;code&gt;db.reembed()&lt;/code&gt;, which isn't callable from Python (#241). My test also bypassed the plugin's config path, so I can't vouch for it end to end. Through the plugin, &lt;code&gt;YANTRIKDB_EMBEDDER&lt;/code&gt; also needs &lt;code&gt;YANTRIKDB_EMBEDDING_DIM&lt;/code&gt; set or it raises, and the plugin README documents a second route, &lt;code&gt;YANTRIKDB_EMBEDDER_MODEL2VEC=minishlab/potion-multilingual-128M&lt;/code&gt;, which needs the &lt;code&gt;model2vec&lt;/code&gt; package. Either way it means a fresh store and a re-import; consolidated originals keep their full text, so nothing is lost.&lt;/p&gt;

&lt;p&gt;I committed to three follow-ups: make &lt;code&gt;reembed()&lt;/code&gt; callable from Python; warn when stored text is mostly outside the embedder's language instead of degrading silently; and on the plugin side, a config switch for the gist write plus keeping gists out of the seed role.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question I owed wolf-crazy
&lt;/h2&gt;

&lt;p&gt;wolf-crazy, who already has one merged fix in this repo, picked up the seed-role piece today. Gists stay eligible as members and never become the seed. Their useful find: &lt;code&gt;MemoryWithEmbedding&lt;/code&gt;, the struct &lt;code&gt;find_clusters&lt;/code&gt; already works on, carries a &lt;code&gt;metadata&lt;/code&gt; field filled from decrypted JSON at two construction sites, so there's no schema change and no extra decrypt. The proposed shape is an &lt;code&gt;Option&amp;lt;&amp;amp;dyn Fn(&amp;amp;MemoryWithEmbedding) -&amp;gt; bool&amp;gt;&lt;/code&gt; parameter, &lt;code&gt;None&lt;/code&gt; by default, so every other caller keeps today's behavior. That includes &lt;code&gt;patterns.rs:327&lt;/code&gt; and the Python binding at &lt;code&gt;py_consolidate.rs:45&lt;/code&gt;, the &lt;code&gt;_find_clusters&lt;/code&gt; antonius888 named in the report. (Checking that one, I noticed it passes &lt;code&gt;None&lt;/code&gt; for the entity map, so the guard my first theory was about doesn't even run on that path.)&lt;/p&gt;

&lt;p&gt;Their claims checked out against &lt;code&gt;origin/main&lt;/code&gt; at &lt;code&gt;1d48d09&lt;/code&gt; earlier today — the files involved hadn't changed since before the issue was even filed.&lt;/p&gt;

&lt;p&gt;They asked one direct question: key the predicate off the &lt;code&gt;pre_compression&lt;/code&gt; tag already in the metadata blob, or promote it to a real column first?&lt;/p&gt;

&lt;p&gt;The reply that verified their work, posted under my account, was written as triage and pushed that data-model call to me instead of making it in a comment. When I sat down to write this, I still hadn't answered wolf-crazy.&lt;/p&gt;

&lt;p&gt;So here's my answer: use the tag.&lt;/p&gt;

&lt;p&gt;The reason sits a few dozen lines above where &lt;code&gt;find_clusters&lt;/code&gt; gets called, at &lt;code&gt;consolidate.rs:727-731&lt;/code&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Vec&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;MemoryWithEmbedding&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;
    &lt;span class="nf"&gt;.into_iter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;.filter&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="py"&gt;.metadata&lt;/span&gt;&lt;span class="nf"&gt;.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"consolidated_from"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.is_none&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;.collect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Records tagged &lt;code&gt;consolidated_from&lt;/code&gt; are dropped before clustering starts, so the next pass doesn't re-merge a summary with its own sources. A metadata key already decides clustering eligibility in the same file, and reading &lt;code&gt;pre_compression&lt;/code&gt; for seed eligibility is the same kind of decision. A column earns its cost once there are enough opt-out knobs to want to query or index them. Right now there are two, both in a blob we already decrypt. If more show up I'll be migrating a live table with encrypted metadata on it, which won't be fun, and I'd still rather pay that later than pay for it now. Someone with more production migrations behind them could fairly call that backwards.&lt;/p&gt;

&lt;p&gt;What I don't have an answer for is the other half. Keeping gists out of the seed role changes which record a cluster is measured against. It doesn't touch &lt;code&gt;extractive_summary&lt;/code&gt;, which will still put a 0.75 gist at the front of any cluster it joins as a member, and with that template floor, gists join clusters easily. The plugin's source calls 0.75 "higher than baseline; this is a deliberate save." Lowering it, skipping gists when picking the lead, or dropping them from clustering the way &lt;code&gt;consolidated_from&lt;/code&gt; records are dropped would each undo something chosen on purpose. I haven't picked one, and wolf-crazy's PR shouldn't wait on it.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opensource</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
    <item>
      <title>I wrote down the cause of this leak two months before anyone reported it</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 15 Oct 2026 15:08:39 +0000</pubDate>
      <link>https://dev.to/spranab/i-wrote-down-the-cause-of-this-leak-two-months-before-anyone-reported-it-16ff</link>
      <guid>https://dev.to/spranab/i-wrote-down-the-cause-of-this-leak-two-months-before-anyone-reported-it-16ff</guid>
      <description>&lt;p&gt;At 00:19:25Z on 2026-10-13, wolf-crazy opened issue #251 on &lt;a href="https://github.com/yantrikos/yantrikdb" rel="noopener noreferrer"&gt;YantrikDB&lt;/a&gt;, the Rust memory engine for AI agents that I maintain. A host running the engine through yantrikdb-hermes-plugin had collected about 60 directories, roughly 490MB, over about eight days. One &lt;code&gt;potion-2M-&amp;lt;pid&amp;gt;&lt;/code&gt; directory per process start. None of them ever removed.&lt;/p&gt;

&lt;p&gt;The directories come from &lt;code&gt;BundledEmbedder&lt;/code&gt;, the zero-setup default. Its model, &lt;code&gt;potion-base-2M&lt;/code&gt;, is about 7.9MB of files compiled into the binary with &lt;code&gt;include_bytes!&lt;/code&gt;. The loader, &lt;code&gt;model2vec_rs::StaticModel::from_pretrained&lt;/code&gt;, reads from a directory, so the first use in every process writes those bytes to disk. This is what the write looked like:&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="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;temp_dir&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="s"&gt;"yantrikdb"&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="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"potion-2M-{}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;process&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create_dir_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"create temp dir {}: {e}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="nf"&gt;.display&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A new directory for every PID, and nothing anywhere in the file that deletes one.&lt;/p&gt;

&lt;h2&gt;
  
  
  PR #128
&lt;/h2&gt;

&lt;p&gt;wolf-crazy's report cross-referenced PR #128, "process-global model init + download retry." That PR fixed a different bug, a race during concurrent model initialisation. Its commit message says "the extraction path is keyed only on &lt;code&gt;process::id()&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;I wrote that PR. I merged it on 2026-08-12.&lt;/p&gt;

&lt;p&gt;So the mechanism behind this leak had been sitting in my own words, in my own merged commit, for two months. I'd noticed the PID keying while fixing the race, written it down, and done nothing else with it. It took someone else's disk filling up for it to matter to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  My reply
&lt;/h2&gt;

&lt;p&gt;I answered 15 minutes and 44 seconds after the issue went up. I grepped the file on &lt;code&gt;origin/main&lt;/code&gt; at &lt;code&gt;3dbb86a&lt;/code&gt; for &lt;code&gt;remove_dir_all&lt;/code&gt;, &lt;code&gt;impl Drop&lt;/code&gt; and &lt;code&gt;TempDir&lt;/code&gt;, found none of them, and confirmed the bug.&lt;/p&gt;

&lt;p&gt;wolf-crazy had proposed two fixes: cache the extracted model in a stable place keyed by model identity, or remove the directory on drop. I picked the cache, under &lt;code&gt;dirs::cache_dir()&lt;/code&gt;, because it also gets rid of the one-copy-per-process-start cost instead of only cleaning up after it. I also gave a stopgap for anyone already sitting on a pile of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMPDIR&lt;/span&gt;&lt;span class="s2"&gt;/yantrikdb"&lt;/span&gt; &lt;span class="nt"&gt;-maxdepth&lt;/span&gt; 1 &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s1"&gt;'potion-2M-*'&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; +1 &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove-on-drop turned out to be a non-starter anyway, and the PR body later explained why: "MODEL is a static OnceLock&amp;gt;, so the model lives for the whole process and a Drop on BundledEmbedder never runs. Cleanup-on-drop cannot fire." That &lt;code&gt;OnceLock&lt;/code&gt; is the one #128 introduced.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first version was already safe
&lt;/h2&gt;

&lt;p&gt;wolf-crazy opened PR #252 at 00:49:28Z. The first commit, &lt;code&gt;7f60cf0&lt;/code&gt;, keyed the extraction directory on a fingerprint of the model's contents and left it under &lt;code&gt;std::env::temp_dir()/yantrikdb/&lt;/code&gt;. Every process start reuses one directory. The host still only needs a writable &lt;code&gt;$TMPDIR&lt;/code&gt;, same as before. That fixes the leak completely and introduces no new requirement.&lt;/p&gt;

&lt;p&gt;At 03:55Z wolf-crazy force-pushed a rework, &lt;code&gt;b9cedc1&lt;/code&gt;, moving the directory to &lt;code&gt;&amp;lt;dirs::cache_dir()&amp;gt;/yantrikdb/models/potion-base-2M-&amp;lt;fingerprint&amp;gt;&lt;/code&gt;. Their comment on the PR: "reworked along the direction you gave on #251."&lt;/p&gt;

&lt;p&gt;That rework is where the risk came in, and it came in through my suggestion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The review
&lt;/h2&gt;

&lt;p&gt;I reviewed &lt;code&gt;b9cedc1&lt;/code&gt; at 04:21:43Z and requested changes. The main point:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The bundled model is the default path, the one that has to work without network access or setup. Today it only needs a writable $TMPDIR. With this PR it needs a writable cache dir, and hosts where HOME is set but read-only would fail on startup where they work now: systemd units with ProtectHome=read-only, containers with a read-only root filesystem and a tmpfs /tmp, and service users with HOME=/nonexistent. dirs::cache_dir() returns Some in all of those, so the existing None fallback doesn't help.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I asked for the fallback to cover the whole extract step, since "a directory that exists but is read-only fails at the write." My issue reply had said nothing about any of this. I didn't see the problem with my own idea until there was code for it, and the first code for it was this commit.&lt;/p&gt;

&lt;p&gt;The second request was about the regression test, which checked &lt;code&gt;!s.contains(&amp;amp;std::process::id().to_string())&lt;/code&gt; against a path that also holds a 64-character hex fingerprint and a model name. Any PID whose digits show up somewhere in that string fails the test for no reason connected to the bug. My review listed them: "PIDs 1–9, 17, 18, 20, 25, 36, 42, 57 and 23 others below 100,000."&lt;/p&gt;

&lt;p&gt;I recounted that against the real fingerprint while writing this. It's 18 others, 34 in total. I got the count wrong in the comment asking for the test to be exact.&lt;/p&gt;

&lt;p&gt;The same review closed with a note that nothing in the PR cleans up directories that had already leaked, and that we'd cover the cleanup from #251 in the release notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second commit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;d37c2c5&lt;/code&gt; landed at 06:47:55Z. It moves extraction into a shared &lt;code&gt;extract_into(dir)&lt;/code&gt;, and &lt;code&gt;init_model()&lt;/code&gt; now looks like this:&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="k"&gt;fn&lt;/span&gt; &lt;span class="nf"&gt;init_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Result&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;StaticModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extraction_dir&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;extract_into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(())&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nf"&gt;Err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nn"&gt;std&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;temp_dir&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="s"&gt;"yantrikdb"&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="nf"&gt;dir_name&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
            &lt;span class="nf"&gt;extract_into&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e2&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="s"&gt;"{e}; temp-dir fallback {} also failed: {e2}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;fallback&lt;/span&gt;&lt;span class="nf"&gt;.display&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="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="n"&gt;fallback&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nn"&gt;StaticModel&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(|&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt; &lt;span class="nd"&gt;format!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"model2vec_rs load: {e}"&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;Any failure on the cache path, at any stage, retries the whole extraction under &lt;code&gt;$TMPDIR&lt;/code&gt; with the same fingerprint-keyed name, and if that fails too, the error carries both causes. wolf-crazy tested it with &lt;code&gt;XDG_CACHE_HOME=/proc/self/nope&lt;/code&gt;, a cache directory that resolves but can't be written, and the model-dependent tests passed through the fallback. They reported 2204 passing for &lt;code&gt;cargo test -p yantrikdb --lib&lt;/code&gt; and 2215 with &lt;code&gt;--all-features&lt;/code&gt;. Across both commits the change is +146/-22.&lt;/p&gt;

&lt;p&gt;CI on &lt;code&gt;d37c2c5&lt;/code&gt; started around 06:54Z. The Windows leg finished last, at 07:15:05Z. Everything was green: Linux x86 and ARM, Windows, macOS, Python 3.10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 18 hours went
&lt;/h2&gt;

&lt;p&gt;I approved at 12:30:57Z: "I read the diff but didn't re-run the suite myself. Between that and CI, this looks ready — queuing it for a final merge decision." That was about five hours and sixteen minutes after CI went green.&lt;/p&gt;

&lt;p&gt;The merge came at 18:45:39Z, about six hours after that approval, with one last comment: "CI is green across Linux x86/ARM, Windows, macOS and py3.10. Thanks for the careful work and the quick turnaround, @wolf-crazy."&lt;/p&gt;

&lt;p&gt;From issue to merge took 18 hours 26 minutes. wolf-crazy's work fits inside two stretches, 00:49 to 03:55 and 04:21 to 06:48, under six hours combined. CI took about twenty minutes. From 07:15, when the last check passed, to 18:45, the PR was green, reviewed and waiting on me. That's eleven and a half hours, the biggest single block of the whole timeline.&lt;/p&gt;

&lt;p&gt;I don't think that's a problem, and I know some maintainers would read it as one. A green CI run and an approved diff from a contributor who had already caught his own regression once is exactly the kind of PR that's safe to leave behind a dozen other things for most of a day. The alternative is merging faster than I actually review, and a stale disk-filling bug that sat unfixed in my own PR description for two months doesn't get worse by sitting reviewed-and-green for half a day more.&lt;/p&gt;

&lt;p&gt;The release-notes cleanup I promised hasn't been written yet. The last release was v0.23.2 on 2026-10-02, and nothing has shipped since, this fix included. Until there's a release, the &lt;code&gt;find&lt;/code&gt; command from the issue is the only cleanup anyone has.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>rust</category>
      <category>opensource</category>
      <category>codereview</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A failed SQLite integrity check on Windows that reported itself as a POSIX lock conflict</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:11:10 +0000</pubDate>
      <link>https://dev.to/spranab/a-failed-sqlite-integrity-check-on-windows-that-reported-itself-as-a-posix-lock-conflict-obn</link>
      <guid>https://dev.to/spranab/a-failed-sqlite-integrity-check-on-windows-that-reported-itself-as-a-posix-lock-conflict-obn</guid>
      <description>&lt;p&gt;For several hours on 2026-09-30, every memory write from a Hermes Agent install on a Windows 11 machine failed with this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;YantrikDB unavailable: remember failed: refusing to write: another SQLite library has C:/Users/&amp;lt;user&amp;gt;/AppData/Local/hermes/yantrikdb-memory-ml.db open in this process (issue #225 - POSIX locks are per process, so its unlock releases the engine's and the two writers would interleave WAL commits). Close that connection, check integrity and reopen the engine (its close may have unlinked the shared-memory file); use the engine API / a separate process for raw SQL.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The engine refusing those writes was &lt;a href="https://github.com/yantrikos/yantrikdb" rel="noopener noreferrer"&gt;YantrikDB&lt;/a&gt; 0.23.1, a persistent memory engine for AI agents that I maintain (Rust core, Python bindings on PyPI). @pawelsu8 filed &lt;a href="https://github.com/yantrikos/yantrikdb/issues/247" rel="noopener noreferrer"&gt;issue #247&lt;/a&gt; the same day, from Windows 11 build 26200, CPython 3.14.7 and stdlib sqlite3 3.53.1. The title is long, and every clause in it turned out to be accurate: "ForeignSqliteInstance latches writes off on Windows, where the foreign-instance detector is inert: a failed quick_check taints the store but reports as an in-process foreign SQLite library, with no in-process recovery."&lt;/p&gt;

&lt;h2&gt;
  
  
  The message described something that can't happen on Windows
&lt;/h2&gt;

&lt;p&gt;The error is about POSIX advisory locks. They belong to the whole process, so if another SQLite library in the same process closes the same file, its unlock releases the engine's locks as well. That's the scenario the engine's in-process foreign-SQLite detector, &lt;code&gt;ForeignSqliteGuard&lt;/code&gt;, looks for.&lt;/p&gt;

&lt;p&gt;On Windows the detector does nothing. &lt;code&gt;ForeignSqliteGuard::new&lt;/code&gt; sets &lt;code&gt;shm_path = None&lt;/code&gt; on any target that isn't Linux or macOS, so &lt;code&gt;supported()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt; and &lt;code&gt;scan()&lt;/code&gt; returns immediately. The module's doc comment says so: "Windows locks are per handle and does not have the problem; there the detector reports &lt;code&gt;supported = false&lt;/code&gt; and the mode is inert."&lt;/p&gt;

&lt;p&gt;@pawelsu8 checked that against the running system. He dumped &lt;code&gt;stats()&lt;/code&gt; on the affected machine and got &lt;code&gt;foreign_sqlite_supported: false&lt;/code&gt;, &lt;code&gt;foreign_sqlite_active: false&lt;/code&gt;, &lt;code&gt;foreign_sqlite_tainted: false&lt;/code&gt;. Then he opened the same store with Python's stdlib &lt;code&gt;sqlite3&lt;/code&gt;, read-only and read-write, with and without commits, and none of it ever set &lt;code&gt;active&lt;/code&gt; or &lt;code&gt;tainted&lt;/code&gt;. The detector was inert exactly as documented, which meant something else was refusing his writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the refusal came from
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;check_write()&lt;/code&gt; doesn't look at the &lt;code&gt;active&lt;/code&gt; flag the detector sets. It refuses on &lt;code&gt;tainted&lt;/code&gt;, and &lt;code&gt;tainted&lt;/code&gt; has a second writer with no platform gate: &lt;code&gt;note_integrity()&lt;/code&gt;. The background materializer tracks SQLite's &lt;code&gt;PRAGMA data_version&lt;/code&gt;, and when it notices a commit from outside the engine it runs &lt;code&gt;PRAGMA quick_check(1)&lt;/code&gt; on a read connection and hands the result to &lt;code&gt;note_integrity()&lt;/code&gt;. A failed check sets &lt;code&gt;tainted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On Windows that's the only thing that can set it, so every refusal of this kind on that platform was a failed integrity check. Both causes raised the same error type, &lt;code&gt;ForeignSqliteInstance&lt;/code&gt;, with the same fixed message. That message described the cause that was impossible on Windows and told him to close a connection that couldn't exist. What had actually happened, a check saying the database might be corrupt, wasn't mentioned anywhere. He called that "a far more serious thing to be told about," and he was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read-only, with no way out
&lt;/h2&gt;

&lt;p&gt;The Python object exposes &lt;code&gt;close()&lt;/code&gt;, but there was no &lt;code&gt;reopen()&lt;/code&gt; or &lt;code&gt;recover()&lt;/code&gt;. Opening the store from a second process got "database is locked." So the message's own advice, reopen the engine, couldn't be followed from inside the running host or from outside it. Restarting the whole application was the only thing that worked.&lt;/p&gt;

&lt;p&gt;Reads kept succeeding the whole time, so nothing looked broken. Refused writes meant no commits, no commits meant no checkpoint, and the WAL file grew to 6,344,832 bytes next to a 6,307,840-byte database.&lt;/p&gt;

&lt;p&gt;There was one more exit, which he found by reading the source: &lt;code&gt;set_foreign_sqlite_mode("warn")&lt;/code&gt;. It resumes writes without re-checking integrity, and the setting persists, so every future open of that store runs with both refusal mechanisms disarmed. He used it cautiously and flagged it in the issue as worse than the docs make it look.&lt;/p&gt;

&lt;p&gt;I confirmed the trace and marked it as a bug. My reply opened with: "Thanks for this one — tracing &lt;code&gt;check_write&lt;/code&gt; down to &lt;code&gt;decide()&lt;/code&gt; and pulling the &lt;code&gt;stats()&lt;/code&gt; flags dump to confirm &lt;code&gt;supported=false&lt;/code&gt; empirically is the kind of report that gets acted on fast."&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit 33d0be9
&lt;/h2&gt;

&lt;p&gt;The fix is commit &lt;code&gt;33d0be9&lt;/code&gt;, 15 files changed, +404/-45, released as v0.23.2 (tagged 2026-10-02T07:04:44Z; &lt;code&gt;pip install -U yantrikdb&lt;/code&gt;). Most of it maps directly onto the numbered requests at the end of his issue:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A new &lt;code&gt;IntegrityCheckFailed&lt;/code&gt; error, whose message carries the actual &lt;code&gt;quick_check&lt;/code&gt; result. &lt;code&gt;ForeignSqliteInstance&lt;/code&gt; is now raised only by the in-process detector, so the Windows message above can't be produced at all anymore.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stats()&lt;/code&gt; shows the cause too. There are new &lt;code&gt;integrity_tainted&lt;/code&gt; and &lt;code&gt;integrity_checks_unconfirmed_since_boot&lt;/code&gt; keys, and &lt;code&gt;foreign_sqlite_tainted&lt;/code&gt; now reflects the detector alone.&lt;/li&gt;
&lt;li&gt;The refusal no longer latches. The next &lt;code&gt;quick_check&lt;/code&gt; that returns &lt;code&gt;ok&lt;/code&gt; resumes writes with no restart. A repair made from another process is itself an outside commit, so the engine notices it and re-checks on its own.&lt;/li&gt;
&lt;li&gt;A failing check is re-run once on the writer connection before anything is refused. A one-off failure gets counted and that's all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The release note ends: "Reported by @pawelsu8 with a source-level trace, stats() evidence from the affected Windows machine, and test-coverage requests that shaped the fix. Thank you."&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock nobody could explain
&lt;/h2&gt;

&lt;p&gt;Testing the fix turned up a second bug. What I wrote to him: "Testing it turned up a second problem that explains your 'can't open it from another process' symptom."&lt;/p&gt;

&lt;p&gt;While writes were refused, the materializer kept retrying its pending work every 100ms, on every worker, forever. Each attempt took the store's write lock and was then aborted at commit. Seen from any other process, the file was write-locked almost continuously, so his attempt to open the store separately got "database is locked" even with a 30-second busy timeout. So would any attempt to repair it.&lt;/p&gt;

&lt;p&gt;That changes how the rest of the report reads. The refusal existed to protect the database until someone worked out what had gone wrong, and the engine's own retry loop was locking out every process that could have done the work. The materializer now pauses while writes are refused and picks back up once a check comes back clean.&lt;/p&gt;

&lt;p&gt;Two new Python tests damage a store for real from a second process, by writing a row that violates a CHECK constraint (which &lt;code&gt;quick_check&lt;/code&gt; reports), and assert that the engine recovers. One of them failed with exactly "database is locked" before the materializer change went in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is one retry enough?
&lt;/h2&gt;

&lt;p&gt;The engine now re-runs a failed &lt;code&gt;quick_check&lt;/code&gt; once, refuses only if it fails again, and resumes by itself the next time a check passes. Someone could fairly argue that's too permissive. An integrity failure means possible corruption, and possible corruption should stop writes until a person has looked.&lt;/p&gt;

&lt;p&gt;I think one retry plus auto-resume is the right setting for this engine. Its job is to be an agent's memory, and #247 is a detailed record of what a refusal that only a human can clear looks like in practice: hours of dropped writes, reads that make everything look fine, a remedy the user can't reach, and finally a workaround that switches the protection off permanently. The one person who hit this found &lt;code&gt;"warn"&lt;/code&gt; by reading Rust source, and he's more careful than most users will be. A refusal that names the failure in the error and in &lt;code&gt;stats()&lt;/code&gt;, and lifts when a check passes, is one people will leave turned on.&lt;/p&gt;

&lt;p&gt;The cost is that the engine now treats a failure that doesn't repeat as a blip. If the storage underneath is bad enough to produce damage that only shows up some of the time, a clean check can resume writes on top of it. And the best evidence for the stricter view is still open on the issue.&lt;/p&gt;

&lt;p&gt;Nobody knows why his process got a bad &lt;code&gt;quick_check&lt;/code&gt; result in the first place. His offline copy of the same database checks clean. My last note on the issue says: "Still unknown: why your process got a bad quick_check result when your offline copy checks clean. The original result string was lost with the restart. If it happens again on 0.23.2, the error message and stats()['last_integrity_check'] will contain it, and integrity_checks_unconfirmed_since_boot will show whether it was a one-off. We'd be glad to see either."&lt;/p&gt;

&lt;p&gt;I also asked him to switch the store from &lt;code&gt;"warn"&lt;/code&gt; back to &lt;code&gt;"refuse"&lt;/code&gt;. Nothing needs the workaround on 0.23.2, and if it stays set, both refusal paths stay disarmed on every open of that file, including the next time a check fails for a reason none of us can explain yet.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>rust</category>
      <category>sqlite</category>
      <category>opensource</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My harness logged "duplicates not canonicalized." It was counting page size.</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 01 Oct 2026 15:05:34 +0000</pubDate>
      <link>https://dev.to/spranab/my-harness-logged-duplicates-not-canonicalized-it-was-counting-page-size-365i</link>
      <guid>https://dev.to/spranab/my-harness-logged-duplicates-not-canonicalized-it-was-counting-page-size-365i</guid>
      <description>&lt;p&gt;Hermes Agent's docs have a user-stories page, and two of the Discord quotes on it explain better than I can why people end up writing their own memory layer. From @mibayy, in the community project showcase: "After ~30 turns, context compression silently removes older messages. A constraint decided at turn 5 is gone by turn 50." @lauratom posted in the same showcase, with more swearing than I'll reproduce, that they'd spent 200 to 400 hours writing a memory kernel for Hermes. In a separate dev-workflow thread they described what it does: "It has actual lifecycle management — decay, promotion, and supersession. So if my negotiation tactics evolve, the kernel actively demotes the old info."&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yantrikos/yantrikdb-hermes-plugin" rel="noopener noreferrer"&gt;yantrikdb-hermes-plugin&lt;/a&gt; is my attempt at that problem, a memory provider for &lt;a href="https://github.com/NousResearch/hermes-agent" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt;. MIT-licensed, v0.27.0 on PyPI since 2026-09-19, about 92 stars and roughly 1,600 downloads a month as of 2026-09-28. The README opens with a bolded tagline: canonicalizes duplicates, surfaces contradictions, explains recall.&lt;/p&gt;

&lt;p&gt;Below that sits a comparison against the other Hermes providers. The first version of that table came from each provider's own &lt;code&gt;plugin.yaml&lt;/code&gt;, and &lt;code&gt;tests/comparison/README.md&lt;/code&gt; records why I stopped accepting that: "a provider could implement canonicalization, contradiction tracking, or explainable recall without advertising it. Claiming 'YantrikDB has X and the others don't' based only on what they say about themselves is strawmanning, not comparing."&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;tests/comparison/&lt;/code&gt; drives each bundled provider through Hermes' real &lt;code&gt;MemoryProvider&lt;/code&gt; contract and saves the raw responses. Six of the nine (byterover, honcho, mem0, openviking, retaindb, supermemory) need a cloud key, an account or a server URL the harness doesn't have, and their rows say &lt;code&gt;unknown&lt;/code&gt;. I'm fine with six blanks. That rule matters later in this post, because the harness didn't apply it to itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  95 passing tests and an "Unknown tool"
&lt;/h2&gt;

&lt;p&gt;On 2026-04-14 I ran the plugin inside an unmodified Hermes 0.9.0 install on LXC 129, with DeepSeek driving. Every &lt;code&gt;yantrikdb_*&lt;/code&gt; call the model attempted resolved as "Unknown tool," and it fell back to Hermes' built-in memory tool without complaint.&lt;/p&gt;

&lt;p&gt;One guard caused it. &lt;code&gt;get_tool_schemas()&lt;/code&gt; checked &lt;code&gt;self._client is None&lt;/code&gt; and returned &lt;code&gt;[]&lt;/code&gt; until &lt;code&gt;initialize()&lt;/code&gt; had run, but Hermes asks for schemas in &lt;code&gt;MemoryManager._register_provider&lt;/code&gt;, at registration time, before &lt;code&gt;initialize()&lt;/code&gt; is ever called. Hermes registered a provider that had no tools.&lt;/p&gt;

&lt;p&gt;The fix returns the schema list unconditionally and moves the readiness check into &lt;code&gt;handle_tool_call()&lt;/code&gt;. A regression test, &lt;code&gt;test_schemas_available_before_initialize&lt;/code&gt;, went in with it. All 95 unit tests that existed then had passed against the broken version, since none of them called things in the order Hermes does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What DeepSeek did with &lt;code&gt;why_retrieved&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Once the tools resolved, the same session asked for &lt;code&gt;yantrikdb_recall&lt;/code&gt; with &lt;code&gt;query='Pranab Sarkar Rust'&lt;/code&gt; and the raw JSON. The top result:&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="nl"&gt;"rid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"019d8eac-f59b-..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"My name is Pranab Sarkar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"why_retrieved"&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;"semantically similar (0.59)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"important (decay=0.76)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"keyword_match"&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;Two older "benchmark memory" entries ranked near the bottom with &lt;code&gt;why_retrieved&lt;/code&gt; of exactly &lt;code&gt;["recent"]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On 2026-05-09, on the embedded backend (pip install, no server, no token, a 3-line &lt;code&gt;.env&lt;/code&gt;), DeepSeek summarised a recall in its own words: "All 3 memories returned, ranked by relevance × recency × importance. Your name ranked highest (semantic match + keyword + high importance + recency), followed by the Rust preference (keyword match), then the YantrikDB project (high importance but no direct keyword overlap)."&lt;/p&gt;

&lt;p&gt;I used to describe that as unprompted, which overstates it. The &lt;code&gt;yantrikdb_recall&lt;/code&gt; tool description goes to the model with every tool definition, and it says each result "includes a &lt;code&gt;why_retrieved&lt;/code&gt; list (semantic_match / graph-connected / keyword_match / important / emotionally weighted, etc.) so the agent can see why each memory ranked." DeepSeek knew the field existed. It couldn't know in advance which reasons a particular memory would get. It read the real ones off the response and turned them into an accurate sentence.&lt;/p&gt;

&lt;p&gt;In the current engine source, &lt;code&gt;why_retrieved&lt;/code&gt; is a &lt;code&gt;Vec&amp;lt;String&amp;gt;&lt;/code&gt; that scoring stages append to as they touch a candidate. Two reasons you'll see now, &lt;code&gt;"multi-lane agreement (N lanes)"&lt;/code&gt; and the staleness hedges added by &lt;code&gt;stamp_trust_metadata&lt;/code&gt;, landed in August and June respectively, after both sessions. They describe the current engine. They don't explain that April transcript.&lt;/p&gt;

&lt;p&gt;The embedded backend is also just faster for a single-agent setup: &lt;code&gt;record_text&lt;/code&gt; p50 0.60ms against 13.8ms over HTTP, &lt;code&gt;recall_text&lt;/code&gt; p50 2.58ms against 24.0ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rereading the scale probe
&lt;/h2&gt;

&lt;p&gt;On 2026-05-12, again on LXC 129, against Hermes commit &lt;code&gt;4610551&lt;/code&gt; and plugin v0.4.2, I pointed the harness at 1000 facts and 20 queries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;writing 1000 facts via yantrikdb_remember...
write retries exhausted for fact #257 after 60 attempts
write retries exhausted for fact #258 after 60 attempts
write retries exhausted for fact #259 after 60 attempts
WRITE TIMEOUT at fact #357 after 600.0s
backpressure retries: 6000
writes: 256/1000 ok (failures=100); p50=0.48ms p99=5.13ms
running 20 queries via yantrikdb_recall...
recalls: 20/20 ok; p50=3.78ms p99=32.94ms; precision@K=16/20
shape (first non-empty result): why_retrieved=True('why_retrieved') score=True metadata=False
duplicate-canonicalization: avg results per Q-dup-* query = 5.0 → false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line went into &lt;code&gt;findings_scale.yaml&lt;/code&gt; as &lt;code&gt;duplicate_canonicalized: false&lt;/code&gt;, and I believed it. The first draft of this post repeated it, with a story about bursts outrunning consolidation. A fact-check on that draft opened the raw response for &lt;code&gt;Q-dup-01&lt;/code&gt;, whose target is "The user prefers nord as their color scheme in IntelliJ." The five results were nord in Hyper, 13pt in iTerm2, light mode in Terminal.app, Fira Code in Hyper, and nord in Emacs. The target isn't among them, and no two of them are the same fact.&lt;/p&gt;

&lt;p&gt;Here's the check, from &lt;code&gt;tests/comparison/probe_scale.py&lt;/code&gt;:&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;dup_returns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&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;per_query_results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&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;Q-dup-&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;dup_returns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;avg_dup_count&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="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;returned&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;dup_returns&lt;/span&gt;&lt;span class="p"&gt;)&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;dup_returns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duplicate_count_observed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;avg_dup_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# If each dup query returns &amp;gt;= 2 results that include the duplicated
&lt;/span&gt;    &lt;span class="c1"&gt;# text, that's strong evidence of no synchronous canonicalization.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;avg_dup_count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duplicate_canonicalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;returned&lt;/code&gt; is &lt;code&gt;len(items)&lt;/code&gt;. The comment says "that include the duplicated text," and nothing in the code reads the text. All five &lt;code&gt;Q-dup-*&lt;/code&gt; queries ask for &lt;code&gt;top_k=5&lt;/code&gt;, so 5.0 means each one got a full page. Any store holding five vaguely related facts gives you 5.0 and a &lt;code&gt;false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It couldn't have found duplicates anyway. The planted duplicates in &lt;code&gt;corpus_1k.json&lt;/code&gt; are ids 901 to 1000, and the write phase stopped around 256. The transcript shows the timeout and then computes a duplicate verdict on the next lines, for duplicates that were never written. The same harness that marks other providers &lt;code&gt;unknown&lt;/code&gt; when it can't run them wrote &lt;code&gt;false&lt;/code&gt; here.&lt;/p&gt;

&lt;p&gt;The 16/20 precision@5 on the facts that did land is a different number from a different check, and I'm not retracting it here — but it's also scored by word overlap on a templated corpus, which I didn't re-audit hit by hit before writing this. What I can say cleanly is the negative: whatever that score means, it says nothing about duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why exactly 256
&lt;/h2&gt;

&lt;p&gt;The write failures were real, and the number has a cause. Engine commit &lt;code&gt;f6132e0&lt;/code&gt; on 2026-06-27, "fix(python): spawn materializer + compactor workers in engine constructors," explains it: the pyo3 constructors "wrapped a fresh &lt;code&gt;YantrikDB&lt;/code&gt; in an &lt;code&gt;Arc&lt;/code&gt; but never spawned its background worker pool. Without the compactor the in-memory delta tier fills to &lt;code&gt;delta_max&lt;/code&gt; (256) and every subsequent write returns Backpressure ('ingest queue full') — wedging any long write session... after ~256 records."&lt;/p&gt;

&lt;p&gt;It shipped in engine v0.9.0. The plugin's current minimum engine is 0.12.1, so anyone installing it today has the fix. Write throughput was never the problem. A worker pool that never started was.&lt;/p&gt;

&lt;p&gt;So the half of the probe that showed up as a crash got chased down in about six weeks. The half that showed up as a clean-looking verdict sat in a findings file I'd been calling reproducible for almost five months, until this article made me reread it.&lt;/p&gt;

&lt;p&gt;The file is reproducible. Rerun &lt;code&gt;probe_scale.py&lt;/code&gt; against the same setup and you'll get 5.0 again, every time, because the metric can't produce anything else. I'd been treating "reproducible" as if it meant "checked," and it doesn't. I built the harness because I didn't trust copy I'd written about my own plugin, and then I trusted a YAML file I'd generated without ever opening the raw JSON next to it. I don't think a findings file deserves more credit than a README does. A verdict should cite the raw response that backs it, and a verdict computed from a run that never wrote its test inputs should say &lt;code&gt;unknown&lt;/code&gt;. The harness already holds the other providers to that rule.&lt;/p&gt;

&lt;p&gt;— Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>testing</category>
    </item>
    <item>
      <title>The embedder I attached got 0 encode() calls during writes</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 24 Sep 2026 15:26:27 +0000</pubDate>
      <link>https://dev.to/spranab/the-embedder-i-attached-got-0-encode-calls-during-writes-1k5b</link>
      <guid>https://dev.to/spranab/the-embedder-i-attached-got-0-encode-calls-during-writes-1k5b</guid>
      <description>&lt;p&gt;A brand-new memory store worked fine in one process. In the next one the plugin's init failed, and every Hermes tool call after that came back with &lt;code&gt;YantrikDB is not active for this session.&lt;/code&gt; Underneath, the engine had refused to attach the embedder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;this database's vectors were built by potion-base-8M (digest sha256:89dd…, dim 256), and the embedder being attached declares no `fingerprint` or `digest`.
Queries would be encoded in a different space than the vectors they are compared against, and cosine distance still returns a plausible number for unrelated spaces — so the results would look fine and be wrong.
Options: attach the embedder that built them; set `.fingerprint = "sha256:89dd…"` on your embedder if it IS that model; call reembed() to rebuild the vectors in the new space; or pass allow_unverified_embedder=True if you accept the risk.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;albertoMartinsen filed that as &lt;a href="https://github.com/yantrikos/yantrikdb-hermes-plugin/issues/84" rel="noopener noreferrer"&gt;#84&lt;/a&gt; on 2026-09-16, on plugin 0.25.0, engine 0.23.0, Hermes Agent 0.21.3, macOS arm64 and Python 3.11.15, with &lt;code&gt;minishlab/potion-multilingual-128M&lt;/code&gt; configured through the plugin's model2vec option.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yantrikos/yantrikdb" rel="noopener noreferrer"&gt;YantrikDB&lt;/a&gt; is a persistent memory engine for AI agents, a Rust core shipped to Python as the &lt;code&gt;yantrikdb&lt;/code&gt; wheel. &lt;a href="https://github.com/yantrikos/yantrikdb-hermes-plugin" rel="noopener noreferrer"&gt;yantrikdb-hermes-plugin&lt;/a&gt; is a memory provider for &lt;a href="https://github.com/NousResearch/hermes-agent" rel="noopener noreferrer"&gt;Hermes Agent&lt;/a&gt; whose default backend runs that engine in-process. I maintain both.&lt;/p&gt;

&lt;p&gt;An embedder is the model that turns memory text into vectors. The engine attaches a default one at the store's dimension when it has one: potion-base-2M at 64, inside the wheel, or potion-base-8M at 256, downloaded on first use (about 28 MB, SHA-256 pinned) and cached. The plugin lets you configure your own, and the reporter's multilingual model is 256-d too.&lt;/p&gt;

&lt;p&gt;The guard works off a stamp. When the engine's own embedder encodes anything for an unstamped store, it records its identity there (one appears in the output further down). A Python embedder can carry the matching digest as &lt;code&gt;.fingerprint&lt;/code&gt; or &lt;code&gt;.digest&lt;/code&gt;. Attach one that carries neither to a stamped store and the engine can't tell it from any other model of that width, so it refuses. That refusal is the fix for engine issue #117, which I opened on 2026-07-28: a reopened store accepted a different embedder at the same dimension, and every recall after that was wrong with no error. PR #138 closed it in August.&lt;/p&gt;

&lt;p&gt;The reporter had noticed the stamp named the engine's default, potion-base-8M, and read that as the store recording the wrong identity. They proposed a stable fingerprint on the plugin's loader wrappers, with the store recording the intended identity at creation, and offered &lt;code&gt;reembed()&lt;/code&gt; or &lt;code&gt;allow_unverified_embedder=True&lt;/code&gt; as alternatives. About half an hour later I agreed, on the strength of a grep (no &lt;code&gt;.fingerprint&lt;/code&gt; on the wrappers, no allow-unverified flag on that call path), and wrote "Real fix is on the plugin side per your suggested directions", which put the bug in the wrong repo.&lt;/p&gt;

&lt;h1&gt;
  
  
  83, the reporter's other issue that day (an always-empty fleet view), had a similar shortcut in its tests: they mocked &lt;code&gt;list_records&lt;/code&gt;, the very method that swapped a missing namespace for the base one, so the swap never ran.
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Counting encode() calls
&lt;/h2&gt;

&lt;p&gt;About nine and a half hours after that reply I posted a different diagnosis. I'd reproduced their failure, same &lt;code&gt;sha256:89dd…&lt;/code&gt; digest, and counted the attached embedder's &lt;code&gt;encode()&lt;/code&gt; calls during a write: 0 at 256 dimensions, where the engine had a native embedder, and 1 at 384, where it had none.&lt;/p&gt;

&lt;p&gt;I reran it today (2026-09-24) with a script like the one further down, plus a call counter on the embedder: an unfingerprinted embedder, one &lt;code&gt;record_text&lt;/code&gt;, a read of &lt;code&gt;db.embedder_identity()&lt;/code&gt;, then a reopen and a reattach. &lt;code&gt;set_embedder&lt;/code&gt; calls &lt;code&gt;encode()&lt;/code&gt; once as a probe, so the counter is zeroed after attaching. Each version got its own venv from PyPI, on Windows 11 with Python 3.13.5. Trimmed (the &lt;code&gt;#####&lt;/code&gt; headers are mine; I cut the digest and the refusal text):&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="gu"&gt;##### 0.23.0 / dim 256&lt;/span&gt;
native embedder present before attach: True
my encode() calls during record_text: 0
identity stamped on the store: {'name': 'potion-base-8M', 'digest': 'sha256:89dd9605...', 'dim': 256}
reopen + reattach my embedder: REFUSED

&lt;span class="gu"&gt;##### 0.23.1 / dim 256&lt;/span&gt;
native embedder present before attach: True
my encode() calls during record_text: 1
identity stamped on the store: None
reopen + reattach my embedder: OK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 64, where the default is the potion-base-2M inside the wheel, both versions behave as they do at 256; PR #240's five tests run at 64. At 384 there's no default and both look like the 0.23.1 block, matching my 09-16 table.&lt;/p&gt;

&lt;p&gt;On 0.23.0, at those widths, no write went through the attached object. potion-base-8M had encoded the reporter's records, so the stamp was accurate and the refusal on reopen was the #117 check doing what it was built for. The plugin log said &lt;code&gt;attached embedder model2vec=… (dim=256)&lt;/code&gt; throughout. Someone who chose a 256-d multilingual model on 0.23.0 to hold non-English memories would have been getting potion-base-8M vectors instead, with no error while writing. I haven't measured what that does to recall.&lt;/p&gt;

&lt;p&gt;With that override in place, the fingerprint plan (the one I'd endorsed) would have stamped the multilingual model's identity at creation and let the reopen pass, over vectors potion-base-8M produced. &lt;code&gt;allow_unverified_embedder=True&lt;/code&gt; skips the comparison. Either would have ended the refusal while the native-first order stayed, and the attached model would still have gotten no calls.&lt;/p&gt;

&lt;p&gt;That second comment also said a fresh store, or &lt;code&gt;reembed()&lt;/code&gt;, would fix stores already written. Minutes later I retracted the &lt;code&gt;reembed()&lt;/code&gt; half: &lt;code&gt;hasattr(YantrikDB, "reembed")&lt;/code&gt; is False. It's a staged rebuild with crash recovery in the Rust engine that the Python binding doesn't expose. The guard message above lists it among the options. That's engine issue &lt;a href="https://github.com/yantrikos/yantrikdb/issues/241" rel="noopener noreferrer"&gt;#241&lt;/a&gt;, still open.&lt;/p&gt;

&lt;p&gt;The override was in the Python binding, &lt;code&gt;crates/yantrikdb-python/src/py_engine/mod.rs&lt;/code&gt;. &lt;code&gt;embed_text&lt;/code&gt; asked the Rust-native embedder first and fell through to the attached Python object only when there wasn't one. &lt;code&gt;set_embedder(obj)&lt;/code&gt; probes the object with &lt;code&gt;encode("__yantrikdb_probe__")&lt;/code&gt;, checks the output is numeric, runs &lt;code&gt;check_embedder_identity&lt;/code&gt; and stores it, as the fallback. The wheel is built with the &lt;code&gt;embedder-download&lt;/code&gt; feature, so at 64, and at 256 whenever potion-base-8M was cached or could be fetched, the native embedder was already there. The fix, abbreviated:&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="c1"&gt;// embed_text, before: native first; the attached Python object was only the fallback below&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&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="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.inner&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;db&lt;/span&gt;&lt;span class="nf"&gt;.has_embedder&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="n"&gt;db&lt;/span&gt;&lt;span class="nf"&gt;.embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nf"&gt;.map_err&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;map_err&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="c1"&gt;// embed_text, after: the attached object first; the native check follows&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nf"&gt;Some&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="py"&gt;.embedder&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;let&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;emb&lt;/span&gt;&lt;span class="nf"&gt;.call_method1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"encode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="o"&gt;...&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more places in &lt;code&gt;memory.rs&lt;/code&gt; made the same choice, the &lt;code&gt;record_text&lt;/code&gt; fast path and then the correction path, and each got one clause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-None if db.has_embedder() =&amp;gt;
&lt;/span&gt;&lt;span class="gi"&gt;+None if db.has_embedder() &amp;amp;&amp;amp; self.embedder.is_none() =&amp;gt;
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="gd"&gt;-let use_caller_embed = new_text.is_some() &amp;amp;&amp;amp; !db.has_embedder() &amp;amp;&amp;amp; self.embedder.is_some();
&lt;/span&gt;&lt;span class="gi"&gt;+let use_caller_embed = new_text.is_some() &amp;amp;&amp;amp; self.embedder.is_some();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I think a default the engine picked because the dimension matched should lose to an object the caller attached by hand, and that's the order now; &lt;code&gt;set_embedder&lt;/code&gt; still runs the identity check on that object.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yantrikos/yantrikdb/pull/240" rel="noopener noreferrer"&gt;PR #240&lt;/a&gt; merged as &lt;code&gt;49c7aac&lt;/code&gt;, 10h31m after the report (+172/-20, 3 files). Its five tests in &lt;code&gt;tests/test_attached_embedder_precedence.py&lt;/code&gt; check both that the attached embedder runs and that the native one still runs when nothing is attached; one first asserts that the dimension really auto-attaches a native embedder.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/yantrikos/yantrikdb/releases/tag/v0.23.1" rel="noopener noreferrer"&gt;v0.23.1&lt;/a&gt; shipped on 2026-09-19 with a behaviour change, in a patch release: &lt;code&gt;record_text&lt;/code&gt; with an &lt;code&gt;idempotency_key&lt;/code&gt; and a Python embedder now raises the existing explicit refusal, where 0.23.0 succeeded through the native path. Stores written the old way keep refusing, and the notes say to start a fresh one.&lt;/p&gt;

&lt;p&gt;Plugin v0.27.0 went out the same day. Its dependency range, on main and in the 0.27.0 metadata, is &lt;code&gt;yantrikdb&amp;gt;=0.12.1,!=0.15.0,!=0.15.1,!=0.15.2,&amp;lt;0.24.0&lt;/code&gt;, so an install already on 0.23.0 keeps the old order until someone runs &lt;code&gt;pip install -U yantrikdb&lt;/code&gt;. I said so in a 09-20 status comment, after running the PR's test file against 0.23.1 from PyPI in a clean venv (5 passed), and left #84 open. I haven't run the reporter's exact configuration, potion-multilingual-128M through the plugin's model2vec path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reverse case
&lt;/h2&gt;

&lt;p&gt;On 2026-09-22 the reporter confirmed the fix on plugin 0.27.0 and engine 0.23.1 (a brand-new model2vec store, 5 of 5 reopens in fresh processes through &lt;code&gt;make_backend&lt;/code&gt;), then posted a three-step repro for the opposite failure, each step a new process. Create a store with the external embedder; its meta has no &lt;code&gt;embedder_*&lt;/code&gt; keys. Open it with the default config and do one ordinary recall, which succeeds without a warning and leaves the store stamped potion-base-8M/256. Go back to the external embedder and it's refused, on a file where &lt;code&gt;PRAGMA quick_check&lt;/code&gt; still passes.&lt;/p&gt;

&lt;p&gt;I traced it through the engine that day. &lt;code&gt;check_embedder_identity&lt;/code&gt; sits in the Python binding and runs only while a Python embedder is being attached, through the constructor's &lt;code&gt;embedder=&lt;/code&gt; or &lt;code&gt;set_embedder()&lt;/code&gt;. On the engine's own encode path, &lt;code&gt;YantrikDB::embed()&lt;/code&gt; calls &lt;code&gt;stamp_embedder_identity_once()&lt;/code&gt; after every successful embed, and nothing on that path asks whether the vectors already in the store came from a different model. Query text goes through &lt;code&gt;embed()&lt;/code&gt; too, so one recall is enough.&lt;/p&gt;

&lt;p&gt;Two days later I ran it. The script below does their three steps against the engine only, 0.23.1 from PyPI in a clean venv, same Windows 11 and Python 3.13.5. Theirs went through &lt;code&gt;make_backend&lt;/code&gt; with model2vec on macOS; mine uses a hash-based stand-in and three handles in one process, closed between steps. Trimmed (docstring and an unused import removed):&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yantrikdb&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;yantrikdb&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;YantrikDB&lt;/span&gt;

&lt;span class="n"&gt;DIM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counting&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&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="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;self&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;t&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100_000&lt;/span&gt;
        &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.001&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DIM&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mf"&gt;1.0&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;x&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tempfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdtemp&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;s.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&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;yantrikdb &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;yantrikdb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__version__&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;?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&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;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YantrikDB&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;embedding_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DIM&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;set_embedder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Counting&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;record_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dana Okafor leads the ML Platform team.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1. external embedder writes; identity on store:&lt;/span&gt;&lt;span class="sh"&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;embedder_identity&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YantrikDB&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;embedding_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DIM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# default config, nothing attached
&lt;/span&gt;&lt;span class="n"&gt;hits&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;recall_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;who leads ML Platform&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2. default open + one recall; identity on store:&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="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embedder_identity&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YantrikDB&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;embedding_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DIM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;try&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;set_embedder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Counting&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. reattach the external embedder: OK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# noqa: BLE001
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3. reattach the external embedder: REFUSED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&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;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;170&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&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;span class="k"&gt;finally&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;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rmtree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ignore_errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yantrikdb 0.23.1
1. external embedder writes; identity on store: None
2. default open + one recall; identity on store: potion-base-8M
3. reattach the external embedder: REFUSED
   this database's vectors were built by potion-base-8M (digest sha256:89dd960591c4fa0c7f7a45ed4cb94167ce4e09886f39bae008b8072b42439ac5, dim 256), and the embedder being att...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repeating steps 1 and 2 with the same class, then trying the reattach plain and with the flag the guard message offers, gives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yantrikdb 0.23.1
after default open + recall, identity: potion-base-8M
plain set_embedder: REFUSED
set_embedder(allow_unverified_embedder=True): OK, write succeeded; identity still: potion-base-8M
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin doesn't pass that flag on the external-embedder path (my first reply on #84 says as much), so through the plugin that store refuses the embedder that built it on every open. Nothing supported rewrites the stamp either: &lt;code&gt;adopt_embedder_identity&lt;/code&gt; refuses when a different digest is already recorded, and &lt;code&gt;reembed()&lt;/code&gt; is #241.&lt;/p&gt;

&lt;p&gt;The doc comment on &lt;code&gt;stamp_embedder_identity_once&lt;/code&gt; covers this, in a paragraph headed "Known over-claim", and argues the stamp "does not create a failure that was not already there". I think that's wrong, and the repro is why. Skip step 2 and the reattach works (the 0.23.1 run at 256 in the first output block). Do step 2 and it's refused. The default-config session does query in the wrong space, as the comment says, but only for that session; the stamp it leaves locks the plugin out of the embedder that wrote the store.&lt;/p&gt;

&lt;p&gt;The reporter suggested two fixes: a symmetric check that refuses the native embedder against a store with vectors and no recorded identity, or stamping the external embedder's identity at the first write. It reproduces on 0.23.1 as of 2026-09-24 and only exists as comments on #84 right now. No engine commit since 2026-09-21 touches the stamping; the latest is PR #246, a vault-key change.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>opensource</category>
      <category>debugging</category>
    </item>
    <item>
      <title>I Rebuilt Real-Time Push Six Times in One Evening, Then Quietly Added a Cron</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 17 Sep 2026 15:07:27 +0000</pubDate>
      <link>https://dev.to/spranab/i-rebuilt-real-time-push-six-times-in-one-evening-then-quietly-added-a-cron-2goh</link>
      <guid>https://dev.to/spranab/i-rebuilt-real-time-push-six-times-in-one-evening-then-quietly-added-a-cron-2goh</guid>
      <description>&lt;p&gt;SwarmCode is an MCP server that lets two Claude Code instances on different machines message each other, so I stop copy-pasting between two terminal windows when one agent needs to hand work to another. Repo: &lt;a href="https://github.com/spranab/swarmcode" rel="noopener noreferrer"&gt;github.com/spranab/swarmcode&lt;/a&gt;. npm: &lt;a href="https://www.npmjs.com/package/swarmcode-mcp" rel="noopener noreferrer"&gt;swarmcode-mcp&lt;/a&gt;. Neither the name nor the architecture it shipped with is the one it started with.&lt;/p&gt;

&lt;p&gt;Claude Code has no native "wake me up when X happens" primitive that plays nicely with a human still typing in the same window. There's no interrupt. Anything that feels like push has to be built by bending some other piece of the toolset into the shape of one. On 2026-03-30, pairing with Claude Opus 4.6, I bent six different things into that shape and kept each one just long enough to find out how it broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The evening
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;28c8822&lt;/code&gt; at 14:51 is the initial commit: "Initial release: MCP server for cross-machine Claude Code communication," named &lt;code&gt;agent-bridge&lt;/code&gt;, published as &lt;code&gt;mcp-agent-bridge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The first push attempt was the crudest. By 16:55 (&lt;code&gt;28daad0&lt;/code&gt;) the Stop hook was returning &lt;code&gt;decision:block&lt;/code&gt;, which forces Claude to process the inbox before it's allowed to end its turn. A hook built for something else, hijacked.&lt;/p&gt;

&lt;p&gt;At 17:17 (&lt;code&gt;1d371fa&lt;/code&gt;) I switched to MCP sampling, &lt;code&gt;createMessage&lt;/code&gt;, the protocol's own mechanism for a server to request a completion from the client. Between 17:26 and 17:44 I added per-workspace Redis channels plus a broadcast channel, then fixed a same-session bug where publish and subscribe were sharing one connection (&lt;code&gt;e4a743b&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;e8919c5&lt;/code&gt; at 18:19: a one-shot Redis listener. Spawn it as a background Bash task; when a message arrives the listener exits; the exit fires a VS Code task notification; Claude reads it, replies, starts a new listener. Nine minutes on, &lt;code&gt;ee51ded&lt;/code&gt; tagged that &lt;code&gt;v1.2.0&lt;/code&gt;, "Real-time channel + background listener for true push in VS Code."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;a1c4757&lt;/code&gt; at 19:19 is labelled "v1.3.0: Clean architecture," which undersells it. The body opens "Breaking: removed SSE server (server.js rewritten as CLI router)," and that single commit deleted the SSE server, &lt;code&gt;tools.js&lt;/code&gt;, &lt;code&gt;redis.js&lt;/code&gt;, &lt;code&gt;check-inbox.js&lt;/code&gt;, the Dockerfile, &lt;code&gt;.dockerignore&lt;/code&gt;, the K8s bridge and ingress manifests, and the express dependency. An entire transport layer, dropped in one go. Thirty-nine seconds later &lt;code&gt;532d66f&lt;/code&gt; pulled the ghcr.io build out of CI, since there was no longer a Dockerfile to build.&lt;/p&gt;

&lt;p&gt;21:56, &lt;code&gt;0ef29bf&lt;/code&gt;: a persistent listener plus a queue file, to make message delivery uninterrupted. Let the listener run forever and write incoming messages to a local file (&lt;code&gt;.agent-bridge-inbox&lt;/code&gt;) that a hook reads each turn. No restart, no gap between listeners. It survived nineteen minutes. &lt;code&gt;ac1ae4f&lt;/code&gt; at 22:16: "Revert to one-shot listener pattern (proven real-time)."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;0cadbb7&lt;/code&gt; at 22:23 went the other way entirely, with no client-side process at all. Swap the plain &lt;code&gt;Server&lt;/code&gt; for &lt;code&gt;McpServer&lt;/code&gt; with experimental task support and register the listener as a real MCP task:&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;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;experimental&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;registerToolTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bridge_listen&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Wait for the next message from another workspace. ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;taskSupport&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;required&lt;/span&gt;&lt;span class="dl"&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="na"&gt;createTask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createTask&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// 10 min&lt;/span&gt;
        &lt;span class="na"&gt;pollInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// poll every 1 second&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;waitForMessage&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// ...&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;storeTaskResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;taskId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;result&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="nx"&gt;task&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;p&gt;Two patches followed before 22:36. &lt;code&gt;41eb1dd&lt;/code&gt; dropped &lt;code&gt;taskSupport&lt;/code&gt; from &lt;code&gt;required&lt;/code&gt; to &lt;code&gt;optional&lt;/code&gt;, because the VS Code extension doesn't advertise required task support. &lt;code&gt;162dd35&lt;/code&gt; handed the server a task store of its own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gi"&gt;+import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental";
&lt;/span&gt;...
&lt;span class="gi"&gt;+const taskStore = new InMemoryTaskStore();
&lt;/span&gt; const mcp = new McpServer(
   { name: "agent-bridge", version: "1.0.0" },
   {
&lt;span class="gi"&gt;+    taskStore,
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit message: "VS Code extension doesn't provide a taskStore, but we can provide our own."&lt;/p&gt;

&lt;p&gt;By 23:19 (&lt;code&gt;7b73909&lt;/code&gt;) I'd walked away from that too. The commit calls its replacement "The proven pattern," and its diff to &lt;code&gt;src/channel.js&lt;/code&gt; (the instructions string the MCP server injects into Claude's context) goes back to the background-listener process from five hours earlier and puts a blocking read on top of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;Step 1: Start the background listener:
  Bash&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;run_in_background&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;, &lt;span class="nb"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600000&lt;span class="o"&gt;)&lt;/span&gt;: &lt;span class="nv"&gt;AGENT_BRIDGE_REDIS_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... npx &lt;span class="nt"&gt;-y&lt;/span&gt; mcp-agent-bridge listen

Step 2: Block-wait &lt;span class="k"&gt;for &lt;/span&gt;the message:
  TaskOutput&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;task_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;&lt;span class="nb"&gt;id &lt;/span&gt;from step 1&amp;gt;, &lt;span class="nv"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;, &lt;span class="nb"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600000&lt;span class="o"&gt;)&lt;/span&gt;

When a message arrives, TaskOutput returns the message. ...
This creates a &lt;span class="nb"&gt;true &lt;/span&gt;real-time event loop with zero user intervention.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same diff demotes &lt;code&gt;bridge_listen&lt;/code&gt;, the task-protocol tool from fifty-six minutes earlier, to "(experimental, may not work in all environments)" in the tools list.&lt;/p&gt;

&lt;p&gt;Thirty-one minutes after that, &lt;code&gt;aa40a2d&lt;/code&gt; at 23:51: "Use task-notification pattern (non-blocking) instead of &lt;code&gt;TaskOutput(block)&lt;/code&gt;." Reason given: "TaskOutput blocks all user interaction." Same instructions string, rewritten again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The listener runs in the background. When a message arrives, you get a task-notification.
When you see the notification:
1. Read the task output file to see the message
2. Call bridge_receive() to mark messages as read
3. Reply with bridge_send(to: "sender", type: "answer", content: "...")
4. Start a new listener (same command as above)

This keeps you free to interact with the user while listening for messages.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Zero user intervention" at 23:19; "keeps you free to interact with the user" at 23:51. From the first push attempt at 16:55 to that revert is six hours and fifty-six minutes, and what came out the other end — background listener, exits on message, fires a notification, gets restarted — is functionally what already existed at 18:28.&lt;/p&gt;

&lt;h2&gt;
  
  
  Except that isn't where it stopped
&lt;/h2&gt;

&lt;p&gt;At 00:13:12, twenty-two minutes later, past midnight but the same sitting, &lt;code&gt;64dbd7d&lt;/code&gt;: "Add CronCreate backup polling to MCP instructions."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Instructions now tell Claude to set up both:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Background listener (real-time push)&lt;/li&gt;
&lt;li&gt;CronCreate every 5 min (backup polling)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Belt and suspenders — real-time when listener is active, 5-min fallback when it times out or misses.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The listener runs on a ten-minute Bash timeout, and the only thing that restarts it is Claude working through to step 4 of an instructions blob. The commit's stated reasons for the cron are that the listener times out or misses. So what shipped isn't push. It's push with a five-minute floor underneath, and the floor is the half that doesn't depend on a model remembering a numbered list.&lt;/p&gt;

&lt;p&gt;That's still how it works. The live &lt;code&gt;src/channel.js&lt;/code&gt; opens with "CRITICAL: Do BOTH of these at the start of every conversation," then the listener, then &lt;code&gt;CronCreate(cron="*/5 * * * *", ...)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  On MCP's task primitives
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;registerToolTask&lt;/code&gt; looks purpose-built for this problem: a server-side wait that completes when something happens, nothing to babysit on the client. I'd still not reach for it first, and my reason is those two patches. Getting the primitive to exist at all in the environment I was targeting meant finding out that VS Code doesn't advertise required task support, then that it ships no task store, then shipping my own. Twelve minutes of patching before the thing could run, all of it spent on the gap between what the spec describes and what the client implements. That gap is mine to fill every time, and it moves.&lt;/p&gt;

&lt;p&gt;That is not an argument about what killed the next attempt. &lt;code&gt;TaskOutput(block=true)&lt;/code&gt; freezing the user's session is Claude Code Bash behaviour, and by then I'd already dropped the task protocol; the same diff that introduced the blocking read is the one that demoted &lt;code&gt;bridge_listen&lt;/code&gt; to experimental. Two unrelated failures that happened to land within an hour of each other. The &lt;code&gt;registerToolTask&lt;/code&gt; code is still in &lt;code&gt;src/channel.js&lt;/code&gt; today, renamed &lt;code&gt;swarm_listen&lt;/code&gt;, still registered, just no longer mentioned in the instructions anyone reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rename, and a name one hyphen away
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;e5cd49d&lt;/code&gt;, 2026-03-31 07:52:26: "Rename to SwarmCode — the missing networking layer for Claude Code." &lt;code&gt;agent-bridge&lt;/code&gt; became &lt;code&gt;swarmcode&lt;/code&gt;, tools went &lt;code&gt;bridge_*&lt;/code&gt; to &lt;code&gt;swarm_*&lt;/code&gt;, env vars &lt;code&gt;AGENT_BRIDGE_*&lt;/code&gt; to &lt;code&gt;SWARMCODE_*&lt;/code&gt;. At 08:03:19, &lt;code&gt;b131fcc&lt;/code&gt;: "npm package name: swarmcode-mcp (swarmcode was too similar to swarm-code)."&lt;/p&gt;

&lt;p&gt;npm blocked the bare name for similarity, not because it was taken; &lt;code&gt;swarmcode&lt;/code&gt; sits unregistered to this day. The package it was too similar to is real and actively published: &lt;code&gt;swarm-code&lt;/code&gt;, "Open-source swarm-native coding agent orchestrator — spawns parallel coding agents in isolated git worktrees," keyworded swarm, coding-agent, orchestrator, llm, cli, agent. I had picked a name one hyphen off a tool in the same corner of the same problem space, and npm's similarity check noticed before I did. The project is &lt;code&gt;swarmcode&lt;/code&gt; everywhere in the source and &lt;code&gt;swarmcode-mcp&lt;/code&gt; on npm.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug, thirty-five hours in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;1745bae&lt;/code&gt;, 2026-04-01 02:04:31: "Fix hook performance: use global binary, add Redis timeout." The &lt;code&gt;UserPromptSubmit&lt;/code&gt; hook that checks the inbox was shelling out to &lt;code&gt;npx -y swarmcode-mcp check&lt;/code&gt; on every single prompt. &lt;code&gt;npx&lt;/code&gt; resolves the package, and sometimes downloads it, before running anything: 5-10 seconds, against roughly 0.5 for a globally installed binary. Every prompt paid it. Chats were hanging.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;   try {
     const { default: Redis } = await import("ioredis");
&lt;span class="gd"&gt;-    const redis = new Redis(REDIS_URL, { keyPrefix: "swarmcode:" });
&lt;/span&gt;&lt;span class="gi"&gt;+    const redis = new Redis(REDIS_URL, {
+      keyPrefix: "swarmcode:",
+      connectTimeout: 2000,
+      commandTimeout: 2000,
+      maxRetriesPerRequest: 1,
+      retryStrategy: () =&amp;gt; null, // don't retry — fail fast
+    });
&lt;/span&gt;     const raw = await redis.lrange(`inbox:${WORKSPACE_ID}`, 0, -1);
     messages = raw.map((m) =&amp;gt; JSON.parse(m)).reverse();
     await redis.quit();
&lt;span class="gd"&gt;-  } catch {}
&lt;/span&gt;&lt;span class="gi"&gt;+  } catch {
+    // Redis unavailable — silently skip
+    process.exit(0);
+  }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix pointed the hook at the installed &lt;code&gt;swarmcode check&lt;/code&gt; binary, added 2-second Redis connect and command timeouts with retries off, cut the hook's own timeout to 3 seconds, and made an unreachable Redis fail silently rather than visibly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The name that won't die
&lt;/h2&gt;

&lt;p&gt;Four and a half months on, &lt;code&gt;7d05b94&lt;/code&gt; (2026-08-18): "Add mcp-agent-bridge migration guide and lead with the problem." From the commit message: "The legacy npm name still gets ~6x the downloads of swarmcode-mcp and its npm page links here, so this README is where those users land." It added a migration banner, a &lt;code&gt;bridge_*&lt;/code&gt; to &lt;code&gt;swarm_*&lt;/code&gt; mapping table, and kept the old env vars working as a fallback so a half-migrated set of machines could still talk to each other mid-switch.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mcp-agent-bridge&lt;/code&gt; has gone 143, 118, 76 downloads per month across 2026-08-31, 09-07 and 09-14. &lt;code&gt;swarmcode-mcp&lt;/code&gt; went 19, 31, 31. The 6x from August is about 2.4x now. The dead name is fading and still pulls more than twice the traffic of the live one.&lt;/p&gt;

&lt;p&gt;Line 112 of that same README, today: "No polling. No cron. True event-driven push in VS Code." Line 122 is a section headed "Backup polling," and it says a 5-minute CronCreate runs alongside the listener as a safety net. Ten lines apart, in the file I rewrote to lead with the problem. I've known about the cron since 00:13 on March 31 and I still haven't fixed the sentence above it.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>opensource</category>
      <category>node</category>
      <category>ai</category>
    </item>
    <item>
      <title>My README Was Mostly Right. The Number I "Verified" Wasn't.</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 10 Sep 2026 15:07:51 +0000</pubDate>
      <link>https://dev.to/spranab/my-readme-was-mostly-right-the-number-i-verified-wasnt-516j</link>
      <guid>https://dev.to/spranab/my-readme-was-mostly-right-the-number-i-verified-wasnt-516j</guid>
      <description>&lt;p&gt;saga-mcp is my MCP server for project tracking. SQLite-backed projects, epics, tasks and subtasks, so a coding agent doesn't lose the plan the moment a session ends. It's on npm as &lt;code&gt;saga-mcp&lt;/code&gt;. On 2026-09-04 at 14:29 I was doing something unglamorous with it: filing a submission to the Cline MCP marketplace (github.com/cline/mcp-marketplace/issues/2435). The form wants a tool count, and I had the README open to copy one out of.&lt;/p&gt;

&lt;p&gt;At that exact commit (&lt;code&gt;698ef81&lt;/code&gt;, which was HEAD when I filed) the README said three things. Line 15, the intro paragraph: "exposed as 35 MCP tools." Line 79, a feature bullet: "35 focused tools." Then line 138, buried in the environment-variable reference table, &lt;code&gt;SAGA_TOOLS&lt;/code&gt; set to &lt;code&gt;full&lt;/code&gt; (the default) "lists all 33 tools," and &lt;code&gt;core&lt;/code&gt; "lists only the 12 an ordinary tracking session needs."&lt;/p&gt;

&lt;p&gt;Two of those agreed with each other and the third didn't, which is exactly the smell that makes you stop reading documentation and go measure the thing. So I did. I pointed a client at the published package with &lt;code&gt;SAGA_TOOLS&lt;/code&gt; set to &lt;code&gt;full&lt;/code&gt; and then to &lt;code&gt;core&lt;/code&gt;, and both runs came back with 22 tools.&lt;/p&gt;

&lt;p&gt;22 matched nothing in the README. I read that as confirmation the file had rotted, put 22 in the marketplace submission as the verified count, noted that rewriting the README was outside the scope of a registry-submission task, and moved on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually running that day
&lt;/h2&gt;

&lt;p&gt;Writing this up today, I went back and nailed it down properly instead of from memory. The method is one line and anyone can rerun it: pipe a raw MCP stdio handshake, a JSON-RPC &lt;code&gt;initialize&lt;/code&gt; followed by &lt;code&gt;tools/list&lt;/code&gt;, into &lt;code&gt;npx -y saga-mcp@&amp;lt;version&amp;gt;&lt;/code&gt; with &lt;code&gt;DB_PATH&lt;/code&gt; pointed at a scratch file, and count what comes back in the response.&lt;/p&gt;

&lt;p&gt;The version genuinely live on 2026-09-04 was &lt;code&gt;saga-mcp@1.8.0&lt;/code&gt;, published the day before. It returns exactly 35 tools.&lt;/p&gt;

&lt;p&gt;So the README's two prominent claims, the ones a reader actually sees, were correct. The stale number was the buried one in the env-var table, 33 and 12, left behind by a couple of releases. My "verified" 22 was further from the truth than the documentation I'd distrusted.&lt;/p&gt;

&lt;p&gt;I did eventually find where 22 lives. &lt;code&gt;saga-mcp@1.1.0&lt;/code&gt; and &lt;code&gt;saga-mcp@1.2.0&lt;/code&gt;, both published 2026-02-21, each return exactly 22 tools, and both ignore &lt;code&gt;SAGA_TOOLS&lt;/code&gt; completely: &lt;code&gt;full&lt;/code&gt; and &lt;code&gt;core&lt;/code&gt; give you the same 22. That's the fingerprint I saw and misread. Getting the same count twice regardless of the env var looked to me like "the env var is broken too," and it actually meant "you are talking to a package from six months ago."&lt;/p&gt;

&lt;p&gt;How a same-day test landed on a February build, I don't know. A stale npx cache is the obvious guess. A globally installed copy shadowing the npx resolve is the other one. I haven't managed to reproduce either, and I'd rather leave it sitting there unexplained than pick whichever story sounds tidiest.&lt;/p&gt;

&lt;p&gt;What bothers me isn't the 22 itself. It's that the wrong number sat in a public marketplace submission for four days, and the reason nobody caught it, including me, is that I had measured it myself. Distrusting the docs and going to the source is the right instinct and I'd do it again. It only helps if you also check what you measured. I never got as far as asking which package had answered me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that did land
&lt;/h2&gt;

&lt;p&gt;The README work I'd deferred shipped four days later: commit &lt;code&gt;3dd5df5&lt;/code&gt;, 2026-09-08 19:18:58 -0500, "Rewrite the README, and test the claims it makes (#51)," co-authored by Claude Opus 5 per the commit trailer. 849 lines touched in the README itself (changed, not final size) plus a new 189-line &lt;code&gt;test/readme.test.js&lt;/code&gt;. From the commit message:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The file had grown by accretion across a dozen releases and was wrong in several places a reader would act on... It claimed ~140 tests when there are 298, and quoted the tool surface as ~6,000 tokens dropping to ~2,700 when the measured figures are ~7,200 and ~2,900... Verified by reintroducing seven kinds of drift that had genuinely been in the file — a stale tool count, a stale test count, a renamed tool, a stale version in the release example, a broken anchor, a stale tab count — and confirming eight tests fail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It says seven kinds of drift and then lists six. A commit about fixing counting errors, undercounting its own list of counting errors. I laughed, then left it in.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;test/readme.test.js&lt;/code&gt; checks, programmatically: every real tool is documented and vice versa; the advertised tool counts match what the running server reports; the &lt;code&gt;SAGA_TOOLS=core&lt;/code&gt; list matches what the code actually restricts to; the tab list matches the web UI's own tabs; quoted test and e2e counts are current; the release example names the current version; no example calls a tool that doesn't exist; every anchor link resolves. The file's own doc-comment puts the motivation plainly: "The README drifts. It claimed ~140 tests when there were 298, quoted a tool surface two releases out of date, and documented sorting behaviour that had since changed — all of it written in good faith and then left behind by the code."&lt;/p&gt;

&lt;h2&gt;
  
  
  Two days later
&lt;/h2&gt;

&lt;p&gt;Two days is not a long time to trust a new test, so I reran the handshake against &lt;code&gt;npx -y saga-mcp@latest&lt;/code&gt;. It resolved to 1.16.0 and printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tracker MCP Server running on stdio (41 of 41 tools listed)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The current README claims 41. The &lt;code&gt;CORE_TOOLS&lt;/code&gt; set in source has also grown from 12 entries to 13, with a new tool called &lt;code&gt;tracker_next&lt;/code&gt; (essentially a "what should I work on" query), and the rewritten &lt;code&gt;SAGA_TOOLS&lt;/code&gt; table says 13. That's precisely the kind of edit that used to silently desync the count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The download number I don't trust
&lt;/h2&gt;

&lt;p&gt;Separately, the metrics I run across my packages flagged saga-mcp: npm downloads went from 321/month on 2026-08-31 to 1,808/month on 2026-09-07, a 463% jump, with no new registry listings going live that week and GitHub stars flat at 33 across the whole window. It got logged as unverified, watch if it holds.&lt;/p&gt;

&lt;p&gt;The daily numbers for 2026-09-01 through 09-10 are 5, 190, 0, 485, 73, 775, 0, 0, 83, 0. My own publish counts for the same days are 0, 1, 2, 5, 0, 5, 0, 2, 0, 0.&lt;/p&gt;

&lt;p&gt;The two biggest download days, 485 on 09-04 and 775 on 09-06, are the two days I shipped five releases each. That fits neatly enough that I wanted it to be the whole answer. It isn't: 09-03 and 09-08 each carried two releases and recorded zero downloads. If publishing reliably drove the counter, those days would show something.&lt;/p&gt;

&lt;p&gt;My read, and this is where a reasonable person could land somewhere else: a curve that spikes on my busiest publish days and flatlines in between is more likely measuring registry mirrors and dependency scanners refetching new versions than it is measuring people installing the thing. The counter-examples mean I can't prove that, and I'm not claiming the correlation is clean. I am claiming that "up 463%" does not belong in a scorecard on its own, which is inconvenient, because it's a very nice number and nobody would stop me from using it.&lt;/p&gt;

&lt;p&gt;The README test now fails the build when the docs and the running code disagree, which is a real improvement over remembering to update things. It would not have caught the 22. Nothing in that suite, or in my head on 2026-09-04, checks whether the process answering me is the process I think I'm talking to.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>opensource</category>
      <category>testing</category>
      <category>node</category>
    </item>
    <item>
      <title>Twelve Green CI Legs, One Dead SSE Server</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:05:38 +0000</pubDate>
      <link>https://dev.to/spranab/twelve-green-ci-legs-one-dead-sse-server-1iij</link>
      <guid>https://dev.to/spranab/twelve-green-ci-legs-one-dead-sse-server-1iij</guid>
      <description>&lt;p&gt;The official &lt;code&gt;mcp&lt;/code&gt; Python SDK went to 2.0.0 and deleted &lt;code&gt;mcp.server.fastmcp&lt;/code&gt; outright. No shim, no deprecation window that downstream packages got to see. yantrikdb-mcp is a thin MCP server over YantrikDB, my persistent cognitive memory substrate for AI agents, and at 04:08 on 2026-08-04 I did the defensive thing: v0.11.0 (&lt;code&gt;d823b82&lt;/code&gt;) pinned &lt;code&gt;mcp[cli]&lt;/code&gt; to &lt;code&gt;&amp;lt;2.0.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A ceiling like that doesn't stop anyone from installing the package. pip resolves mcp 1.x and moves on. It bites later, in an environment where something else wants &lt;code&gt;mcp&amp;gt;=2.0.0&lt;/code&gt;, because then the resolver has to pick a loser and one of the two packages isn't getting installed. That's an acceptable trade for a few hours. It's a bad thing to leave sitting in a published &lt;code&gt;pyproject.toml&lt;/code&gt;, so I spent the morning taking it back out.&lt;/p&gt;

&lt;p&gt;Scale matters here, because it changes how much a five-hour window costs. yantrikdb-mcp is the fastest-moving package in my download numbers at the moment: 1,688 a month when I logged it on 2026-08-24, 2,137 on 2026-08-31. Up 26.6% in a week, mostly a cluster of registry listings landing close together rather than any single event.&lt;/p&gt;

&lt;h2&gt;
  
  
  Probing instead of parsing
&lt;/h2&gt;

&lt;p&gt;v0.12.0 (&lt;code&gt;0ca6092&lt;/code&gt;, PR #19) shipped at 10:39, six and a half hours after the pin. One file, &lt;code&gt;src/yantrikdb_mcp/_compat.py&lt;/code&gt;, became the single import site for every MCP SDK symbol the rest of the package touches, and it resolved them by probing rather than by reading a version string and branching on it:&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;# _compat.py, trimmed to the shape of the probe
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.mcpserver&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MCPServer&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.mcpserver.exceptions&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ToolError&lt;/span&gt;
    &lt;span class="n"&gt;MCP_MAJOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;Server&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;MCP_MAJOR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What actually moved, found by running the suite against 2.0.0 rather than by reading a changelog: &lt;code&gt;FastMCP&lt;/code&gt; became &lt;code&gt;MCPServer&lt;/code&gt;, &lt;code&gt;Context&lt;/code&gt; moved under &lt;code&gt;mcp.server.mcpserver&lt;/code&gt;, and &lt;code&gt;ToolError&lt;/code&gt; went one level deeper than that, to &lt;code&gt;mcp.server.mcpserver.exceptions&lt;/code&gt;. &lt;code&gt;mcp.types.ToolAnnotations&lt;/code&gt; stayed exactly where it was, which I would not have guessed given everything around it had shifted. The lifespan dict, &lt;code&gt;request_context.lifespan_context&lt;/code&gt;, annotations passthrough, &lt;code&gt;.run(transport=...)&lt;/code&gt;: all identical across the major bump. Most of a breaking release wasn't breaking for this codebase, and the pin had been treating the entire API surface as suspect when only a slice of it had moved.&lt;/p&gt;

&lt;p&gt;One divergence surfaced only by running things. mcp 2.x refuses to register a static resource whose handler declares a &lt;code&gt;Context&lt;/code&gt; parameter, and it refuses at import time, which takes the server down before it answers a single request. yantrikdb-mcp had two of those, &lt;code&gt;yantrikdb://stats&lt;/code&gt; and &lt;code&gt;yantrikdb://health&lt;/code&gt;. Both now reach the engine through a process-singleton and don't ask for a &lt;code&gt;Context&lt;/code&gt; at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What twelve legs bought
&lt;/h2&gt;

&lt;p&gt;CI got a new axis, &lt;code&gt;mcp-line&lt;/code&gt;, with values &lt;code&gt;mcp1&lt;/code&gt; and &lt;code&gt;mcp2&lt;/code&gt;, applied across the unit and e2e suites. Twelve legs. Each leg installs its SDK pin &lt;em&gt;after&lt;/em&gt; the package installs and then asserts the resolved major, specifically so a compat layer that quietly resolved the wrong way couldn't pass by accident and turn one leg into a duplicate of the other. 214 tests passed on mcp 1.x. 214 on mcp 2.x. The &lt;code&gt;pyproject.toml&lt;/code&gt; ceiling went from &lt;code&gt;&amp;lt;2.0.0&lt;/code&gt; to &lt;code&gt;&amp;lt;3.0.0&lt;/code&gt;, and &lt;code&gt;--version&lt;/code&gt; started reporting the active line: &lt;code&gt;yantrikdb-mcp 0.12.0 (mcp 2.x)&lt;/code&gt;. I tagged it.&lt;/p&gt;

&lt;h2&gt;
  
  
  15:42
&lt;/h2&gt;

&lt;p&gt;v0.12.1 went out five hours later: "fix SSE / streamable-http startup on mcp 2.x (v0.12.0 regression)."&lt;/p&gt;

&lt;p&gt;The e2e cases, on both legs, all drove stdio. Most of the 214 are unit tests that don't exercise a transport at all. So nothing in the suite touched SSE or streamable-http, which are the two transports yantrikdb-mcp uses for shared network deployments, and v0.12.0 had broken both of them on mcp 2.x.&lt;/p&gt;

&lt;p&gt;mcp 1.x configures a network server by mutating shared state (&lt;code&gt;mcp.settings.host&lt;/code&gt;, &lt;code&gt;mcp.settings.transport_security.enable_dns_rebinding_protection&lt;/code&gt;) and then reading those values back inside &lt;code&gt;server.sse_app()&lt;/code&gt;. mcp 2.x's &lt;code&gt;Settings&lt;/code&gt; object has no such fields. The old code kept mutating, and on 2.x it raised:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ValueError: "Settings" object has no field "host"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2.x those values are arguments to the app factory instead. &lt;code&gt;_compat.build_network_app(server, transport, host, port)&lt;/code&gt; is now the only place in the package that knows the difference, and &lt;code&gt;_run_network()&lt;/code&gt; delegates to it rather than poking &lt;code&gt;settings.host&lt;/code&gt; itself:&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;# build_network_app(), condensed to the branch that matters
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;MCP_MAJOR&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 1.x reads its configuration back off the server object
&lt;/span&gt;    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transport_security&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enable_dns_rebinding_protection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sse_app&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2.x: the fields don't exist, the factory takes them
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sse_app&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;transport_security&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;security&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The streamable-http factory goes through the same branch with a different call. Worth being precise about the failure mode: the server didn't come up misconfigured, it didn't come up. The process died before uvicorn bound a port. A stdio suite is structurally incapable of noticing, because stdio never reaches &lt;code&gt;sse_app()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I found it because I went to upgrade the live SSE box I run, which sits behind bearer-token auth and depends entirely on that path, and I checked it before rolling it forward. Twelve green legs did not catch this bug. A pre-deploy check did, which is the least impressive way there is to find one.&lt;/p&gt;

&lt;p&gt;Past the unit tests, I started a real SSE server on mcp 2.x with &lt;code&gt;YANTRIKDB_API_KEY&lt;/code&gt; set: uvicorn bound, &lt;code&gt;GET /sse&lt;/code&gt; returned 401 with no token and 200 with one. A new file, &lt;code&gt;tests/test_network_transport_compat.py&lt;/code&gt;, builds the actual ASGI app for both &lt;code&gt;sse&lt;/code&gt; and &lt;code&gt;streamable-http&lt;/code&gt; against whichever SDK line is installed, so both legs finally walk the network path. It also carries a source-level guard against &lt;code&gt;_run_network&lt;/code&gt; ever reverting to direct &lt;code&gt;settings.host&lt;/code&gt; mutation, plus a tripwire assertion that the &lt;code&gt;host&lt;/code&gt; field really is absent from &lt;code&gt;Settings&lt;/code&gt; on 2.x, so a future 2.x release that quietly restores it fails the suite instead of letting the shim do the wrong thing on both majors at once. Counts after the fix: 218 on mcp 1.x, 219 on mcp 2.x. The asymmetric one is that tripwire, which has nothing to assert on the 1.x leg.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'd argue about
&lt;/h2&gt;

&lt;p&gt;Twelve felt like coverage because twelve is a bigger number than three. What I had was two dependency pins multiplied against one transport. Multiplying an axis against the same assertions doesn't buy a code path, it buys a longer CI run and a wider grid of checkmarks, and I think a great many matrices in a great many repos are built exactly that way: wide where widening is cheap, thin where the interesting failures live. Mine was, until August. Now, before I add an axis, I want to be able to name the line of code it forces the suite through. If I can't name one, the axis is decoration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same shape, one size down
&lt;/h2&gt;

&lt;p&gt;A different gate on this repo caps how large the &lt;code&gt;tools/list&lt;/code&gt; JSON schema is allowed to get, kept deliberately tight so it doesn't eat into a model's context budget. PR #37 merged with four red CI checks. The gate had passed locally on Python 3.13 and failed in CI on 3.10 and 3.12, because 3.10 renders a hint like &lt;code&gt;str | None&lt;/code&gt; into a noticeably fatter &lt;code&gt;anyOf&lt;/code&gt; block than 3.13 or 3.14 do: roughly 1,950 extra characters from identical source, against a budget with 197 characters of headroom. PR #38 clawed back 204 characters by trimming a docstring, raised the ceiling from 48,600 to 48,800, and wrote the reasoning into the test file instead of bumping the number silently. There was already a comment in that file warning about this exact interpreter quirk, left there the first time it happened.&lt;/p&gt;

&lt;p&gt;Same shape as the SSE regression, one size down: green in the configuration I looked at, red in the one I didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three weeks later, the probe needed patching too
&lt;/h2&gt;

&lt;p&gt;On 2026-08-25 I had to fix the probe itself (&lt;code&gt;0e170a9&lt;/code&gt;, PR #35). mcp 2.1.1 reintroduced &lt;code&gt;mcp.server.fastmcp&lt;/code&gt; alongside the canonical &lt;code&gt;mcp.server.mcpserver&lt;/code&gt; tree. Read the code block above again: its logic is "if &lt;code&gt;fastmcp&lt;/code&gt; imports, we're on 1.x," and on 2.1.x both trees import cleanly, so it confidently answered 1. The fix reads &lt;code&gt;importlib.metadata.version('mcp')&lt;/code&gt; directly instead of inferring the line from which modules happen to resolve, adds a second check tying the canonical 2.x tree to &lt;code&gt;MCP_MAJOR&lt;/code&gt;, and pins the both-trees-importable shape to 2.x in a regression test. Validated against mcp 1.29.1, 2.0.0 and 2.1.1.&lt;/p&gt;

&lt;p&gt;So probing beat parsing right up to the point where upstream did something stranger than I'd allowed for, and the version string I'd deliberately avoided ended up being part of the answer after all.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>python</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Made an MCP Server Stop Calling Any AI API, and It Got More Useful</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Thu, 27 Aug 2026 14:58:41 +0000</pubDate>
      <link>https://dev.to/spranab/i-made-an-mcp-server-stop-calling-any-ai-api-and-it-got-more-useful-26jg</link>
      <guid>https://dev.to/spranab/i-made-an-mcp-server-stop-calling-any-ai-api-and-it-got-more-useful-26jg</guid>
      <description>&lt;p&gt;v1.0 of brainstorm-mcp shipped with two providers wired up: OpenAI and DeepSeek. Gemini arrived later in v1.2.0 (&lt;code&gt;3690f24&lt;/code&gt;, 2026-02-26), and groq, mistral and together are in the list too. You never needed all of them. The provider loader walks the list and skips anything whose env var is missing, so one working key is enough to run a debate.&lt;/p&gt;

&lt;p&gt;The catch is which key. Every provider in that list belongs to somebody other than Anthropic. There is no Claude provider in the server and there never was; in API mode Claude's role is to read what the other models said and add its own take, which happens through the host rather than through a key the server holds. So if you're running Claude Code and you install &lt;code&gt;brainstorm-mcp&lt;/code&gt; to see what a multi-model debate looks like, the number of usable keys you have is quite possibly zero. The friction was never "go collect N keys." It was "go sign up with a vendor you weren't otherwise using, before you can find out whether this tool is any good."&lt;/p&gt;

&lt;h2&gt;
  
  
  The inversion
&lt;/h2&gt;

&lt;p&gt;v1.4.0, commit &lt;code&gt;7906fbf&lt;/code&gt;, 2026-03-24: "Add hosted mode for API-key-free multi-model brainstorming" (PR #1, co-authored with Claude Opus 4.6 per the commit trailer; Opus and I worked out the orchestration model together).&lt;/p&gt;

&lt;p&gt;Hosted mode isn't a seventh provider or a free-tier proxy. In API mode the server is a caller. It holds keys, hits &lt;code&gt;api.openai.com&lt;/code&gt; or &lt;code&gt;api.deepseek.com&lt;/code&gt;, returns results. In hosted mode it never calls a model API. It builds the debate prompt for a given round and a given voice, then hands that prompt back to the host as tool output. Claude Code spawns sub-agents on opus, sonnet and haiku to run those prompts, using model access the host already has. Responses come back in through &lt;code&gt;brainstorm_collect&lt;/code&gt;, which appends them to the debate and decides what round comes next.&lt;/p&gt;

&lt;p&gt;Worth being precise about the mechanism, because it's easy to overstate. The server cannot make the host do any of that. What it returns is a prompt plus instructions, and Claude Code acts on them because acting on tool guidance is what a host does. There's no protocol-level guarantee anywhere in this. A host that ignored the instructions and dumped the prompt text at the user would leave the server sitting on a half-finished session until it timed out. The whole arrangement is cooperative, and I'd rather say so than let it sound like the server is driving.&lt;/p&gt;

&lt;p&gt;Mode selection is automatic. If the model names you pass don't carry a &lt;code&gt;provider:&lt;/code&gt; prefix (no &lt;code&gt;openai:gpt-5.5&lt;/code&gt;, no &lt;code&gt;deepseek:deepseek-chat&lt;/code&gt;), the server reads that as hosted mode and assumes you mean the host's own models. No flag, no config file, just the shape of the string.&lt;/p&gt;

&lt;p&gt;The same commit brought context injection, so a debate can be grounded in a real diff or file instead of a topic sentence, plus the debate styles (freeform, redteam, socratic) and a synthesis format that closes every debate with Recommendation, Key Tradeoffs, Strongest Disagreement in both modes. &lt;code&gt;brainstorm_respond&lt;/code&gt; was already there from v1.1.0 (&lt;code&gt;75c8949&lt;/code&gt;, "Add interactive mode — Claude participates as active debater"); &lt;code&gt;7906fbf&lt;/code&gt; touched it by exactly one line, passing &lt;code&gt;session.style&lt;/code&gt; through.&lt;/p&gt;

&lt;p&gt;The design consequence I care about is what happens once the server stops calling models. It has to hold state: which round the debate is on, which voices have spoken, what's outstanding. That lives in memory with a 10-minute TTL, and calling &lt;code&gt;brainstorm_collect&lt;/code&gt; against an expired session returns an error telling you to start a new one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways it broke, same afternoon
&lt;/h2&gt;

&lt;p&gt;Both of these live in the API-mode calling path, which hosted mode's design doesn't touch. They broke a debate I was running to test hosted mode, so they're part of the same afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.5.6, &lt;code&gt;c9e3d86&lt;/code&gt;, 2026-04-27, "drop temperature for gpt-5.x reasoning models."&lt;/strong&gt; Three-round brainstorm, &lt;code&gt;openai:gpt-5.5&lt;/code&gt; and &lt;code&gt;deepseek:deepseek-chat&lt;/code&gt; as the two voices. Every round-1 turn from gpt-5.5 came back as an HTTP 400:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unsupported value: 'temperature' does not support 0.7 with this model. Only the default (1) value is supported.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The gpt-5.x family and the o-series reject any non-default temperature. deepseek-chat doesn't care what you send it, so the loop ran fine right up to the OpenAI voice, which is why testing hadn't caught it. The code already branched on model family for &lt;code&gt;max_tokens&lt;/code&gt; vs &lt;code&gt;max_completion_tokens&lt;/code&gt;, tracked by a &lt;code&gt;useNewTokenParam&lt;/code&gt; flag, so the fix reused that branch and stopped sending &lt;code&gt;temperature: 0.7&lt;/code&gt; down it. Older gpt-4.x models still get it, since they still accept it. Rechecked against &lt;code&gt;gpt-5.5-2026-04-23&lt;/code&gt;: 200 OK, no &lt;code&gt;temperature&lt;/code&gt; field in the request body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v1.5.7, &lt;code&gt;9af668b&lt;/code&gt;, same day, "bump per-call timeout 2m -&amp;gt; 5m."&lt;/strong&gt; I shipped the temperature fix, reran the same debate, and it stalled at 120 seconds. &lt;code&gt;DEFAULT_TIMEOUT_MS&lt;/code&gt; in &lt;code&gt;src/debate.ts&lt;/code&gt; was hardcoded to &lt;code&gt;120_000&lt;/code&gt;. gpt-5.5 doing reasoning on a real brainstorm prompt (multi-thousand-word topic, six-option intervention list, redteam framing instructions on top) goes past two minutes routinely. Session &lt;code&gt;01751c60-256a-489b-88d3-9b954a3edd79&lt;/code&gt;, round 1, same wall on retry. One constant, &lt;code&gt;300_000&lt;/code&gt;, done. deepseek-chat and gpt-4.x were finishing well under two minutes and saw no change at all.&lt;/p&gt;

&lt;p&gt;Under an hour between the two commits, which is about how long I spent believing the first one had unblocked the debate.&lt;/p&gt;

&lt;p&gt;One thing I'm watching now: &lt;code&gt;SESSION_TTL_MS&lt;/code&gt; is &lt;code&gt;600_000&lt;/code&gt; while &lt;code&gt;DEFAULT_TIMEOUT_MS&lt;/code&gt; is &lt;code&gt;300_000&lt;/code&gt; post-fix. Two slow reasoning calls back to back inside a single round could in principle burn most of a session's life before the round finishes. I haven't hit it and I'm not certain the arithmetic works out that way in practice, but it's the next thing I'd expect to go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;From &lt;code&gt;state/metrics-history.jsonl&lt;/code&gt;, npm downloads per month and GitHub stars as recorded:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;npm downloads/month&lt;/th&gt;
&lt;th&gt;GitHub stars&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-09&lt;/td&gt;
&lt;td&gt;286&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-18&lt;/td&gt;
&lt;td&gt;345&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-21&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-24&lt;/td&gt;
&lt;td&gt;427&lt;/td&gt;
&lt;td&gt;67&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Downloads up about 49% across those 15 days. Stars sat at 67 on every check.&lt;/p&gt;

&lt;p&gt;I read that as a property of how this kind of tool gets installed rather than as a problem. You add &lt;code&gt;brainstorm-mcp&lt;/code&gt; to an MCP client config and start calling &lt;code&gt;brainstorm_quick&lt;/code&gt;. Nothing in that path requires opening the repo. Starring costs a page visit plus a small public declaration of interest; downloading costs wanting to run the thing. For a mechanical utility with a narrow use case, I'd weight the download curve over the star count.&lt;/p&gt;

&lt;p&gt;Plenty of people will call that motivated reasoning from someone whose star count isn't moving, and four data points over two weeks isn't evidence of anything. It's four numbers. What it did change is what I bother logging.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>opensource</category>
      <category>llm</category>
    </item>
    <item>
      <title>The Model Reading My Benchmark Mattered More Than the Memory System Did</title>
      <dc:creator>Pranab Sarkar</dc:creator>
      <pubDate>Tue, 25 Aug 2026 20:40:17 +0000</pubDate>
      <link>https://dev.to/spranab/the-model-reading-my-benchmark-mattered-more-than-the-memory-system-did-44l4</link>
      <guid>https://dev.to/spranab/the-model-reading-my-benchmark-mattered-more-than-the-memory-system-did-44l4</guid>
      <description>&lt;p&gt;On 2026-08-11 I reran a benchmark I'd already published and changed exactly one thing: the model that reads the retrieved memory and writes the answer. Same memory system, same retrieved context, same judge, same rubric. YantrikDB's own score moved about 9 points on that swap.&lt;/p&gt;

&lt;p&gt;Grounding, if you haven't heard of the thing being measured: YantrikDB is a persistent memory system for AI agents — it stores what happened in earlier sessions and hands the relevant parts back on demand. It makes no LLM call at ingest, which is where the 241-second ingest and 80ms retrieval numbers further down come from.&lt;/p&gt;

&lt;p&gt;Two weeks before that rerun, Hindsight — a competing agent-memory system — had published a score on the same benchmark 25 points above mine. Nine of those 25 came from a model swap that has nothing to do with either memory architecture. More than a third of a published architectural gap, produced by changing the plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comparison that got thrown out
&lt;/h2&gt;

&lt;p&gt;The first version was the lazy one. BEAM-100K, an agent-memory benchmark, rubric-scored: YantrikDB at 0.611, Hindsight's published number at 0.862. My answerer was deepseek-v4-flash, theirs was gemini-3.1-pro-preview, and I filed that as a footnote.&lt;/p&gt;

&lt;p&gt;Two adversarial reviews came back on it on 2026-08-11, one from a Codex-family model (gpt-5.6-sol) and one from Qwen (qwen3.8-max), both rejecting it for the same reason. An end-to-end agent-memory score has at least three things stacked inside it: how good retrieval is, whatever an ingest-time LLM does to the data on the way in, and how well the reading model uses what came back. YantrikDB and Hindsight differ in architecture. Hindsight runs an LLM fact-extraction pass at ingest and YantrikDB doesn't. The answerer models were different. Three variables moved together and I'd assigned all 25 points to the one I happen to build.&lt;/p&gt;

&lt;p&gt;My first reaction was that this was reviewer pedantry — the reader model is plumbing, the memory system is the interesting part. Then I ran the swap to prove it, got the 9 points, and spent the next two days rewriting the paper instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Replaying their own context strings
&lt;/h2&gt;

&lt;p&gt;Hindsight publishes, per query, the literal context string it injects into its answerer — one for a RAG configuration, one for a single-query configuration. Not a description of what its retrieval does; the string itself. So I took those strings unmodified and ran them through YantrikDB's own fixed answerer and judge: deepseek-v4-flash:0731 at temperature 0, the identical pair already scoring YantrikDB's own retrieved contexts. Nothing here calls Hindsight's live system; it's their published artifacts replayed through my scoring pipeline, so the only thing varying between rows is which memory system wrote the context.&lt;/p&gt;

&lt;p&gt;Worth being blunt about one detail: the answerer and the judge here are the same model. deepseek-v4-flash:0731 writes the answer and then grades it, and self-grading has an obvious bias risk that I'm not going to dress up. What makes it usable is the frozen-context design — the same reader and the same judge process both systems' contexts under the same rubric, so whatever that model is inclined to reward, it rewards equally in both columns. The residual worry attaches to the absolute score level; the gap between the columns is what gets reported.&lt;/p&gt;

&lt;p&gt;400 queries nested inside 20 conversations, all from BEAM-100K. All 400 query_ids matched across systems, so there's no partial-overlap story to explain this away.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;Binary accuracy&lt;/th&gt;
&lt;th&gt;Rubric score&lt;/th&gt;
&lt;th&gt;Context tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YantrikDB&lt;/td&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;72.2%&lt;/td&gt;
&lt;td&gt;0.607&lt;/td&gt;
&lt;td&gt;13,673&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hindsight&lt;/td&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;71.5%&lt;/td&gt;
&lt;td&gt;0.592&lt;/td&gt;
&lt;td&gt;23,689&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hindsight&lt;/td&gt;
&lt;td&gt;single-query&lt;/td&gt;
&lt;td&gt;65.0%&lt;/td&gt;
&lt;td&gt;0.563&lt;/td&gt;
&lt;td&gt;17,655&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The significance test needs a note, because the obvious test is the wrong one. Those 400 queries aren't 400 independent draws — they sit inside 20 conversations, and queries from the same conversation tend to succeed and fail together, so a per-query t-test would count 400 samples where there are really 20. The test therefore works at conversation level: flip the sign of each conversation's mean difference, enumerate every possible assignment, which with 20 conversations is 2^20 of them, a bit over a million, and p comes out at 0.438. The interval is a separate procedure — 4,000 conversation-clustered bootstrap resamples, giving [-0.021, +0.052]. A +0.015 rubric difference with an interval that comfortably straddles zero is a tie, and the paper's own word for it is "equivalent". Against Hindsight's single-query configuration the difference is +0.044, p=0.025, CI [+0.009, +0.077] — real, but single-query is the weaker of their two published configurations, so leading with it would be picking the easy target.&lt;/p&gt;

&lt;p&gt;What did survive is cheaper than a win. YantrikDB reached that equivalent rubric score on 13,673 tokens of context against Hindsight's 23,689 — 42% fewer. The paper is careful here and I want to stay careful: part of that 42% is the cost of not carrying event time. Hindsight's contexts carry a median of 117 dates each. YantrikDB's carry a median of 5. "Not a free efficiency" is the paper's phrase, and it's the right call: that's a difference in what the two memory layers choose to emit. YantrikDB's two weakest categories in this eval are event ordering at 0.298 and temporal reasoning at 0.425. Those numbers and the token saving are one fact seen from two sides: a context with fewer dates in it is cheaper to read and worse at time.&lt;/p&gt;

&lt;p&gt;Ingest and retrieval move the same direction, for a design reason and not a tuning one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;System&lt;/th&gt;
&lt;th&gt;Ingest time&lt;/th&gt;
&lt;th&gt;Retrieval (mean)&lt;/th&gt;
&lt;th&gt;LLM call at ingest&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YantrikDB&lt;/td&gt;
&lt;td&gt;241s&lt;/td&gt;
&lt;td&gt;80ms&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hindsight (RAG)&lt;/td&gt;
&lt;td&gt;404s&lt;/td&gt;
&lt;td&gt;2,565ms&lt;/td&gt;
&lt;td&gt;fact extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hindsight (single-query)&lt;/td&gt;
&lt;td&gt;404s&lt;/td&gt;
&lt;td&gt;6,379ms&lt;/td&gt;
&lt;td&gt;fact extraction&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No LLM call at ingest is the choice; 80ms against 2,565ms is what the choice buys.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part worth stealing
&lt;/h2&gt;

&lt;p&gt;Back to the 9 points, the finding I'd defend hardest. Holding retrieval completely fixed and changing only the answerer moved YantrikDB's own score by more than a third of the entire published gap between two different memory systems. The paper puts it flatly: "The published gap is therefore overwhelmingly a property of the reading model, not of the memory systems." Any end-to-end "our agent memory beats X" number that doesn't name its answerer model and its judge model isn't a comparable number, and may be describing nothing more interesting than a stronger LLM reading the same context better. That's a complaint about how the category reports results, not about Hindsight, whose habit of publishing raw context strings is the only reason this experiment was possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  A different reader, a different answer
&lt;/h2&gt;

&lt;p&gt;There's a later experiment in the same paper, and leaving it out here would be editing rather than summarising. On 2026-08-15 I ran the rag-mode 2×2 again under an equalized frontier-class reader: moonshotai/kimi-k2.6 answering, meta-llama/llama-4-maverick judging — a different reader/judge pair from the deepseek one above. On 393 paired queries, YantrikDB scored 0.6331 rubric against Hindsight RAG's 0.5982: +3.5 percentage points, bootstrap 95% CI [+0.7, +6.2]pp, excluding zero.&lt;/p&gt;

&lt;p&gt;That doesn't overturn the tie, and I'd rather be tedious about it than let anyone read it that way. A different reader model is a different question rather than a rerun of the same one, and the RAG-vs-RAG comparison under the shared deepseek reader stays the honest headline: equivalent. What the frontier run supports is narrower — under one stronger reader, on a slightly smaller paired set, a small edge that clears its own confidence interval. It is not the sentence "YantrikDB beats Hindsight," and writing that sentence would be doing the exact thing the previous section complains about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrong twice, and what's still open
&lt;/h2&gt;

&lt;p&gt;The reader-model confound was the big retraction. There's a second one, smaller, the same shape. I'd had a hypothesis that Hindsight's ingest-time extraction step was quietly discarding evidence later questions needed — an information-bottleneck story, and the one result that would have made YantrikDB structurally better instead of just cheaper. Category by category against their single-query configuration, it looked real. Against their RAG configuration it mostly evaporated. The paper's line: "We are withdrawing the information-bottleneck claim as unsupported by this experiment." That paragraph took longer to write than the whole results section.&lt;/p&gt;

&lt;p&gt;Smaller things, in no order. The results page briefly said engine version 0.14.0; the version actually installed in the benchmark's virtualenv, per dist-info at 21:05 on 2026-08-11, was 0.13.4. That number turned out to matter slightly — a 2026-08-16 rerun of the same comparison on engine 0.15.0 scored 72.5% / 0.6375 where 0.13.4 scored 71.5% / 0.6107, a +0.027 rubric delta, bootstrap CI [+0.007, +0.045], small and clear of zero. The deepseek 2×2 is incomplete: YantrikDB's contexts were never run through Hindsight's answerer, so three of the four cells exist. Context length was never controlled either, so the token-efficiency and accuracy-equivalence findings stay two claims sitting next to each other rather than one. The local harness is a fork of vectorize-io/agent-memory-benchmark, which carries no license and can't be redistributed, so the paper's "Reproduce" link points at the upstream repo.&lt;/p&gt;

&lt;p&gt;The paper went up 2026-08-12 at yantrikdb.com/papers/beam-frozen-context/ and until this post it hadn't been anywhere but its own domain. Take the boring thing from it: when you publish agent-memory numbers, name the model that read the context and name the model that graded the answer. Without those two names the number doesn't mean much, including when the person publishing it is me.&lt;/p&gt;




&lt;p&gt;Pranab Sarkar, Independent Researcher&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>benchmarking</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
