Mnemosyne OS
Nuevo La documentación está en línea: cada motor, paso a paso. docs.mnemosyne-os.io →
← Todos los artículos

Presente no es lo mismo que cargable

Publicado el 10 de agosto de 2026 7 min de lectura

electronpackagingesmpnpmcipostmortem

Un paquete puede estar ahí, dentro del archivo, en la versión correcta, sin que falte nada en el disco, y seguir siendo inalcanzable para el código que lo importa. Esa es la clase de bug que vive en el punto ciego de todos los controles de empaquetado.

Lo aprendimos de la forma habitual. Mnemosyne OS v1.3.7 salió el 10 de agosto con una promesa simple: todo el bucle funciona en tu máquina. Modelo, memoria, recuperación, sin clave, sin cuenta. En la aplicación instalada, no cargaba ningún modelo local.

Esto es el relato de cómo una versión superó todos nuestros controles y aun así se publicó rota, cuáles fueron los tres defectos, y qué test de diez segundos se interpone ahora entre un paquete y su firma. Si alguna vez has visto un ERR_MODULE_NOT_FOUND aparecer solo en producción, ya conoces el final; lo interesante es por qué ninguno de nuestros controles podía verlo venir.

De qué iba realmente esta versión

Lo que hacía importante a la 1.3.7 es una cifra que llevaba tiempo arruinando en silencio la experiencia local. Nuestro anterior modelo local de entrada, el que elige por defecto una máquina de 4 GB, era Phi-3 Mini, con una ventana de contexto de 4096 tokens. Mnemosyne ancla sus respuestas en los recuerdos que recupera, y esos recuerdos forman parte del prompt. En la primerísima pregunta anclada en memoria, el contexto se desbordaba. La promesa «funciona sin conexión» era técnicamente cierta y prácticamente falsa.

El catálogo local renovado trae 8 modelos con ventanas de contexto de 262k tokens. Ese es el contraste sobre el que descansa la versión: de 4k a 262k. No es un argumento de rendimiento, un modelo local pequeño sigue recordando peor que uno grande en la nube, y no vamos a fingir lo contrario, pero es la diferencia entre un bucle capaz de sostener tus recuerdos y uno que no podía.

La misma versión arregla otras tres cosas que merecen nombrarse:

  • En BYOK, la lista de modelos viene ahora del propio proveedor. Seis proveedores estaban muertos porque pasábamos un identificador de catálogo donde se esperaba un nombre de modelo de API. Un identificador interno no es el vocabulario de un endpoint.
  • Un registro local del coste de cada llamada, a las tarifas que introduces tú. La aplicación no incorpora ninguna tabla de precios, los precios cambian, y una tabla obsoleta con aire de autoridad es peor que ninguna tabla.
  • Gobernanza de las bóvedas: marcar una bóveda como MAXIMUM mantiene por fin cerrada de verdad la ruta hacia la nube.

El primer clic

Entonces la aplicación instalada intentó cargar un modelo:

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

Tres defectos, todos con la misma forma: algo está presente, pero no donde el código que lo necesita va a buscarlo.

1. asarUnpack cubría el paquete, no su cierre de dependencias

Desempaquetamos node-llama-cpp porque lleva binarios nativos. Al estar desempaquetado, su dist/index.js se ejecuta desde app.asar.unpacked, y resuelve sus imports contra el sistema de ficheros real. Nunca puede alcanzar una dependencia que se quedó dentro de app.asar. Desempaquetar un paquete sin desempaquetar su árbol transitivo da un módulo que falla en el primer eslabón que siguió empaquetado.

Lo más exasperante: esa misma regla ya estaba escrita en el mismo fichero de configuración, doce líneas más arriba, para el cierre de otro paquete. Simplemente nunca se había aplicado aquí. Faltaban ochenta y ocho entradas, todas deducibles del grafo resuelto.

2. El script de aplanado de pnpm elegía una versión y descartaba el resto, en silencio

Nuestro script aplana un store de pnpm en un node_modules corriente para el empaquetado. Cuando existían dos versiones de un mismo nombre, se quedaba con la primera que encontraba y contaba todas las demás como «ya presente». El árbitro era el orden de directorios, es decir, el alfabeto.

signal-exit@3.0.7 (CommonJS) ganó a 4.1.0 (ESM), y el consumidor que necesita la exportación nombrada de la v4 murió con:

Named export 'onExit' not found

