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.
250 lines • 10.8 kB
JavaScript
/**
* harper-fabric-embeddings
*
* Minimal llama.cpp embedding wrapper for Harper Fabric. Talks directly to
* the `@node-llama-cpp` native N-API addon — no build tools, no CLI, no chat
* wrappers, no model downloaders beyond a simple HuggingFace fetch.
*
* ~19 MB installed (native binary only) vs ~250 MB+ for node-llama-cpp.
*
* Three ways in:
* - Raw API — `init` / `embed` / `embedBatch` / `dimensions` / `dispose`
* against a module-level default engine (what harper-kb consumes).
* - Harper sub-component — `handleApplication(scope)` when loaded via
* `package:` in a parent component's config.yaml.
* - Harper models backend — the `register` factory, invoked by Harper's
* models bootstrap for `backend: harper-fabric-embeddings` config entries.
* Wires the engine into `models.embed()`, `@embed` table directives, and
* model-call analytics.
*/
import path from 'node:path';
import { EmbeddingEngine } from './engine.js';
export { EmbeddingEngine, decodeAndEmbed, downloadModel, renderTemplate, resolveEngineTemplates, validateTemplates, } from './engine.js';
export { assertDeclaredPooling, readGgufPooling, POOLING_NAMES } from './gguf.js';
// ─── Module-level default engine (raw API) ──────────────────────────────────
let defaultEngine = null;
let defaultInit = null;
/**
* Initialize the default embedding engine. Call once before using `embed()`.
*
* Provide either `modelPath` (absolute path to a .gguf file) or
* `modelsDir` (directory to search/download into) + optional `modelName`.
*
* Safe to call concurrently — concurrent callers share the same initialization.
* A failed attempt resets so the next call retries.
*/
export async function init(options) {
if (defaultInit)
return defaultInit;
const attempt = (async () => {
const engine = new EmbeddingEngine(options);
await engine.ensureReady();
defaultEngine = engine;
})();
defaultInit = attempt;
try {
await attempt;
}
catch (err) {
if (defaultInit === attempt)
defaultInit = null;
throw err;
}
}
function requireDefaultEngine() {
if (!defaultEngine)
throw new Error('Not initialized. Call init() first.');
return defaultEngine;
}
/**
* Generate an L2-normalized embedding vector for the given text.
*
* Calls are serialized internally — concurrent callers wait in queue
* rather than hitting the llama.cpp context simultaneously.
*/
export async function embed(text) {
const { vectors } = await requireDefaultEngine().embedMany([text]);
const vector = vectors[0];
return vector ? Array.from(vector) : [];
}
/**
* Generate embedding vectors for multiple texts.
*
* More efficient than calling embed() in a loop — texts are processed
* sequentially through the native context without queue overhead per item.
*/
export async function embedBatch(texts) {
const { vectors } = await requireDefaultEngine().embedMany(texts);
return vectors.map((vector) => Array.from(vector));
}
/**
* Get the embedding vector dimensionality.
*/
export function dimensions() {
if (!defaultEngine)
throw new Error('Not initialized. Call init() first.');
return defaultEngine.dimensions();
}
/**
* Clean up native resources for the default engine.
*/
export async function dispose() {
defaultInit = null;
const engine = defaultEngine;
defaultEngine = null;
if (engine)
await engine.dispose();
}
// ─── Harper sub-component plugin entry point ────────────────────────────────
/**
* Harper plugin hook — called on each worker thread when loaded as a
* sub-component via `package:` in the parent's config.yaml.
*
* Reads config from scope.options, initializes the GGUF engine, and
* handles close/change events for cleanup and hot-reload.
*
* Uses the module-level default engine: two sub-components loading this
* package in the same worker share one engine (the second's config is
* ignored, and either's close tears it down for both). When you need
* per-entry engines, use the models-backend `register` path instead.
*
* Config options (in parent config.yaml):
* modelName — model from the built-in registry (default: nomic-embed-text)
* modelsDir — override models directory (default: <plugin dir>/models)
* contextSize — token context window size
* batchSize — batch processing size
* threads — CPU threads for inference
* gpuLayers — layers to offload to GPU (0 = CPU only)
* addonPath — override path to llama-addon.node
* pooling — expected pooling declared by the model file (verified at init)
*/
export async function handleApplication(scope) {
function resolveConfig() {
// Harper's `scope.options` is an OptionsWatcher (EventEmitter); config values
// are NOT exposed as direct properties — they live behind `.getAll()` /
// `.get([key])`. Without this, every config.yaml override (modelName,
// batchSize, contextSize, threads, gpuLayers, addonPath) silently falls
// through to defaults. Fall back to direct property access if a caller
// passes a plain-object scope (tests, non-Harper hosts).
const opts = typeof scope.options.getAll === 'function' ? scope.options.getAll() : scope.options;
return {
modelsDir: opts.modelsDir || path.join(scope.directory, 'models'),
modelName: opts.modelName || 'nomic-embed-text',
contextSize: toFiniteNumber(opts.contextSize, 'contextSize'),
batchSize: toFiniteNumber(opts.batchSize, 'batchSize'),
threads: toFiniteNumber(opts.threads, 'threads'),
gpuLayers: toFiniteNumber(opts.gpuLayers, 'gpuLayers'),
addonPath: opts.addonPath,
templates: opts.templates,
pooling: opts.pooling,
};
}
// Await init so the model is ready before Harper routes requests to this worker
await init(resolveConfig());
scope.on('close', () => {
dispose().catch((err) => {
console.error('[harper-fabric-embeddings] Error during dispose:', err.message);
});
});
scope.options.on('change', () => {
dispose()
.then(() => init(resolveConfig()))
.catch((err) => {
console.error('[harper-fabric-embeddings] Failed to re-initialize after config change:', err.message);
});
});
}
/**
* Harper models-backend factory. Harper's `bootstrapModels` imports this
* package when a `models:` config entry names it and invokes this export
* (a named `register` export is probed before the default export):
*
* models:
* embedding:
* default:
* backend: harper-fabric-embeddings
* modelName: nomic-embed-text
* modelsDir: ./models
*
* `modelsDir` (or `modelPath`) is required — there is no instance-root default
* on this path. `model` is accepted as an alias for `modelName`, matching the
* field Harper's built-in backends use.
*
* Registration is fast-boot: model resolution / download / load kicks off in
* the background here and the FIRST embed call awaits it (a failed attempt
* retries on the next call). Misconfiguration — wrong kind, missing model
* source, unknown model name — throws right here so Harper's bootstrap logs
* and skips the entry at boot instead of surfacing at first use.
*
* Returns the engine so tests and advanced callers can dispose it; Harper
* ignores the return value.
*/
export async function register({ logicalName, kind, config }) {
if (kind !== 'embedding') {
throw new Error(`harper-fabric-embeddings is an embedding backend; cannot register models.${kind}.${logicalName}`);
}
const models = globalThis.models;
if (typeof models?.registerBackend !== 'function' || typeof models?.defineBackend !== 'function') {
throw new Error('global `models` API not available — harper-fabric-embeddings requires a Harper version with model-backend support');
}
const engine = new EmbeddingEngine(engineOptionsFromConfig(config));
// Fast boot: start the model load/download now, but don't block Harper boot
// on it — the first embed call awaits readiness instead.
engine.ensureReady().catch((err) => {
console.error(`[harper-fabric-embeddings] models.embedding.${logicalName}: model init failed (will retry on first embed): ${err.message}`);
});
models.registerBackend('embedding', logicalName, models.defineBackend({
name: `fabric-embeddings:${engine.modelIdentity}`,
embed: async (input, opts) => {
const texts = Array.isArray(input) ? input : [input];
const started = performance.now();
const { vectors, tokens } = await engine.embedMany(texts, {
inputType: opts?.inputType,
task: opts?.task,
signal: opts?.signal,
});
return {
status: 'completed',
output: vectors,
usage: { embeddingTokens: tokens, latencyMs: Math.round(performance.now() - started) },
};
},
}));
return engine;
}
export default register;
function engineOptionsFromConfig(config) {
const c = config ?? {};
return {
modelPath: c.modelPath,
modelsDir: c.modelsDir,
// `model` is the conventional per-entry field on Harper's built-in
// backends; `modelName` is this package's native option. Either works.
modelName: c.modelName ?? c.model,
contextSize: toFiniteNumber(c.contextSize, 'contextSize'),
batchSize: toFiniteNumber(c.batchSize, 'batchSize'),
threads: toFiniteNumber(c.threads, 'threads'),
gpuLayers: toFiniteNumber(c.gpuLayers, 'gpuLayers'),
addonPath: c.addonPath,
// Shape-validated by the engine constructor, so a bad block fails at
// registration (Harper logs + skips the entry at boot).
templates: c.templates,
pooling: c.pooling,
};
}
/**
* Coerce a numeric config value. YAML env-var expansion (`threads: ${THREADS}`)
* and quoted values (`"2048"`) deliver strings; these fields feed native
* constructors and the truncation math, so a non-finite value must fail at
* registration, not inside the addon.
*/
function toFiniteNumber(value, field) {
if (value === undefined || value === null || value === '')
return undefined;
const n = Number(value);
if (!Number.isFinite(n)) {
throw new Error(`${field} must be a finite number, got '${String(value)}'`);
}
return n;
}
//# sourceMappingURL=index.js.map