Mnemosyne OS
New The documentation is live: every engine, step by step. docs.mnemosyne-os.io →
← All posts

Present is not the same as loadable

Published August 10, 2026 6 min read

electronpackagingesmpnpmcipostmortem

A package can sit inside the archive, at the right version, with nothing missing from disk, and still be unreachable to the code that imports it. That is the class of bug living in the blind spot of every packaging check.

We learned it the usual way. Mnemosyne OS v1.3.7 went out on August 10th with a simple headline: the whole loop runs on your machine. Model, memory, retrieval, no key, no account. In the installed app, it loaded no local model at all.

Here is how a release passed every gate we had and still shipped broken, what the three defects were, and the ten-second test that now stands between a package and its signature. If you have ever watched an ERR_MODULE_NOT_FOUND appear only in production, you already know the ending; the interesting part is why none of our checks could see it coming.

What the release was actually about

The reason 1.3.7 mattered is a number that had been quietly ruining the local experience. Our previous entry-level local model, the one a 4 GB machine picks by default, was Phi-3 Mini, with a 4096-token context window. Mnemosyne grounds an answer in retrieved memories, and those memories are part of the prompt. On the very first memory-grounded question, the context overflowed. The promise “it works offline” was technically true and practically false.

The refreshed local catalogue ships 8 models with 262k-token context windows. That is the contrast the release rests on: 4k to 262k. Not a performance claim, a small local model still recalls less well than a large cloud one, and we are not going to pretend otherwise, but the difference between a loop that can hold your memories and one that couldn’t.

The same release fixed three other things worth naming:

  • BYOK model lists now come from the provider itself. Six providers were dead because we were passing a catalogue id where an API model name was expected. An internal identifier is not an endpoint’s vocabulary.
  • A local journal of what each call costs, at rates you enter yourself. There is no price table shipped in the app, prices change, and a stale table that looks authoritative is worse than no table.
  • Vault governance: marking a vault MAXIMUM now actually holds the cloud route closed.

The first click

Then the installed app tried to load a model, and:

Cannot find package 'lifecycle-utils' imported from
  …/app.asar.unpacked/node_modules/node-llama-cpp/dist/index.js

Three defects, all the same shape: something is present, but not where the code that needs it goes looking.

1. asarUnpack covered the package, not its dependency closure

We unpack node-llama-cpp because it has native binaries. Unpacking means its dist/index.js runs from app.asar.unpacked, and resolves its imports against the real filesystem. It can never reach a dependency that stayed inside app.asar. Unpacking a package without unpacking its transitive tree gives you a module that fails at the first packed link.

The infuriating part: that exact rule was already written in the same config file, twelve lines above, for another package’s closure. It had simply never been applied here. Eighty-eight entries were missing, all derivable from the resolved graph.

2. The pnpm flattening script elected one version and dropped the rest, silently

Our script flattens a pnpm store into a plain node_modules for packaging. When two versions of a name existed, it kept the first one it met and counted every other as “already present”. The arbiter was directory order, that is, the alphabet.

signal-exit@3.0.7 (CommonJS) beat 4.1.0 (ESM), and the consumer that needs the named export from v4 died with:

Named export 'onExit' not found

Both consumers were right. One wants ^3, the other ^4, and no single version serves both. The fix is what npm has always done: nest the satisfying version under each dependent, and say what was nested, instead of incrementing a mute counter. The first run reported 8 conflicts, one of which was node-llama-cpp needing a newer lifecycle-utils than the flat tree had kept.

3. A closure computed before nesting misses what nesting introduces

Nesting a v7 package pulled in a transitive dependency no earlier pass had seen, which was therefore left packed. This one appeared after defects 1 and 2 were fixed and the build had been declared good. Twice.

Every gate asked “is the file there?”

That is the actual lesson, and it is not about Electron.

v1.3.7 passed a sealed-asar check, a size check and a signature check. Each of them asked a presence question. None of them asked whether the thing loads. Presence and resolvability are different properties, and packaging bugs live exactly in the gap between them.

So we wrote the check that asks the other question. It imports the native entry point from the built package, exactly as Electron will: ESM resolution against the real filesystem, which is precisely what makes a dependency stranded inside the archive unreachable. It takes ten seconds, needs no install and no GPU, importing the module does not load a model. A non-zero exit fails the job, before signing. It is wired into all three build jobs (Windows, macOS, Linux), and it locates app.asar.unpacked by searching the release directory, because the three platforms bury it in three different places and a hardcoded path per job is one rename away from a gate that silently checks nothing.

The negative test reproduces the shipped failure word for word. A guardrail you have never seen fail is a guardrail you have not tested.

What we did about the release already out there

The release was switched to pre-release roughly twenty minutes after publication. Our updater ignores pre-releases, so from that moment no installation was offered the broken build. The macOS and Linux artifacts were downloaded zero times before they were pulled.

Every installer has since been rebuilt with the three defects fixed and the import check in place, and v1.3.7 is public again.

What we saw while the machine was choking

One thing worth reporting from the same week, because it cuts the other way.

On a single 31 GB machine with an RTX 4050 we had, at the same time: a long benchmark run, the OS itself with a 2B model resident on the GPU, 26 vaults mounting, the embedding pipeline, and Electron builds. Roughly 5 GB of RAM left. The logs show exactly what that costs:

event-loop stall: 34355ms
metrics.worker: no reply within 10000ms

This is not a “runs comfortably under load” story. The machine was throttled and inference suffered for it. What we care about is how it suffered: the metrics sampler gave up and said so, vaults kept mounting, inference ran to completion, and the answer arrived. It degraded legibly instead of dying. For a system whose whole proposition is that you can check what it remembers, the failure mode is part of the product.

The part worth stealing

If you package a native module into a desktop app, add one test that imports your real entry point from the artifact you are about to sign, in the same module system your runtime uses. Not a file listing. An import.

Ours cost ten seconds and would have caught all three defects.

Mnemosyne OS is a local-first memory OS. The current build is on the download page.

Share

Cite this post

No DOI, a blog post is not a deposited record. This cites the page itself.

APA

Mnemosyne OS. (August 10, 2026). Present is not the same as loadable. Mnemosyne OS. https://mnemosyne-os.io/blog/a-release-that-could-not-load-a-model

BibTeX
@misc{mnemosyne-a-release-that-could-not-load-a-model,
  author       = {{Mnemosyne OS}},
  title        = {Present is not the same as loadable},
  year         = {2026},
  month        = {8},
  day          = {10},
  howpublished = {Blog post, Mnemosyne OS},
  url          = {https://mnemosyne-os.io/blog/a-release-that-could-not-load-a-model}
}