UNPKG

harper-fabric-embeddings

Version:

Minimal llama.cpp embedding wrapper for Harper Fabric. Talks directly to the native addon — no build tools, no CLI, no chat wrappers.

695 lines • 31.9 kB
/** * Embedding engine internals. * * `EmbeddingEngine` owns one GGUF model + llama.cpp context and serializes * embed calls against it. Multiple engines can coexist in one process — one * per registered Harper model entry — so the native addon binding is shared * through a process-resident registry keyed by addon path (dlopen of the same * path returns the same handle; llama.cpp keeps all state in heap objects, so * engines stay independent through the shared native code). */ import { createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs'; import { open, rename, unlink } from 'node:fs/promises'; import { assertDeclaredPooling, POOLING_NAMES } from './gguf.js'; import { pipeline } from 'node:stream/promises'; import { setTimeout as sleep } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; // ─── Model registry ───────────────────────────────────────────────────────── const NOMIC_TEMPLATES = { document: 'search_document: {text}', query: 'search_query: {text}', }; const MODELS = { 'nomic-embed-text': { repo: 'nomic-ai/nomic-embed-text-v1.5-GGUF', file: 'nomic-embed-text-v1.5.Q4_K_M.gguf', templates: NOMIC_TEMPLATES, }, 'nomic-embed-text-v2-moe': { repo: 'nomic-ai/nomic-embed-text-v2-moe-GGUF', file: 'nomic-embed-text-v2-moe.Q4_K_M.gguf', templates: NOMIC_TEMPLATES, }, }; // ─── Shared native binding registry ───────────────────────────────────────── // One dlopen + backend load per addon path, shared across engines and resident // for the life of the process. Never torn down: dlopen dedups by path, so a // "fresh" load after a teardown gets the SAME native module back — and // re-running init()/loadBackends() re-registers backend devices in ggml's // global registry without unregistering the old entries. ggml_backend_sched_new // asserts n_backends <= GGML_SCHED_MAX_BACKENDS (16), so ~7 construct/dispose // cycles abort the host process on Metal (issue #9). Engines still free their // own model + context in dispose(); the addon handle and its one-time backend // registrations are process-lifetime. A failed load is evicted so the next // acquire retries. const bindings = new Map(); function acquireBinding(addonPath) { let promise = bindings.get(addonPath); if (!promise) { const created = loadBinding(addonPath); created.catch(() => { if (bindings.get(addonPath) === created) bindings.delete(addonPath); }); bindings.set(addonPath, created); promise = created; } return promise; } async function loadBinding(addonPath) { const binding = loadAddon(addonPath); await binding.init(); // Load GPU/CPU backends from the same directory as the addon binary binding.loadBackends(); binding.loadBackends(path.dirname(addonPath)); return binding; } // ─── Engine ───────────────────────────────────────────────────────────────── /** * One GGUF model + llama.cpp embedding context. * * - Construction validates configuration synchronously (missing model source, * unknown model name) so misconfiguration fails at registration, not first use. * - Heavy work (download, addon + model load) happens in `ensureReady()`, which * is lazy, shared across concurrent callers, and retryable after failure. * - `embedMany()` serializes against a per-engine queue — the llama.cpp context * is not safe for concurrent use. */ export class EmbeddingEngine { #options; #modelIdentity; #templates; #nomicFallback; #binding = null; #model = null; #context = null; #bosToken = -1; #eosToken = -1; // Effective n_ubatch the live context was created with. embedOne truncates // against this so oversized inputs return a (shorter) embedding instead of // tripping the llama.cpp GGML_ASSERT that kills the host process. #maxInputTokens = 0; #truncationWarned = false; #disposed = false; // Set at dispose() entry, before the queue drain. Embeds already queued on an // initialized engine complete; anything needing a fresh init (or submitted // after) rejects instead of starting native work during shutdown. #disposeStarted = false; #initPromise = null; // Serial queue for embed calls — llama.cpp context is not safe for concurrent use #queue = Promise.resolve(); constructor(options) { if (!options.modelPath && !options.modelsDir) { throw new Error('Either modelPath or modelsDir is required'); } const modelName = options.modelName ?? 'nomic-embed-text'; if (!options.modelPath && !MODELS[modelName]) { throw new Error(`Unknown model: ${modelName}. Available: ${Object.keys(MODELS).join(', ')}`); } // The truncation guard in #embedOne reserves 2 slots for BOS/EOS; an // effective batch below 3 can still build a 3-token sequence and trip the // llama.cpp GGML_ASSERT that kills the host process. Reject up front. const effectiveBatchSize = options.batchSize ?? options.contextSize ?? 2048; if (effectiveBatchSize < 3) { throw new Error(`batchSize must be at least 3 (one body token plus BOS/EOS), got ${effectiveBatchSize}`); } if (options.pooling !== undefined && !POOLING_NAMES.includes(options.pooling)) { throw new Error(`Unknown pooling '${String(options.pooling)}' (expected one of: ${POOLING_NAMES.join(', ')})`); } this.#options = options; this.#modelIdentity = options.modelPath ? path.basename(options.modelPath) : modelName; const templates = resolveEngineTemplates(options); if (templates) validateTemplates(templates); this.#templates = templates; this.#nomicFallback = /nomic-embed-text/i.test(this.#modelIdentity); } /** Model name (or model file basename) — used for backend naming and prefix detection. */ get modelIdentity() { return this.#modelIdentity; } /** * Kick off (or join) initialization. Lazy and retryable: a failed attempt * resets so the next call tries again (e.g. a transient download failure). */ ensureReady() { if (this.#initPromise) return this.#initPromise; const attempt = this.#doInit(); this.#initPromise = attempt; attempt.catch(() => { if (this.#initPromise === attempt) this.#initPromise = null; }); return attempt; } async #doInit() { if (this.#disposed || this.#disposeStarted) throw new Error('Engine has been disposed.'); const { modelPath: explicitPath, modelsDir, modelName = 'nomic-embed-text', contextSize = 2048, batchSize: explicitBatchSize, threads = 6, gpuLayers = 0, addonPath, } = this.#options; // node-llama-cpp's AddonContext ties n_batch and n_ubatch to `batchSize`, // and llama.cpp aborts (GGML_ASSERT -> ggml_abort, kills the host process) // when any input tokenizes above n_ubatch. Default to contextSize so the // full declared context window is actually embeddable. const batchSize = explicitBatchSize ?? contextSize; // Resolve the model BEFORE touching the addon: the common misconfiguration // (bad path, missing file) never churns the shared binding refcount. let modelPath = explicitPath; if (!modelPath) { modelPath = await resolveModelPath(modelsDir, modelName); } if (!existsSync(modelPath)) throw new Error(`Model file not found: ${modelPath}`); // Same principle as resolving the model first: verify pooling before the // addon is touched, so a wrong-pooling model never churns the binding // refcount or pays a model load just to be rejected. if (this.#options.pooling) { await assertDeclaredPooling(modelPath, this.#options.pooling); } const resolvedAddonPath = addonPath || findAddonBinary(); const binding = await acquireBinding(resolvedAddonPath); let model = null; let context = null; try { model = new binding.AddonModel(modelPath, { gpuLayers, useMmap: true, useMlock: false, checkTensors: false, }); if (!(await model.init())) { // Dispose before nulling: a false init() may still hold native // resources, and the catch below skips dispose for a null model. await model.dispose().catch(() => { }); model = null; throw new Error('Failed to load model'); } context = new binding.AddonContext(model, { contextSize, batchSize, sequences: 1, embeddings: true, threads, }); if (!(await context.init())) { context = null; throw new Error('Failed to create embedding context'); } if (this.#disposed) throw new Error('Engine has been disposed.'); } catch (err) { if (context) await context.dispose().catch(() => { }); if (model) await model.dispose().catch(() => { }); throw err; } // Commit state only after everything succeeds this.#binding = binding; this.#model = model; this.#context = context; this.#bosToken = model.tokenBos(); this.#eosToken = model.tokenEos(); this.#maxInputTokens = batchSize; } /** * Generate one L2-normalized vector per input, in input order. * * Serialized against the engine's queue; init is awaited lazily on first * call. `opts.signal` is checked between inputs (best-effort — a decode in * flight can't be interrupted). */ embedMany(texts, opts = {}) { // Fail an already-aborted call synchronously instead of parking it in the queue. opts.signal?.throwIfAborted(); const result = this.#queue.then(async () => { await this.ensureReady(); this.#assertReady(); const vectors = []; let tokens = 0; for (const text of texts) { opts.signal?.throwIfAborted(); const one = await this.#embedOne(this.#applyTemplate(text, opts)); vectors.push(one.vector); tokens += one.tokens; } return { vectors, tokens }; }); this.#queue = result.catch(() => { }); return result; } /** Get the embedding vector dimensionality. */ dimensions() { if (!this.#model) throw new Error('Not initialized. Call init() first.'); return this.#model.getEmbeddingVectorSize(); } /** * Clean up native resources. * * Drains the embed queue before touching native handles — disposing the * llama.cpp context while a decodeBatch is executing is a use-after-free * that can kill the host process. Embeds accepted before this call complete * (when the engine is initialized); embeds that would need a fresh init * during disposal, and any submitted after, reject with a disposed error. */ async dispose() { this.#disposeStarted = true; await this.#queue.catch(() => { }); this.#disposed = true; this.#initPromise = null; if (this.#context) { await this.#context.dispose(); this.#context = null; } if (this.#model) { await this.#model.dispose(); this.#model = null; } // The shared addon binding is resident (see the bindings registry) — only // this engine's reference is dropped here. this.#binding = null; this.#bosToken = -1; this.#eosToken = -1; this.#maxInputTokens = 0; this.#truncationWarned = false; } #assertReady() { if (this.#disposed) throw new Error('Engine has been disposed.'); if (!this.#binding || !this.#model || !this.#context) { throw new Error('Not initialized. Call init() first.'); } } #applyTemplate(text, opts) { const inputType = opts.inputType; // Contract: omitted inputType is ALWAYS passthrough, templates or not — // input handling stays byte-identical to pre-template versions so // existing vectors remain comparable. The explicit two-value check also // keeps unrecognized runtime values (`inputType: 'toString'` through the // untyped facade path) on the passthrough side instead of resolving // prototype properties off the templates object. if (inputType !== 'document' && inputType !== 'query') return text; const template = this.#templates?.[inputType]; if (template) { const vars = { ...this.#templates.defaults }; if (opts.task !== undefined) vars.task = opts.task; vars.text = text; return renderTemplate(template, vars); } // Legacy fallback for template-less models (explicit modelPath overrides): // nomic-embed-text v1.5+ task prefixes, detected by name. Mirrors the // convention in Harper's built-in ollama backend. if (this.#nomicFallback) { return (inputType === 'document' ? 'search_document: ' : 'search_query: ') + text; } return text; } /** Generate a single embedding (must be called within the serial queue). */ async #embedOne(text) { let tokens = this.#model.tokenize(text, false); if (tokens.length === 0) return { vector: new Float32Array(0), tokens: 0 }; // Belt-and-suspenders: reserve 2 slots for BOS/EOS and truncate anything // that would exceed the live context's n_ubatch. Prevents GGML_ASSERT when // callers pass an explicit `batchSize` smaller than their real inputs. const maxBodyTokens = Math.max(1, this.#maxInputTokens - 2); if (tokens.length > maxBodyTokens) { if (!this.#truncationWarned) { console.warn(`[harper-fabric-embeddings] Input tokenized to ${tokens.length} tokens, ` + `truncating to ${maxBodyTokens} (batchSize=${this.#maxInputTokens}). ` + `Increase batchSize/contextSize if you need longer inputs embedded in full.`); this.#truncationWarned = true; } tokens = tokens.subarray(0, maxBodyTokens); } const input = this.#buildTokenSequence(tokens); return decodeAndEmbed(this.#context, input); } /** Build the full token sequence with BOS/EOS markers. */ #buildTokenSequence(tokens) { const parts = []; if (this.#bosToken >= 0 && tokens[0] !== this.#bosToken) { parts.push(this.#bosToken); } for (let i = 0; i < tokens.length; i++) { parts.push(tokens[i]); } if (this.#eosToken >= 0 && tokens[tokens.length - 1] !== this.#eosToken) { parts.push(this.#eosToken); } return new Uint32Array(parts); } } // ─── Single-sequence decode ───────────────────────────────────────────────── /** * Decode one token sequence against a single-sequence llama.cpp context * (`sequences: 1` — see `#doInit`) and return its L2-normalized embedding. * * Always decodes at `seq=0, pos=0`: there is exactly one KV-cache sequence * slot on the context, reused across every call. `disposeSequence(0)` evicts * whatever cache cells the *previous* call on this context left behind * before writing new cells at pos 0 — without it, a second decode on the * same context sees inconsistent KV-cache/position state at pos 0 and * `llama_decode` hard-aborts the host process (a native `GGML_ASSERT`, not a * catchable JS error). A no-op on a fresh context (issue #8). * * A free function (not a private method) so it can be unit tested against a * fake `LlamaContext` double — the real native context can't be constructed * without a loaded GGUF model. */ export async function decodeAndEmbed(context, input) { context.disposeSequence(0); context.initBatch(input.length); const logitIndexes = new Uint32Array(input.length); for (let i = 0; i < input.length; i++) logitIndexes[i] = i; context.addToBatch(0, 0, input, logitIndexes); await context.decodeBatch(); // Copy before normalizing — don't assume the addon's returned view stays // stable across the next decode. const vector = Float32Array.from(context.getEmbedding(input.length)); l2NormalizeInPlace(vector); return { vector, tokens: input.length }; } // ─── Prompt templates ─────────────────────────────────────────────────────── // One pass: `{{` / `}}` escapes and `{name}` placeholders, matched together so // values are substituted exactly once and never re-scanned. const TEMPLATE_TOKEN = /\{\{|\}\}|\{([A-Za-z_][A-Za-z0-9_]*)\}/g; /** * Resolve the templates an engine will use: explicit `options.templates` wins; * a registry model falls back to its entry's templates; an explicit `modelPath` * with no explicit templates gets none (the legacy name-prefix heuristic still * applies at embed time). Exported for tests — the models-backend production * path constructs via `modelName` and must resolve the registry branch. */ export function resolveEngineTemplates(options) { if (options.templates) return options.templates; if (options.modelPath) return undefined; return MODELS[options.modelName ?? 'nomic-embed-text']?.templates; } /** * Validate templates at construction (registration) time, so misconfiguration * fails at Harper boot instead of on the first embed call. Unrecognized * top-level keys are rejected (a typo'd side like `documnet:` would otherwise * silently fall back to unprefixed embeds); every placeholder must be `{text}`, * `{task}` (call-suppliable), or covered by `defaults`; any other `{`/`}` must * be escaped as `{{` / `}}`. */ export function validateTemplates(templates) { if (typeof templates !== 'object' || templates === null || Array.isArray(templates)) { throw new Error('templates must be an object with optional document/query strings and a defaults record'); } for (const key of Object.keys(templates)) { if (key !== 'document' && key !== 'query' && key !== 'defaults') { throw new Error(`templates contains unrecognized key '${key}' (expected 'document', 'query', 'defaults')`); } } // `??` already normalized a null/omitted defaults to `{}`. const defaults = templates.defaults ?? {}; if (typeof defaults !== 'object' || Array.isArray(defaults)) { throw new Error('templates.defaults must be a record of string values'); } for (const [key, value] of Object.entries(defaults)) { if (typeof value !== 'string') { throw new Error(`templates.defaults.${key} must be a string, got ${typeof value}`); } if (key === 'text') { throw new Error("templates.defaults may not define 'text' — it is always the embed input"); } } for (const side of ['document', 'query']) { const template = templates[side]; if (template === undefined) continue; if (typeof template !== 'string') { throw new Error(`templates.${side} must be a string, got ${typeof template}`); } // Brace hygiene first, so a placeholder typo like `{ text }` reports as an // unescaped-brace error rather than a missing-{text} error. const residue = template.replace(TEMPLATE_TOKEN, ''); if (/[{}]/.test(residue)) { throw new Error(`templates.${side} contains an unescaped '{' or '}' (placeholders are {name}; escape literals as {{ and }})`); } const placeholders = templatePlaceholders(template); if (!placeholders.includes('text')) { // Without {text} every input renders the same static prompt — identical // vectors and silently broken retrieval. Catch the typo at registration. throw new Error(`templates.${side} must include the {text} placeholder`); } for (const name of placeholders) { // Object.hasOwn, not `in`: a prototype-property name ({toString}, // {constructor}) must not satisfy the defaults check. if (name !== 'text' && name !== 'task' && !Object.hasOwn(defaults, name)) { throw new Error(`templates.${side} uses {${name}} which is neither {text}, {task}, nor covered by templates.defaults`); } } } } function templatePlaceholders(template) { const names = []; for (const match of template.matchAll(TEMPLATE_TOKEN)) { if (match[1]) names.push(match[1]); } return names; } /** Single-pass interpolation. `vars` must cover every placeholder (validated at registration for all but a default-less `{task}`). */ export function renderTemplate(template, vars) { return template.replace(TEMPLATE_TOKEN, (match, name) => { if (match === '{{') return '{'; if (match === '}}') return '}'; // Own properties only — a prototype-property placeholder must error, not // coerce an inherited function into the prompt. const value = Object.hasOwn(vars, name) ? vars[name] : undefined; if (value === undefined) { throw new Error(`No value for template placeholder {${name}} — pass it in the embed call (e.g. 'task') or add it to templates.defaults`); } return value; }); } /** L2-normalize in place. A zero vector is left unchanged. */ function l2NormalizeInPlace(vec) { let sumSq = 0; for (let i = 0; i < vec.length; i++) { sumSq += vec[i] * vec[i]; } const norm = Math.sqrt(sumSq); if (norm === 0) return; for (let i = 0; i < vec.length; i++) { vec[i] /= norm; } } // ─── Model resolution & download ──────────────────────────────────────────── /** * Find an existing model file or download it. */ async function resolveModelPath(dir, modelName) { const config = MODELS[modelName]; if (!config) { throw new Error(`Unknown model: ${modelName}. Available: ${Object.keys(MODELS).join(', ')}`); } mkdirSync(dir, { recursive: true }); // Check for existing file (hf-prefixed name from node-llama-cpp, or bare name) const hfName = `hf_${config.repo.replace('/', '_')}_${config.file}`; const hfPath = path.join(dir, hfName); if (existsSync(hfPath)) return hfPath; const barePath = path.join(dir, config.file); if (existsSync(barePath)) return barePath; // Scan for any matching .gguf const stem = config.file.replace('.gguf', ''); for (const entry of readdirSync(dir)) { if (entry.endsWith('.gguf') && entry.includes(stem)) { return path.join(dir, entry); } } // Download from Hugging Face return downloadModel(dir, modelName); } /** * A `.downloading` lock untouched for this long is considered abandoned and is * reclaimed. An active download keeps the lock file's mtime fresh — the bytes * stream into it — so only a dead owner (SIGKILL / OOM / power loss mid-download) * leaves it unmoving. */ const LOCK_STALE_MS = 60_000; /** Total time a worker will wait on other workers before giving up. */ const DOWNLOAD_WAIT_TIMEOUT_MS = 300_000; /** * Download a model from Hugging Face. * * Cross-worker coordination: exclusive creation of `<file>.downloading` elects * one downloader; the rest poll. A lock whose owner died is reclaimed after * `LOCK_STALE_MS`, and a waiter whose winner failed (lock vanished, no final * file) retakes the lock and retries rather than timing out. */ export async function downloadModel(dir, modelName = 'nomic-embed-text') { const config = MODELS[modelName]; if (!config) { throw new Error(`Unknown model: ${modelName}. Available: ${Object.keys(MODELS).join(', ')}`); } mkdirSync(dir, { recursive: true }); const destPath = path.join(dir, config.file); // Already downloaded if (existsSync(destPath)) return destPath; const tmpPath = destPath + '.downloading'; const deadline = Date.now() + DOWNLOAD_WAIT_TIMEOUT_MS; while (true) { reclaimStaleLock(tmpPath); // Try to exclusively create the temp file — only one worker wins let lockHandle; try { lockHandle = await open(tmpPath, 'wx'); } catch { // Another worker is downloading — wait for it to finish or fail console.log(`[harper-fabric-embeddings] Waiting for another worker to finish downloading ${config.file}...`); const outcome = await waitForDownload(destPath, tmpPath, deadline); if (outcome === 'complete') return destPath; continue; // lock vanished or went stale without a final file — take over } // We won the lock — download the model try { console.log(`[harper-fabric-embeddings] Downloading ${config.file} from Hugging Face...`); await lockHandle.close(); const url = `https://huggingface.co/${config.repo}/resolve/main/${config.file}`; // HuggingFace 403s anonymous large-file (CDN) downloads in some // environments; a free account token clears it. undici strips the // Authorization header on the cross-origin redirect to the CDN, so the // token never reaches the signed-URL host. const token = process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN; const response = await fetch(url, { redirect: 'follow', headers: token ? { authorization: `Bearer ${token}` } : undefined, }); if (!response.ok) { const hint = response.status === 403 && !token ? ' (HuggingFace may require authentication for large-file downloads — set HF_TOKEN)' : ''; throw new Error(`Download failed: ${response.status} ${response.statusText} — ${url}${hint}`); } const fileStream = createWriteStream(tmpPath); await pipeline(response.body, fileStream); // Rename to final path (atomic on same filesystem) await rename(tmpPath, destPath); console.log(`[harper-fabric-embeddings] Downloaded ${config.file} to ${destPath}`); } catch (err) { await unlink(tmpPath).catch(() => { }); throw err; } return destPath; } } /** Remove a `.downloading` lock whose owner died (no mtime movement for `LOCK_STALE_MS`). */ function reclaimStaleLock(tmpPath) { try { if (Date.now() - statSync(tmpPath).mtimeMs > LOCK_STALE_MS) { console.warn(`[harper-fabric-embeddings] Reclaiming stale download lock ${tmpPath}`); unlinkSync(tmpPath); } } catch { // No lock file (or it vanished between stat and unlink) — nothing to reclaim. } } /** * Poll until the download completes (`'complete'`), the lock disappears or goes * stale without a final file (`'retry'` — the caller retakes the lock), or the * deadline passes (throws). */ async function waitForDownload(destPath, tmpPath, deadline) { while (true) { if (existsSync(destPath)) return 'complete'; let lockAlive; try { lockAlive = Date.now() - statSync(tmpPath).mtimeMs <= LOCK_STALE_MS; } catch { // Lock gone: the winner either renamed to destPath (check again) or failed. return existsSync(destPath) ? 'complete' : 'retry'; } if (!lockAlive) return 'retry'; if (Date.now() > deadline) { throw new Error(`Timed out waiting for model download: ${destPath}`); } await sleep(500); } } /** * Find the llama-addon.node binary from installed platform packages. * * Scans node_modules on the filesystem rather than using require.resolve, * since Harper's sandbox blocks node:module. */ function findAddonBinary() { const candidates = [ '@node-llama-cpp/linux-x64', '@node-llama-cpp/mac-arm64-metal', '@node-llama-cpp/mac-x64', '@node-llama-cpp/linux-arm64', ]; // fileURLToPath, not URL.pathname: pathname is percent-encoded (spaces → %20) // and carries a broken leading slash on Windows, so existsSync would miss. const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const searchRoots = [ path.join(process.cwd(), 'node_modules'), // Sibling packages in the same node_modules as harper-fabric-embeddings path.resolve(moduleDir, '../..'), // harper-fabric-embeddings own nested node_modules (hoisted installs) path.resolve(moduleDir, '..', 'node_modules'), ]; console.log(`[harper-fabric-embeddings] findAddonBinary: cwd=${process.cwd()}, searching ${searchRoots.length} roots`); for (const nmDir of searchRoots) { if (!existsSync(nmDir)) { console.log(`[harper-fabric-embeddings] findAddonBinary: skip ${nmDir} (not found)`); continue; } for (const pkg of candidates) { const binsDir = path.join(nmDir, pkg, 'bins'); if (!existsSync(binsDir)) { console.log(`[harper-fabric-embeddings] findAddonBinary: skip ${binsDir} (not found)`); continue; } for (const entry of readdirSync(binsDir)) { const addonPath = path.join(binsDir, entry, 'llama-addon.node'); if (existsSync(addonPath)) { console.log(`[harper-fabric-embeddings] findAddonBinary: found ${addonPath}`); return addonPath; } } } } throw new Error('No llama-addon.node binary found. Install a @node-llama-cpp platform package ' + '(e.g., @node-llama-cpp/linux-x64).'); } /** * Load the native addon via process.dlopen. * * On Linux, dlopen of the same path returns the same handle (OS deduplicates * by inode), so all worker threads share the same native .so in memory. * This is safe because llama.cpp stores all state in heap-allocated objects * (LlamaModel, LlamaContext) rather than C++ globals — each thread operates * on its own object instances through the shared native code. */ function loadAddon(addonPath) { const mod = { exports: {} }; process.dlopen(mod, addonPath); return mod.exports; } //# sourceMappingURL=engine.js.map