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('