Ambos consumidores tenían razón. Uno quiere ^3, el otro ^4, y ninguna versión única sirve a los dos. El arreglo es lo que npm hace desde siempre: anidar la versión que satisface bajo cada dependiente, y decir qué se anidó, en lugar de incrementar un contador mudo. La primera pasada reportó 8 conflictos, uno de ellos node-llama-cpp pidiendo un lifecycle-utils más reciente que el que había conservado el árbol plano.

3. Un cierre calculado antes del anidado no ve lo que el anidado introduce

Anidar un paquete en v7 arrastró una dependencia transitiva que ninguna pasada anterior había visto, y que por tanto quedó empaquetada. Esta apareció después de corregir los defectos 1 y 2, con el build ya declarado bueno. Dos veces.

Todos los controles preguntaban «¿está el fichero?»

Esa es la lección de verdad, y no va de Electron.

La 1.3.7 pasó un control de asar sellado, uno de tamaño y uno de firma. Cada uno hacía una pregunta de presencia. Ninguno preguntaba si la cosa carga. Presencia y resolubilidad son propiedades distintas, y los bugs de empaquetado viven exactamente en el hueco entre ambas.

Así que escribimos el control que hace la otra pregunta. Importa el punto de entrada nativo desde el paquete construido, exactamente como hará Electron: resolución ESM contra el sistema de ficheros real, que es precisamente lo que vuelve inalcanzable a una dependencia atrapada dentro del archivo. Tarda diez segundos, no necesita instalación ni GPU, importar el módulo no carga ningún modelo. Una salida distinta de cero hace fallar el job, antes de la firma. Está conectado a los tres jobs de build (Windows, macOS, Linux), y localiza app.asar.unpacked buscándolo en el directorio de release, porque las tres plataformas lo entierran en tres sitios distintos y una ruta fija por job está a un renombrado de convertirse en un control que ya no comprueba nada.

El test negativo reproduce el fallo publicado palabra por palabra. Un control que nunca has visto fallar es un control que no has probado.

Qué hicimos con la versión ya publicada

La release pasó a pre-release unos veinte minutos después de su publicación. Nuestro updater ignora las pre-releases: desde ese momento, a ninguna instalación se le ofreció el build roto. Los artefactos de macOS y Linux se descargaron cero veces antes de ser retirados.

Desde entonces todos los instaladores se han reconstruido con los tres defectos corregidos y el test de importación en su sitio, y la 1.3.7 vuelve a ser pública.

Lo que vimos mientras la máquina se ahogaba

Vale la pena contar una observación de la misma semana, porque va en el otro sentido.

En una sola máquina de 31 GB con una RTX 4050 teníamos a la vez: una tirada larga de benchmark, el propio OS con un modelo 2B residente en la GPU, 26 bóvedas montándose, el pipeline de embeddings y builds de Electron. Unos 5 GB de RAM libres. Los logs muestran exactamente lo que eso cuesta:

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

Esto no es una historia de «funciona cómodamente bajo carga». La máquina estaba estrangulada y la inferencia lo sufrió. Lo que nos importa es cómo lo sufrió: el muestreador de métricas se rindió y lo dijo, las bóvedas siguieron montándose, la inferencia llegó hasta el final y la respuesta apareció. Se degradó de forma legible en vez de morir. Para un sistema cuya propuesta entera es que puedas comprobar qué recuerda, el modo de fallo forma parte del producto.

La parte que merece copiarse

Si empaquetas un módulo nativo en una aplicación de escritorio, añade un test que importe tu punto de entrada real desde el artefacto que estás a punto de firmar, en el mismo sistema de módulos que usa tu runtime. No un listado de ficheros. Un import.

El nuestro cuesta diez segundos y habría cazado los tres defectos.

Mnemosyne OS es un OS de memoria local-first. El build actual está en la página de descarga.

Compartir

Citar este artículo

Sin DOI, una entrada de blog no es un depósito. La cita apunta a la página.

APA

Mnemosyne OS. (10 de agosto de 2026). Presente no es lo mismo que cargable. Mnemosyne OS. https://mnemosyne-os.io/es/blog/a-release-that-could-not-load-a-model

BibTeX
@misc{mnemosyne-a-release-that-could-not-load-a-model,
  author       = {{Mnemosyne OS}},
  title        = {Presente no es lo mismo que cargable},
  year         = {2026},
  month        = {8},
  day          = {10},
  howpublished = {Blog post, Mnemosyne OS},
  url          = {https://mnemosyne-os.io/es/blog/a-release-that-could-not-load-a-model}
}