diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml
index d6384d0..f4c1c17 100644
--- a/.gitea/workflows/ci.yml
+++ b/.gitea/workflows/ci.yml
@@ -105,6 +105,8 @@ jobs:
exit 1
fi
+ echo "Testing QVAC enable over native messaging"
+ python3 scripts/qvac-packed-selftest.py "$BIN"
echo "PASS: smoke test complete"
- name: Generate checksums
diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 97bbf3b..da831cf 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -72,6 +72,9 @@ jobs:
exit 1
fi
+ echo "Testing QVAC enable over native messaging"
+ python3 scripts/qvac-packed-selftest.py "$BIN"
+
- name: List release artifacts
run: |
echo "=== releases/ ==="
diff --git a/extension/dashboard.js b/extension/dashboard.js
index 2392e43..2469ba7 100644
--- a/extension/dashboard.js
+++ b/extension/dashboard.js
@@ -280,7 +280,7 @@ function renderCapabilitiesPage(state) {
Available${q.available ? 'yes' : 'no'}
Device${escapeHtml(q.device || '—')}
Backend${escapeHtml(q.backend || q.backendId || '—')}
- GPU${escapeHtml(q.deviceName || '—')}
+ GPU${escapeHtml(q.deviceName || (q.vram ? String(q.vram) : '—'))}
Model${escapeHtml(q.friendlyId || q.modelId || 'none')}
Plugins${escapeHtml((q.plugins || []).join(', ') || '—')}
OpenAI${escapeHtml(q.openaiUrl || 'off')}
diff --git a/native-host/index.mjs b/native-host/index.mjs
index e768f04..f046f84 100644
--- a/native-host/index.mjs
+++ b/native-host/index.mjs
@@ -33,6 +33,7 @@ import {
} from './register-default-packs.mjs';
import _messenger from './messenger.js';
import _host from './host.js';
+import selftest from './qvac/selftest.js';
installConsoleToStderr();
normalizeBareVersions();
@@ -49,6 +50,8 @@ if (process.argv.includes('--extract-addons')) {
await warmDefaultModules();
await extractAddons(DEFAULT_ADDON_PACKAGES);
})();
+} else if (process.argv.includes('--qvac-selftest') || process.env.BRIDGE_SWARM_QVAC_SELFTEST === '1') {
+ selftest.runQvacSelftest(logErr, normalizeBareVersions);
} else {
try {
startHost(_messenger, _host);
diff --git a/native-host/qvac/device.js b/native-host/qvac/device.js
index 9d1933b..dc5e75e 100644
--- a/native-host/qvac/device.js
+++ b/native-host/qvac/device.js
@@ -44,7 +44,7 @@ function backendLabel(resources) {
if (process.platform === 'darwin' || drivers.metal) {
return { backend: 'metal', backendId: 1, deviceName: gpuName, vram };
}
- if (drivers.vulkan || process.platform === 'linux' || process.platform === 'win32') {
+ if (drivers.vulkan || hasGpu(resources)) {
return { backend: 'vulkan', backendId: 3, deviceName: gpuName, vram };
}
return { backend: 'cpu', backendId: 0, deviceName: gpuName, vram };
diff --git a/native-host/qvac/engine.js b/native-host/qvac/engine.js
index c5e1077..f7b0406 100644
--- a/native-host/qvac/engine.js
+++ b/native-host/qvac/engine.js
@@ -12,6 +12,7 @@ const { ensureQvacRoot } = require('../capabilities/paths.js');
const catalog = require('./catalog.js');
const device = require('./device.js');
const events = require('./events.js');
+const llamacppHost = require('./llamacpp-host.js');
const SKIP = process.env.BRIDGE_SWARM_SKIP_QVAC === '1';
@@ -68,7 +69,10 @@ function setUserEnabled(on) {
if (userEnabled === next) return;
userEnabled = next;
initPromise = null;
- if (!next) initError = 'disabled';
+ if (!next) {
+ initError = 'disabled';
+ lastResources = null;
+ }
}
function disabledError() {
@@ -108,6 +112,11 @@ async function assembleSdk() {
return null;
}
normalizeBareVersions();
+ try {
+ llamacppHost.patchLlamaInterface(log);
+ } catch (err) {
+ log('llm backends patch skipped: ' + ((err && err.message) || err));
+ }
ensureQvacRoot();
if (!process.env.QVAC_CONFIG_PATH) {
const cfg = path.join(__dirname, '..', 'qvac.config.json');
@@ -120,14 +129,24 @@ async function assembleSdk() {
await import('@qvac/fabric');
} catch (_) {}
- const infMain = await tryImport('@qvac/inference/plugins');
+ let infMain;
+ try {
+ infMain = await import('@qvac/inference/plugins');
+ } catch (err) {
+ infMain = { __error: err };
+ }
if (infMain.__error) {
initError = infMain.__error.message || String(infMain.__error);
log('inference plugins unavailable: ' + initError);
return null;
}
- const llmMod = await tryImport('@qvac/inference/llamacpp-completion/plugin');
+ let llmMod;
+ try {
+ llmMod = await import('@qvac/inference/llamacpp-completion/plugin');
+ } catch (err) {
+ llmMod = { __error: err };
+ }
if (llmMod.__error) {
initError = llmMod.__error.message || String(llmMod.__error);
log('llm plugin unavailable: ' + initError);
@@ -182,9 +201,14 @@ function ensureInit() {
if (initPromise) return initPromise;
normalizeBareVersions();
initPromise = assembleSdk()
- .then((s) => {
+ .then(async (s) => {
sdk = s;
if (!s) initPromise = null;
+ else {
+ try {
+ await probeHardware(s);
+ } catch (_) {}
+ }
return s;
})
.catch((err) => {
@@ -195,6 +219,8 @@ function ensureInit() {
}
function publicStatus() {
+ const hw = lastResources || {};
+ const modelLoaded = !!loaded.modelId;
return {
enabled: isUserEnabled(),
available: isUserEnabled() && !!sdk && !skipped(),
@@ -206,11 +232,11 @@ function publicStatus() {
constant: loaded.constant,
modelType: loaded.modelType,
tools: loaded.tools,
- device: loaded.device,
- backend: loaded.backend,
- backendId: loaded.backendId,
- deviceName: loaded.deviceName,
- vram: loaded.vram,
+ device: modelLoaded ? loaded.device : hw.deviceDefault || loaded.device,
+ backend: loaded.backend || hw.backend || null,
+ backendId: loaded.backendId != null ? loaded.backendId : hw.backendId,
+ deviceName: loaded.deviceName || hw.deviceName || null,
+ vram: loaded.vram || hw.vram || null,
ctxSize: loaded.ctxSize,
platform: process.platform,
arch: process.arch,
@@ -230,8 +256,13 @@ const pickDevice = device.pickDevice;
const backendLabel = device.backendLabel;
async function probeGpuInfo() {
- const gpuMod = await tryImport('bare-gpu-info');
- if (gpuMod.__error) return null;
+ let gpuMod;
+ try {
+ gpuMod = await import('bare-gpu-info');
+ } catch (err) {
+ log('bare-gpu-info unavailable: ' + ((err && err.message) || err));
+ return null;
+ }
try {
const GPUInfo = gpuMod.default || gpuMod.GPUInfo || gpuMod;
if (typeof GPUInfo !== 'function') return null;
@@ -243,19 +274,18 @@ async function probeGpuInfo() {
if (typeof info.destroy === 'function') info.destroy();
} catch (_) {}
return { gpus, drivers, length };
- } catch (_) {
+ } catch (err) {
+ log('gpu probe failed: ' + ((err && err.message) || err));
return null;
}
}
-async function resources(opts) {
- if (!isUserEnabled()) throw disabledError();
- const s = await ensureInit();
+async function probeHardware(s) {
const ram = totalmem();
let qvacRes = null;
if (s && typeof s.getSystemResources === 'function') {
try {
- qvacRes = await s.getSystemResources({ sample: !!(opts && opts.sample) });
+ qvacRes = await s.getSystemResources({ sample: false });
} catch (err) {
qvacRes = { error: err.message };
}
@@ -265,6 +295,7 @@ async function resources(opts) {
if (gpuProbe && gpuProbe.gpus && gpuProbe.gpus[0] && gpuProbe.gpus[0].memory) {
merged.vram = gpuProbe.gpus[0].memory;
}
+ const bl = backendLabel(merged);
lastResources = {
totalRamBytes: ram,
capabilities: qvacRes && qvacRes.capabilities,
@@ -274,11 +305,31 @@ async function resources(opts) {
drivers: gpuProbe && gpuProbe.drivers,
suggest: catalog.suggestProfile({ totalRamBytes: ram, vramBytes: merged.vram }),
deviceDefault: pickDevice('auto', merged).device,
- ...backendLabel(merged),
+ ...bl,
};
+ log(
+ 'hardware ' +
+ lastResources.deviceDefault +
+ '/' +
+ (lastResources.backend || 'none') +
+ (lastResources.deviceName ? ' ' + lastResources.deviceName : '')
+ );
return lastResources;
}
+async function resources(opts) {
+ if (!isUserEnabled()) throw disabledError();
+ const s = await ensureInit();
+ if (opts && opts.sample && s && typeof s.getSystemResources === 'function') {
+ try {
+ const qvacRes = await s.getSystemResources({ sample: true });
+ if (lastResources) lastResources.sample = qvacRes && qvacRes.sample;
+ } catch (_) {}
+ }
+ if (lastResources && !opts) return lastResources;
+ return probeHardware(s);
+}
+
async function resolveSrc(s, modelSrc) {
if (modelSrc && typeof modelSrc === 'object') return modelSrc;
const constant = catalog.resolveModelConstant(modelSrc);
@@ -307,7 +358,7 @@ async function load(opts, onProgress) {
const entry = catalog.findCatalogEntry(opts.model || opts.modelSrc || opts.friendlyId);
const modelSrc = await resolveSrc(s, opts.modelSrc || (entry && entry.constant) || opts.model);
const res = lastResources || (await resources({ sample: false }));
- const dev = pickDevice(opts.device || 'auto', res);
+ let dev = pickDevice(opts.device || 'auto', res);
const tools = opts.tools !== false && (!entry || entry.tools !== false);
const ctxSize = opts.ctxSize || opts.ctx_size || (entry && entry.ctxSize) || 8192;
const modelConfig = Object.assign(
@@ -339,8 +390,42 @@ async function load(opts, onProgress) {
}
if (opts.delegate) loadOpts.delegate = opts.delegate;
- const modelId = await s.loadModel(loadOpts);
- const bl = backendLabel(res);
+ log(
+ 'load ' +
+ ((entry && entry.id) || opts.model || 'model') +
+ ' device=' +
+ modelConfig.device +
+ ' ngl=' +
+ modelConfig.gpu_layers +
+ ' ctx=' +
+ modelConfig.ctx_size
+ );
+
+ let modelId;
+ try {
+ modelId = await s.loadModel(loadOpts);
+ } catch (err) {
+ const msg = llamacppHost.flattenError(err);
+ log('load failed: ' + msg);
+ const requested = String(opts.device || 'auto').toLowerCase();
+ if (dev.device === 'gpu' && requested !== 'cpu') {
+ log('retrying load on cpu');
+ modelConfig.device = 'cpu';
+ modelConfig.gpu_layers = 0;
+ try {
+ modelId = await s.loadModel(loadOpts);
+ dev = { device: 'cpu', gpu_layers: 0, fallback: 'cpu' };
+ } catch (err2) {
+ throw new Error(llamacppHost.flattenError(err2));
+ }
+ } else {
+ throw new Error(msg);
+ }
+ }
+ const bl =
+ dev.device === 'cpu'
+ ? Object.assign({}, backendLabel(res), { backend: 'cpu', backendId: 0 })
+ : backendLabel(res);
loaded = {
modelId,
friendlyId: (entry && entry.id) || opts.model || null,
@@ -549,6 +634,7 @@ async function close() {
}
sdk = null;
initPromise = null;
+ lastResources = null;
}
function setIdleUnloadMin(min) {
diff --git a/native-host/qvac/llamacpp-host.js b/native-host/qvac/llamacpp-host.js
new file mode 100644
index 0000000..ab5f55e
--- /dev/null
+++ b/native-host/qvac/llamacpp-host.js
@@ -0,0 +1,122 @@
+/**
+ * Packed Bare extracts the llama addon .bare + backend .so files under /tmp,
+ * but addon.js still sets backendsDir from bundled __dirname (`bare:/...`).
+ * C++ cannot dlopen that URL; llama.cpp then runs --fit against a broken
+ * backend path and init fails with "Failed to initialize model".
+ *
+ * Point backendsDir at the extracted prebuilds directory before createInstance.
+ */
+
+function posixDirname(p) {
+ const n = String(p || '').replace(/\\/g, '/').replace(/\/+$/, '');
+ const i = n.lastIndexOf('/');
+ if (i < 0) return n;
+ if (i === 0) return '/';
+ return n.slice(0, i);
+}
+
+function flattenError(err) {
+ const parts = [];
+ let cur = err;
+ for (let n = 0; cur && n < 6; n++) {
+ const msg = cur && cur.message ? String(cur.message) : cur ? String(cur) : '';
+ if (msg && parts.indexOf(msg) === -1) parts.push(msg);
+ cur = cur && cur.cause;
+ }
+ return parts.join(' | ') || 'unknown error';
+}
+
+function hrefOf(url) {
+ if (!url) return '';
+ if (typeof url === 'string') return url;
+ if (typeof url.href === 'string') return url.href;
+ try {
+ return String(url);
+ } catch (_) {
+ return '';
+ }
+}
+
+function fileUrlToPath(href) {
+ if (!href || href.indexOf('file:') !== 0) return null;
+ try {
+ const url = require('bare-url');
+ if (url && typeof url.fileURLToPath === 'function') return url.fileURLToPath(href);
+ } catch (_) {}
+ let rest = href.replace(/^file:\/\//, '');
+ if (/^\/[A-Za-z]:\//.test(rest)) rest = rest.slice(1);
+ try {
+ rest = decodeURIComponent(rest);
+ } catch (_) {}
+ return rest;
+}
+
+/**
+ * addon.js uses path.join(packageRoot, 'prebuilds').
+ * Extracted .bare lives at prebuilds//qvac__llm-llamacpp.bare
+ */
+function backendsDirFromAddonHref(href) {
+ const raw = hrefOf(href);
+ if (!raw || !/qvac__llm-llamacpp|llm-llamacpp/i.test(raw)) return null;
+ const file = raw.indexOf('file:') === 0 ? fileUrlToPath(raw) : raw.indexOf('bare:') === 0 ? null : raw;
+ if (!file) return null;
+ const base = file.replace(/\\/g, '/');
+ if (!/\.(bare|node)$/i.test(base)) return null;
+ return posixDirname(posixDirname(file));
+}
+
+function resolveBackendsDir() {
+ try {
+ const cache = typeof Bare !== 'undefined' && Bare.Addon && Bare.Addon.cache;
+ if (cache) {
+ const hrefs = Object.keys(cache);
+ for (let i = 0; i < hrefs.length; i++) {
+ const dir = backendsDirFromAddonHref(hrefs[i]);
+ if (dir) return dir;
+ const addon = cache[hrefs[i]];
+ const fromUrl = backendsDirFromAddonHref(addon && addon.url);
+ if (fromUrl) return fromUrl;
+ }
+ }
+ } catch (_) {}
+ return null;
+}
+
+function patchLlamaInterface(logFn) {
+ const log = typeof logFn === 'function' ? logFn : function () {};
+ let addon;
+ try {
+ addon = require('@qvac/llm-llamacpp/addon.js');
+ } catch (err) {
+ log('llm addon.js unavailable: ' + ((err && err.message) || err));
+ return false;
+ }
+ const Orig = addon && addon.LlamaInterface;
+ if (!Orig) return false;
+ if (Orig.__bsBackendsPatched) return true;
+ function Wrapped(binding, configurationParams, outputCb) {
+ if (!configurationParams) configurationParams = {};
+ if (!configurationParams.config) configurationParams.config = {};
+ const dir = resolveBackendsDir();
+ if (dir) {
+ configurationParams.config.backendsDir = dir;
+ log('llm backendsDir ' + dir);
+ }
+ return new Orig(binding, configurationParams, outputCb);
+ }
+ Wrapped.prototype = Orig.prototype;
+ try {
+ Object.setPrototypeOf(Wrapped, Orig);
+ } catch (_) {}
+ Wrapped.__bsBackendsPatched = true;
+ Orig.__bsBackendsPatched = true;
+ addon.LlamaInterface = Wrapped;
+ return true;
+}
+
+module.exports = {
+ flattenError,
+ backendsDirFromAddonHref,
+ resolveBackendsDir,
+ patchLlamaInterface,
+};
diff --git a/native-host/qvac/selftest.js b/native-host/qvac/selftest.js
new file mode 100644
index 0000000..f83ed11
--- /dev/null
+++ b/native-host/qvac/selftest.js
@@ -0,0 +1,21 @@
+function runQvacSelftest(logErr, normalizeBareVersions) {
+ logErr('qvac-selftest starting');
+ try {
+ normalizeBareVersions();
+ } catch (err) {
+ logErr('qvac-selftest normalize fail: ' + ((err && err.message) || err));
+ }
+ logErr('qvac-selftest importing plugins');
+ import('@qvac/inference/plugins')
+ .then((mod) => {
+ logErr('qvac-selftest ok plugins=' + typeof (mod.plugins || mod.default));
+ process.exit(0);
+ })
+ .catch((err) => {
+ const msg = String((err && err.message) || err);
+ logErr('qvac-selftest fail: ' + msg);
+ process.exit(/INVALID_VERSION/i.test(msg) ? 1 : 0);
+ });
+}
+
+module.exports = { runQvacSelftest };
diff --git a/native-host/qvac/semver-patch.js b/native-host/qvac/semver-patch.js
index 7d1cddb..1fb7842 100644
--- a/native-host/qvac/semver-patch.js
+++ b/native-host/qvac/semver-patch.js
@@ -15,13 +15,6 @@ function concreteVersion(input) {
return m ? m[1] : input;
}
-function coerceEnginesMap(engines) {
- if (!engines || typeof engines !== 'object') return engines;
- const out = {};
- for (const key of Object.keys(engines)) out[key] = concreteVersion(engines[key]);
- return out;
-}
-
function log(msg) {
try {
process.stderr.write('[bridge-swarm-qvac] ' + msg + '\n');
@@ -70,24 +63,6 @@ function normalizeBareVersions() {
} catch (_) {}
}
-function patchBareModule() {
- try {
- const Module = typeof module !== 'undefined' && module.constructor;
- if (!Module) return;
- const wrap = (name) => {
- const orig = Module[name];
- if (typeof orig !== 'function' || orig.__bsPatched) return;
- Module[name] = function patchedModuleFn() {
- normalizeBareVersions();
- return orig.apply(this, arguments);
- };
- Module[name].__bsPatched = true;
- };
- wrap('resolve');
- wrap('asset');
- } catch (_) {}
-}
-
function install() {
try {
const semver = require('bare-semver');
@@ -103,17 +78,16 @@ function install() {
try {
const resolve = require('bare-module-resolve');
if (typeof resolve.validateEngines === 'function' && !resolve.validateEngines.__bsPatched) {
- const orig = resolve.validateEngines;
- resolve.validateEngines = function patchedValidateEngines(packageURL, packageEngines, opts) {
- const next = Object.assign({}, opts || {});
- if (next.engines) next.engines = coerceEnginesMap(next.engines);
- return orig(packageURL, packageEngines, next);
+ resolve.validateEngines = function patchedValidateEngines() {
+ // Packed Bare.versions.bare can be a range; skip engines checks.
+ return;
};
resolve.validateEngines.__bsPatched = true;
}
} catch (_) {}
- patchBareModule();
+ // Do not wrap Module.resolve: the packed runtime's resolver is inside
+ // bare:/bare.bundle and wrapping module.constructor.resolve can stall imports.
normalizeBareVersions();
}
diff --git a/native-host/register-default-packs.mjs b/native-host/register-default-packs.mjs
index d2c790a..090fe15 100644
--- a/native-host/register-default-packs.mjs
+++ b/native-host/register-default-packs.mjs
@@ -87,6 +87,15 @@ export async function warmDefaultModules() {
try {
await import('@qvac/inference');
} catch (_) {}
+ try {
+ await import('@qvac/inference/plugins');
+ } catch (_) {}
+ try {
+ await import('@qvac/inference/llamacpp-completion/plugin');
+ } catch (_) {}
+ try {
+ await import('@qvac/inference/models');
+ } catch (_) {}
try {
await import('bare-gpu-info');
} catch (_) {}
diff --git a/native-host/test-bare.js b/native-host/test-bare.js
index 7e7383b..97bd628 100644
--- a/native-host/test-bare.js
+++ b/native-host/test-bare.js
@@ -154,7 +154,6 @@ async function testBareVersionsCoerce() {
const { satisfies } = require('bare-semver');
ok(satisfies('^1.30.3', '^1.30.3') === true, 'satisfies coerces caret runtime version');
- ok(!!(module.constructor.resolve && module.constructor.resolve.__bsPatched), 'runtime Module.resolve wrapped');
Object.defineProperty(Bare, 'versions', {
configurable: true,
diff --git a/scripts/build-distributable.js b/scripts/build-distributable.js
index bfd58ff..c699e9b 100644
--- a/scripts/build-distributable.js
+++ b/scripts/build-distributable.js
@@ -164,6 +164,7 @@ function patchBundle(bundle) {
}
let jsonFixed = 0;
+ let enginesStripped = 0;
let keysToProcess = typeof bundle.keys === 'function' ? [...bundle.keys()] : Object.keys(bundle.files || {});
for (const key of keysToProcess) {
if (!key.endsWith('.json')) continue;
@@ -195,6 +196,18 @@ function patchBundle(bundle) {
}
}
if (content && content.length > 0) {
+ const keyNormSlash = key.replace(/\\/g, '/');
+ if (/package\.json$/i.test(keyNormSlash)) {
+ try {
+ const json = JSON.parse(content.toString());
+ if (json && json.engines) {
+ delete json.engines;
+ content = Buffer.from(JSON.stringify(json));
+ bundle.write(key, content);
+ enginesStripped++;
+ }
+ } catch (_) {}
+ }
const keyNoLead = key.replace(/^\/+/, '');
const prefixSlash = 'runtime.bundle/' + keyNoLead;
const prefixLead = '/runtime.bundle/' + keyNoLead;
@@ -207,6 +220,11 @@ function patchBundle(bundle) {
void resolveKey;
if (jsonFixed > 0) console.log(` Patched ${jsonFixed} empty/invalid .json entries`);
+ if (enginesStripped > 0) {
+ console.log(
+ ` Stripped engines from ${enginesStripped} package.json files (packed Bare.versions.bare can be a range)`
+ );
+ }
console.log(' Added runtime.bundle pathname variants for .json entries');
}
diff --git a/scripts/qvac-packed-selftest.py b/scripts/qvac-packed-selftest.py
new file mode 100755
index 0000000..b7137f8
--- /dev/null
+++ b/scripts/qvac-packed-selftest.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+"""Send qvac.setEnabled over native messaging and fail on INVALID_VERSION."""
+import json
+import os
+import struct
+import subprocess
+import sys
+import threading
+import time
+
+
+def main():
+ binpath = sys.argv[1]
+ env = os.environ.copy()
+ env.setdefault('BRIDGE_SWARM_STORAGE', '/tmp/bs-qvac-packed-selftest')
+ proc = subprocess.Popen(
+ [binpath],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ env=env,
+ )
+ err_chunks = []
+
+ def pump_err():
+ while True:
+ line = proc.stderr.readline()
+ if not line:
+ break
+ err_chunks.append(line)
+ sys.stderr.buffer.write(line)
+ sys.stderr.flush()
+
+ threading.Thread(target=pump_err, daemon=True).start()
+ time.sleep(1.5)
+ msg = json.dumps(
+ {
+ 'id': 1,
+ 'type': 'capability',
+ 'payload': {
+ 'pack': 'qvac',
+ 'cmd': 'setEnabled',
+ 'payload': {'enabled': True},
+ },
+ }
+ ).encode()
+ proc.stdin.write(struct.pack('