Present is not the same as loadable
A package can sit inside the archive at the right version. Nothing is missing from disk. The code that imports it still cannot reach it. Every packaging check we had was blind to this class of bug.
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.
This post tells how the release passed every gate we had and still shipped
broken. It names the three defects. It describes the ten-second test that now
runs 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 about
Version 1.3.7 mattered because of one number. That number had been quietly ruining the local experience. A 4 GB machine picks the entry-level local model by default. Until this release that model was Phi-3 Mini, with a 4096-token context window. Mnemosyne OS grounds an answer in the memories it retrieves, and those memories are part of the prompt. On the very first memory-grounded question, the context overflowed. The promise “it works offline” was true on paper and false in practice.
The refreshed local catalogue ships 8 models with 262k-token context windows. The release rests on that contrast: 4k to 262k. This is not a performance claim. A small local model still recalls less well than a large cloud one, and we will not pretend otherwise. But a 262k window can hold your memories in the prompt. A 4k window could not.
The same release fixed three other things:
- BYOK model lists now come from the provider itself. Six providers were dead because we passed a catalogue id where the API expected a model name. The endpoint does not know our internal identifiers.
- A local journal of what each call costs, at rates you enter yourself. The app ships no price table. 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
We found three defects. All three have 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. If you unpack a package without its transitive tree, the module
fails at the first packed link.
That exact rule was already written in the same config file, twelve lines above, for another package’s closure. Nobody had applied it here. Eighty-eight entries were missing. Every one of them could be derived from the resolved graph.
2. The pnpm flattening script kept one version and dropped the rest in silence
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”. Directory order decided, which means the
alphabet decided.
signal-exit@3.0.7 (CommonJS) beat 4.1.0 (ESM). 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. The script now also says what it nested, instead of
incrementing a mute counter. The first run reported 8 conflicts. One of them
was node-llama-cpp, which needed 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 that no earlier pass had seen. That dependency was left packed. This defect appeared after defects 1 and 2 were fixed and the build had been declared good. It appeared twice.
Every gate asked “is the file there?”
This is the real lesson. It applies far beyond 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 two different properties. Packaging bugs live 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. That means ESM
resolution against the real filesystem, the same resolution that cannot reach a
dependency stranded inside the archive. The check takes ten seconds. It needs
no install and no GPU, because 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). It locates app.asar.unpacked by searching
the release directory, because the three platforms bury it in three different
places. A hardcoded path per job would be one rename away from a gate that
silently checks nothing.
The negative test reproduces the shipped failure word for word. We had to see the guardrail fail once before we trusted it.
What we did about the release already out there
We switched the release to pre-release roughly twenty minutes after publication. Our updater ignores pre-releases. From that moment, no installation was offered the broken build. The macOS and Linux artifacts were downloaded zero times before we pulled them.
We have since rebuilt every installer with the three defects fixed and the import check in place. v1.3.7 is public again.
What we saw while the machine was choking
The same week gave us one more observation, and it points the other way.
One machine with 31 GB of RAM and an RTX 4050 was running all of this at once. A long benchmark run was in progress. The OS itself had a 2B model resident on the GPU. 26 vaults were mounting, the embedding pipeline was working, and Electron builds were running. Roughly 5 GB of RAM was left. The logs show what that costs:
event-loop stall: 34355ms
metrics.worker: no reply within 10000ms
The machine was throttled, and inference suffered for it. It did not run comfortably under load. 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. The system degraded legibly instead of dying. Mnemosyne OS promises that you can check what it remembers. So the way it fails is part of the product.
One test to add to your own build
If you package a native module into a desktop app, add one test. It imports your real entry point from the artifact you are about to sign, in the same module system your runtime uses. A file listing does not do this. An import does.
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.