llm.js 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299
  1. /**
  2. * llm.ts - LLM abstraction layer for QMD using node-llama-cpp
  3. *
  4. * Provides embeddings, text generation, and reranking using local GGUF models.
  5. */
  6. import { getLlama, resolveModelFile, LlamaChatSession, LlamaLogLevel, } from "node-llama-cpp";
  7. import { homedir } from "os";
  8. import { join } from "path";
  9. import { existsSync, mkdirSync, statSync, unlinkSync, readdirSync, readFileSync, writeFileSync } from "fs";
  10. // =============================================================================
  11. // Local-LLM env-var policy (i-c28wngnd)
  12. // =============================================================================
  13. /**
  14. * Truthy values for boolean-style env vars. Mirrors the convention used by
  15. * `QMD_LLAMA_GPU` (false-style) — kept narrow so unrelated values don't flip
  16. * the disable.
  17. */
  18. const TRUTHY_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
  19. /**
  20. * Falsy / off-style values accepted by `QMD_LLAMA_GPU`.
  21. */
  22. const QMD_LLAMA_GPU_OFF_VALUES = new Set([
  23. "false", "off", "none", "disable", "disabled", "0",
  24. ]);
  25. /**
  26. * `QMD_DISABLE_LOCAL_LLM=1` opt-out: when set, `LlamaCpp.ensureLlama()`
  27. * throws on first invocation. Use for remote-only deployments where any
  28. * `getLlama()` call indicates an unintended fallback (e.g. cron host
  29. * without libvulkan-dev/glslc — issue i-c28wngnd).
  30. */
  31. export function isLocalLlmDisabled(env = process.env) {
  32. const raw = env.QMD_DISABLE_LOCAL_LLM?.trim().toLowerCase();
  33. return raw !== undefined && TRUTHY_ENV_VALUES.has(raw);
  34. }
  35. /**
  36. * Resolve the GPU mode for `getLlama()`:
  37. * 1. Explicit `QMD_LLAMA_GPU=off|none|0|...` → "cpu"
  38. * 2. Explicit `QMD_LLAMA_GPU=auto` → "auto"
  39. * 3. Auto-detect: `QMD_EMBED_ENDPOINT` set → "cpu"
  40. * (remote embed provider — embed never touches local LLM. Rerank/expand
  41. * still use prebuilt CPU binary; no Vulkan probe / cmake build.)
  42. * 4. Otherwise (legacy local-only setup) → "auto"
  43. */
  44. export function resolveLlamaGpuMode(env = process.env) {
  45. const explicit = env.QMD_LLAMA_GPU?.trim().toLowerCase();
  46. if (explicit !== undefined && explicit !== "") {
  47. if (QMD_LLAMA_GPU_OFF_VALUES.has(explicit))
  48. return "cpu";
  49. if (explicit === "auto" || explicit === "true" || explicit === "on") {
  50. return "auto";
  51. }
  52. // Unknown value — preserve legacy behavior (probe).
  53. return "auto";
  54. }
  55. // Auto-detect remote-only deployment. When QMD_EMBED_ENDPOINT is set the
  56. // embed path runs over HTTP (factory.ts resolveProviderKind), so any
  57. // local LLM access is for rerank/expand only — the prebuilt CPU binary
  58. // is sufficient and skipping the Vulkan probe avoids the ~30s cmake
  59. // attempt on hosts without libvulkan-dev/glslc.
  60. const remoteEmbed = env.QMD_EMBED_ENDPOINT?.trim();
  61. if (remoteEmbed && remoteEmbed !== "")
  62. return "cpu";
  63. return "auto";
  64. }
  65. // =============================================================================
  66. // Embedding Formatting Functions
  67. // =============================================================================
  68. /**
  69. * Detect if a model URI uses the Qwen3-Embedding format.
  70. * Qwen3-Embedding uses a different prompting style than nomic/embeddinggemma.
  71. */
  72. export function isQwen3EmbeddingModel(modelUri) {
  73. return /qwen.*embed/i.test(modelUri) || /embed.*qwen/i.test(modelUri);
  74. }
  75. /**
  76. * Format a query for embedding.
  77. * Uses nomic-style task prefix format for embeddinggemma (default).
  78. * Uses Qwen3-Embedding instruct format when a Qwen embedding model is active.
  79. */
  80. export function formatQueryForEmbedding(query, modelUri) {
  81. const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
  82. if (isQwen3EmbeddingModel(uri)) {
  83. return `Instruct: Retrieve relevant documents for the given query\nQuery: ${query}`;
  84. }
  85. return `task: search result | query: ${query}`;
  86. }
  87. /**
  88. * Format a document for embedding.
  89. * Uses nomic-style format with title and text fields (default).
  90. * Qwen3-Embedding encodes documents as raw text without special prefixes.
  91. */
  92. export function formatDocForEmbedding(text, title, modelUri) {
  93. const uri = modelUri ?? process.env.QMD_EMBED_MODEL ?? DEFAULT_EMBED_MODEL;
  94. if (isQwen3EmbeddingModel(uri)) {
  95. // Qwen3-Embedding: documents are raw text, no task prefix
  96. return title ? `${title}\n${text}` : text;
  97. }
  98. return `title: ${title || "none"} | text: ${text}`;
  99. }
  100. // =============================================================================
  101. // Model Configuration
  102. // =============================================================================
  103. // HuggingFace model URIs for node-llama-cpp
  104. // Format: hf:<user>/<repo>/<file>
  105. // Override via QMD_EMBED_MODEL env var (e.g. hf:Qwen/Qwen3-Embedding-0.6B-GGUF/Qwen3-Embedding-0.6B-Q8_0.gguf)
  106. const DEFAULT_EMBED_MODEL = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
  107. const DEFAULT_RERANK_MODEL = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
  108. // const DEFAULT_GENERATE_MODEL = "hf:ggml-org/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf";
  109. const DEFAULT_GENERATE_MODEL = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
  110. // Alternative generation models for query expansion:
  111. // LiquidAI LFM2 - hybrid architecture optimized for edge/on-device inference
  112. // Use these as base for fine-tuning with configs/sft_lfm2.yaml
  113. export const LFM2_GENERATE_MODEL = "hf:LiquidAI/LFM2-1.2B-GGUF/LFM2-1.2B-Q4_K_M.gguf";
  114. export const LFM2_INSTRUCT_MODEL = "hf:LiquidAI/LFM2.5-1.2B-Instruct-GGUF/LFM2.5-1.2B-Instruct-Q4_K_M.gguf";
  115. export const DEFAULT_EMBED_MODEL_URI = DEFAULT_EMBED_MODEL;
  116. export const DEFAULT_RERANK_MODEL_URI = DEFAULT_RERANK_MODEL;
  117. export const DEFAULT_GENERATE_MODEL_URI = DEFAULT_GENERATE_MODEL;
  118. // Local model cache directory
  119. const MODEL_CACHE_DIR = process.env.XDG_CACHE_HOME
  120. ? join(process.env.XDG_CACHE_HOME, "qmd", "models")
  121. : join(homedir(), ".cache", "qmd", "models");
  122. export const DEFAULT_MODEL_CACHE_DIR = MODEL_CACHE_DIR;
  123. function parseHfUri(model) {
  124. if (!model.startsWith("hf:"))
  125. return null;
  126. const without = model.slice(3);
  127. const parts = without.split("/");
  128. if (parts.length < 3)
  129. return null;
  130. const repo = parts.slice(0, 2).join("/");
  131. const file = parts.slice(2).join("/");
  132. return { repo, file };
  133. }
  134. async function getRemoteEtag(ref) {
  135. const url = `https://huggingface.co/${ref.repo}/resolve/main/${ref.file}`;
  136. try {
  137. const resp = await fetch(url, { method: "HEAD" });
  138. if (!resp.ok)
  139. return null;
  140. const etag = resp.headers.get("etag");
  141. return etag || null;
  142. }
  143. catch {
  144. return null;
  145. }
  146. }
  147. export async function pullModels(models, options = {}) {
  148. const cacheDir = options.cacheDir || MODEL_CACHE_DIR;
  149. if (!existsSync(cacheDir)) {
  150. mkdirSync(cacheDir, { recursive: true });
  151. }
  152. const results = [];
  153. for (const model of models) {
  154. let refreshed = false;
  155. const hfRef = parseHfUri(model);
  156. const filename = model.split("/").pop();
  157. const entries = readdirSync(cacheDir, { withFileTypes: true });
  158. const cached = filename
  159. ? entries
  160. .filter((entry) => entry.isFile() && entry.name.includes(filename))
  161. .map((entry) => join(cacheDir, entry.name))
  162. : [];
  163. if (hfRef && filename) {
  164. const etagPath = join(cacheDir, `${filename}.etag`);
  165. const remoteEtag = await getRemoteEtag(hfRef);
  166. const localEtag = existsSync(etagPath)
  167. ? readFileSync(etagPath, "utf-8").trim()
  168. : null;
  169. const shouldRefresh = options.refresh || !remoteEtag || remoteEtag !== localEtag || cached.length === 0;
  170. if (shouldRefresh) {
  171. for (const candidate of cached) {
  172. if (existsSync(candidate))
  173. unlinkSync(candidate);
  174. }
  175. if (existsSync(etagPath))
  176. unlinkSync(etagPath);
  177. refreshed = cached.length > 0;
  178. }
  179. }
  180. else if (options.refresh && filename) {
  181. for (const candidate of cached) {
  182. if (existsSync(candidate))
  183. unlinkSync(candidate);
  184. refreshed = true;
  185. }
  186. }
  187. const path = await resolveModelFile(model, cacheDir);
  188. const sizeBytes = existsSync(path) ? statSync(path).size : 0;
  189. if (hfRef && filename) {
  190. const remoteEtag = await getRemoteEtag(hfRef);
  191. if (remoteEtag) {
  192. const etagPath = join(cacheDir, `${filename}.etag`);
  193. writeFileSync(etagPath, remoteEtag + "\n", "utf-8");
  194. }
  195. }
  196. results.push({ model, path, sizeBytes, refreshed });
  197. }
  198. return results;
  199. }
  200. /**
  201. * LLM implementation using node-llama-cpp
  202. */
  203. // Default inactivity timeout: 5 minutes (keep models warm during typical search sessions)
  204. const DEFAULT_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
  205. const DEFAULT_EXPAND_CONTEXT_SIZE = 2048;
  206. function resolveExpandContextSize(configValue) {
  207. if (configValue !== undefined) {
  208. if (!Number.isInteger(configValue) || configValue <= 0) {
  209. throw new Error(`Invalid expandContextSize: ${configValue}. Must be a positive integer.`);
  210. }
  211. return configValue;
  212. }
  213. const envValue = process.env.QMD_EXPAND_CONTEXT_SIZE?.trim();
  214. if (!envValue)
  215. return DEFAULT_EXPAND_CONTEXT_SIZE;
  216. const parsed = Number.parseInt(envValue, 10);
  217. if (!Number.isInteger(parsed) || parsed <= 0) {
  218. process.stderr.write(`QMD Warning: invalid QMD_EXPAND_CONTEXT_SIZE="${envValue}", using default ${DEFAULT_EXPAND_CONTEXT_SIZE}.\n`);
  219. return DEFAULT_EXPAND_CONTEXT_SIZE;
  220. }
  221. return parsed;
  222. }
  223. export class LlamaCpp {
  224. _ciMode = !!process.env.CI;
  225. llama = null;
  226. embedModel = null;
  227. embedContexts = [];
  228. generateModel = null;
  229. rerankModel = null;
  230. rerankContexts = [];
  231. embedModelUri;
  232. generateModelUri;
  233. rerankModelUri;
  234. modelCacheDir;
  235. expandContextSize;
  236. // Ensure we don't load the same model/context concurrently (which can allocate duplicate VRAM).
  237. embedModelLoadPromise = null;
  238. generateModelLoadPromise = null;
  239. rerankModelLoadPromise = null;
  240. // Inactivity timer for auto-unloading models
  241. inactivityTimer = null;
  242. inactivityTimeoutMs;
  243. disposeModelsOnInactivity;
  244. // Track disposal state to prevent double-dispose
  245. disposed = false;
  246. constructor(config = {}) {
  247. this.embedModelUri = config.embedModel || process.env.QMD_EMBED_MODEL || DEFAULT_EMBED_MODEL;
  248. this.generateModelUri = config.generateModel || process.env.QMD_GENERATE_MODEL || DEFAULT_GENERATE_MODEL;
  249. this.rerankModelUri = config.rerankModel || process.env.QMD_RERANK_MODEL || DEFAULT_RERANK_MODEL;
  250. this.modelCacheDir = config.modelCacheDir || MODEL_CACHE_DIR;
  251. this.expandContextSize = resolveExpandContextSize(config.expandContextSize);
  252. this.inactivityTimeoutMs = config.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS;
  253. this.disposeModelsOnInactivity = config.disposeModelsOnInactivity ?? false;
  254. }
  255. get embedModelName() {
  256. return this.embedModelUri;
  257. }
  258. /**
  259. * Reset the inactivity timer. Called after each model operation.
  260. * When timer fires, models are unloaded to free memory (if no active sessions).
  261. */
  262. touchActivity() {
  263. // Clear existing timer
  264. if (this.inactivityTimer) {
  265. clearTimeout(this.inactivityTimer);
  266. this.inactivityTimer = null;
  267. }
  268. // Only set timer if we have disposable contexts and timeout is enabled
  269. if (this.inactivityTimeoutMs > 0 && this.hasLoadedContexts()) {
  270. this.inactivityTimer = setTimeout(() => {
  271. // Check if session manager allows unloading
  272. // canUnloadLLM is defined later in this file - it checks the session manager
  273. // We use dynamic import pattern to avoid circular dependency issues
  274. if (typeof canUnloadLLM === 'function' && !canUnloadLLM()) {
  275. // Active sessions/operations - reschedule timer
  276. this.touchActivity();
  277. return;
  278. }
  279. this.unloadIdleResources().catch(err => {
  280. console.error("Error unloading idle resources:", err);
  281. });
  282. }, this.inactivityTimeoutMs);
  283. // Don't keep process alive just for this timer
  284. this.inactivityTimer.unref();
  285. }
  286. }
  287. /**
  288. * Check if any contexts are currently loaded (and therefore worth unloading on inactivity).
  289. */
  290. hasLoadedContexts() {
  291. return !!(this.embedContexts.length > 0 || this.rerankContexts.length > 0);
  292. }
  293. /**
  294. * Unload idle resources but keep the instance alive for future use.
  295. *
  296. * By default, this disposes contexts (and their dependent sequences), while keeping models loaded.
  297. * This matches the intended lifecycle: model → context → sequence, where contexts are per-session.
  298. */
  299. async unloadIdleResources() {
  300. // Don't unload if already disposed
  301. if (this.disposed) {
  302. return;
  303. }
  304. // Clear timer
  305. if (this.inactivityTimer) {
  306. clearTimeout(this.inactivityTimer);
  307. this.inactivityTimer = null;
  308. }
  309. // Dispose contexts first
  310. for (const ctx of this.embedContexts) {
  311. await ctx.dispose();
  312. }
  313. this.embedContexts = [];
  314. for (const ctx of this.rerankContexts) {
  315. await ctx.dispose();
  316. }
  317. this.rerankContexts = [];
  318. // Optionally dispose models too (opt-in)
  319. if (this.disposeModelsOnInactivity) {
  320. if (this.embedModel) {
  321. await this.embedModel.dispose();
  322. this.embedModel = null;
  323. }
  324. if (this.generateModel) {
  325. await this.generateModel.dispose();
  326. this.generateModel = null;
  327. }
  328. if (this.rerankModel) {
  329. await this.rerankModel.dispose();
  330. this.rerankModel = null;
  331. }
  332. // Reset load promises so models can be reloaded later
  333. this.embedModelLoadPromise = null;
  334. this.generateModelLoadPromise = null;
  335. this.rerankModelLoadPromise = null;
  336. }
  337. // Note: We keep llama instance alive - it's lightweight
  338. }
  339. /**
  340. * Ensure model cache directory exists
  341. */
  342. ensureModelCacheDir() {
  343. if (!existsSync(this.modelCacheDir)) {
  344. mkdirSync(this.modelCacheDir, { recursive: true });
  345. }
  346. }
  347. /**
  348. * Initialize the llama instance (lazy)
  349. *
  350. * Env-var controls (i-c28wngnd):
  351. * - QMD_DISABLE_LOCAL_LLM=1 : hard-disable; throws on first ensureLlama()
  352. * call. Use when the deployment must NEVER
  353. * load node-llama-cpp (e.g. headless cron
  354. * on a host without libvulkan-dev/glslc).
  355. * - QMD_LLAMA_GPU=off|none|... : force CPU-only (skip Vulkan probe).
  356. * - QMD_LLAMA_GPU=auto : explicit opt-in to GPU probe even when
  357. * QMD_EMBED_ENDPOINT is set (rare; useful
  358. * for hybrid local-rerank + remote-embed).
  359. *
  360. * Auto-detect: when QMD_EMBED_ENDPOINT is set (HTTP embed provider, e.g.
  361. * cron on `code` → ai.mm.mk → models:8082), we default to CPU-only because
  362. * the embed path runs over HTTP and the only remaining local LLM consumers
  363. * are rerank/query-expansion, which work fine on the prebuilt CPU binary
  364. * and never need to invoke cmake-js-llama. This silences ~30s/run of
  365. * Vulkan probe + cmake noise on headless LXCs.
  366. */
  367. async ensureLlama() {
  368. if (!this.llama) {
  369. // Hard-disable opt-out — fails fast so the caller knows. Throw early
  370. // so any path that ignores the documented `EmbeddingProvider` route
  371. // and reaches for the local LLM gets a loud, actionable error rather
  372. // than a silent 30s Vulkan compile attempt.
  373. if (isLocalLlmDisabled(process.env)) {
  374. throw new Error("QMD_DISABLE_LOCAL_LLM=1 — local node-llama-cpp is disabled. " +
  375. "This deployment is configured for remote embeddings only; the " +
  376. "code path that reached `ensureLlama()` should route through an " +
  377. "EmbeddingProvider (set QMD_EMBED_ENDPOINT) instead. Unset " +
  378. "QMD_DISABLE_LOCAL_LLM to re-enable local rerank/expand.");
  379. }
  380. // Resolve GPU mode: explicit QMD_LLAMA_GPU wins, else auto-detect
  381. // remote-only deployment (CPU when QMD_EMBED_ENDPOINT is set), else
  382. // probe GPU normally for legacy local-only setups.
  383. const gpuMode = resolveLlamaGpuMode(process.env);
  384. const loadLlama = async (gpu) => await getLlama({
  385. // `never` = load a prebuilt binary only; never invoke cmake at query
  386. // time. When the GPU auto-probe picks a backend whose prebuilt binary
  387. // is incompatible with the host (e.g. the `code` LXC has libvulkan.so.1
  388. // but no GPU device and no glslc), `autoAttempt` would compile
  389. // llama.cpp from source per-GPU — a 30-60s+ blocking stall that then
  390. // fails for lack of glslc and leaves a half-built localBuilds/ dir,
  391. // hanging interactive `qmd query`. `never` instead falls straight
  392. // through the prebuilt candidate list (Vulkan -> CUDA -> CPU) and lands
  393. // on the prebuilt CPU binary. node-llama-cpp ships prebuilts for every
  394. // platform we deploy on, so the source-build fallback is dead weight.
  395. // Completes i-c28wngnd (which only covered the QMD_EMBED_ENDPOINT=cpu
  396. // path) for the interactive / gpu:"auto" path. (i-tgac7ig3)
  397. build: "never",
  398. logLevel: LlamaLogLevel.error,
  399. gpu,
  400. });
  401. let llama;
  402. if (gpuMode === "cpu") {
  403. llama = await loadLlama(false);
  404. }
  405. else {
  406. try {
  407. llama = await loadLlama("auto");
  408. }
  409. catch (err) {
  410. // GPU backend (e.g. Vulkan on headless/driverless machines) can throw at init.
  411. // Fall back to CPU so qmd still works.
  412. process.stderr.write(`QMD Warning: GPU init failed (${err instanceof Error ? err.message : String(err)}), falling back to CPU.\n`);
  413. llama = await loadLlama(false);
  414. }
  415. }
  416. // Suppress the "running on CPU (slow)" warning when CPU was requested
  417. // explicitly or auto-selected for a remote-only deployment — there's
  418. // nothing the operator can do about it and the hint isn't relevant
  419. // (embed runs via HTTP; only rerank/expand use the local CPU path).
  420. if (llama.gpu === false && gpuMode === "auto") {
  421. process.stderr.write("QMD Warning: no GPU acceleration, running on CPU (slow). Run 'qmd status' for details.\n");
  422. }
  423. this.llama = llama;
  424. }
  425. return this.llama;
  426. }
  427. /**
  428. * Resolve a model URI to a local path, downloading if needed
  429. */
  430. async resolveModel(modelUri) {
  431. this.ensureModelCacheDir();
  432. // resolveModelFile handles HF URIs and downloads to the cache dir
  433. return await resolveModelFile(modelUri, this.modelCacheDir);
  434. }
  435. /**
  436. * Load embedding model (lazy)
  437. */
  438. async ensureEmbedModel() {
  439. if (this.embedModel) {
  440. return this.embedModel;
  441. }
  442. if (this.embedModelLoadPromise) {
  443. return await this.embedModelLoadPromise;
  444. }
  445. this.embedModelLoadPromise = (async () => {
  446. const llama = await this.ensureLlama();
  447. const modelPath = await this.resolveModel(this.embedModelUri);
  448. const model = await llama.loadModel({ modelPath });
  449. this.embedModel = model;
  450. // Model loading counts as activity - ping to keep alive
  451. this.touchActivity();
  452. return model;
  453. })();
  454. try {
  455. return await this.embedModelLoadPromise;
  456. }
  457. finally {
  458. // Keep the resolved model cached; clear only the in-flight promise.
  459. this.embedModelLoadPromise = null;
  460. }
  461. }
  462. /**
  463. * Compute how many parallel contexts to create.
  464. *
  465. * GPU: constrained by VRAM (25% of free, capped at 8).
  466. * CPU: constrained by cores. Splitting threads across contexts enables
  467. * true parallelism (each context runs on its own cores). Use at most
  468. * half the math cores, with at least 4 threads per context.
  469. */
  470. async computeParallelism(perContextMB) {
  471. const llama = await this.ensureLlama();
  472. if (llama.gpu) {
  473. try {
  474. const vram = await llama.getVramState();
  475. const freeMB = vram.free / (1024 * 1024);
  476. const maxByVram = Math.floor((freeMB * 0.25) / perContextMB);
  477. return Math.max(1, Math.min(8, maxByVram));
  478. }
  479. catch {
  480. return 2;
  481. }
  482. }
  483. // CPU: split cores across contexts. At least 4 threads per context.
  484. const cores = llama.cpuMathCores || 4;
  485. const maxContexts = Math.floor(cores / 4);
  486. return Math.max(1, Math.min(4, maxContexts));
  487. }
  488. /**
  489. * Get the number of threads each context should use, given N parallel contexts.
  490. * Splits available math cores evenly across contexts.
  491. */
  492. async threadsPerContext(parallelism) {
  493. const llama = await this.ensureLlama();
  494. if (llama.gpu)
  495. return 0; // GPU: let the library decide
  496. const cores = llama.cpuMathCores || 4;
  497. return Math.max(1, Math.floor(cores / parallelism));
  498. }
  499. /**
  500. * Load embedding contexts (lazy). Creates multiple for parallel embedding.
  501. * Uses promise guard to prevent concurrent context creation race condition.
  502. */
  503. embedContextsCreatePromise = null;
  504. async ensureEmbedContexts() {
  505. if (this.embedContexts.length > 0) {
  506. this.touchActivity();
  507. return this.embedContexts;
  508. }
  509. if (this.embedContextsCreatePromise) {
  510. return await this.embedContextsCreatePromise;
  511. }
  512. this.embedContextsCreatePromise = (async () => {
  513. const model = await this.ensureEmbedModel();
  514. // Embed contexts are ~143 MB each (nomic-embed 2048 ctx)
  515. const n = await this.computeParallelism(150);
  516. const threads = await this.threadsPerContext(n);
  517. for (let i = 0; i < n; i++) {
  518. try {
  519. this.embedContexts.push(await model.createEmbeddingContext({
  520. contextSize: LlamaCpp.EMBED_CONTEXT_SIZE,
  521. ...(threads > 0 ? { threads } : {}),
  522. }));
  523. }
  524. catch {
  525. if (this.embedContexts.length === 0)
  526. throw new Error("Failed to create any embedding context");
  527. break;
  528. }
  529. }
  530. this.touchActivity();
  531. return this.embedContexts;
  532. })();
  533. try {
  534. return await this.embedContextsCreatePromise;
  535. }
  536. finally {
  537. this.embedContextsCreatePromise = null;
  538. }
  539. }
  540. /**
  541. * Get a single embed context (for single-embed calls). Uses first from pool.
  542. */
  543. async ensureEmbedContext() {
  544. const contexts = await this.ensureEmbedContexts();
  545. return contexts[0];
  546. }
  547. /**
  548. * Load generation model (lazy) - context is created fresh per call
  549. */
  550. async ensureGenerateModel() {
  551. if (!this.generateModel) {
  552. if (this.generateModelLoadPromise) {
  553. return await this.generateModelLoadPromise;
  554. }
  555. this.generateModelLoadPromise = (async () => {
  556. const llama = await this.ensureLlama();
  557. const modelPath = await this.resolveModel(this.generateModelUri);
  558. const model = await llama.loadModel({ modelPath });
  559. this.generateModel = model;
  560. return model;
  561. })();
  562. try {
  563. await this.generateModelLoadPromise;
  564. }
  565. finally {
  566. this.generateModelLoadPromise = null;
  567. }
  568. }
  569. this.touchActivity();
  570. if (!this.generateModel) {
  571. throw new Error("Generate model not loaded");
  572. }
  573. return this.generateModel;
  574. }
  575. /**
  576. * Load rerank model (lazy)
  577. */
  578. async ensureRerankModel() {
  579. if (this.rerankModel) {
  580. return this.rerankModel;
  581. }
  582. if (this.rerankModelLoadPromise) {
  583. return await this.rerankModelLoadPromise;
  584. }
  585. this.rerankModelLoadPromise = (async () => {
  586. const llama = await this.ensureLlama();
  587. const modelPath = await this.resolveModel(this.rerankModelUri);
  588. const model = await llama.loadModel({ modelPath });
  589. this.rerankModel = model;
  590. // Model loading counts as activity - ping to keep alive
  591. this.touchActivity();
  592. return model;
  593. })();
  594. try {
  595. return await this.rerankModelLoadPromise;
  596. }
  597. finally {
  598. this.rerankModelLoadPromise = null;
  599. }
  600. }
  601. /**
  602. * Load rerank contexts (lazy). Creates multiple contexts for parallel ranking.
  603. * Each context has its own sequence, so they can evaluate independently.
  604. *
  605. * Tuning choices:
  606. * - contextSize 1024: reranking chunks are ~800 tokens max, 1024 is plenty
  607. * - flashAttention: ~20% less VRAM per context (568 vs 711 MB)
  608. * - Combined: drops from 11.6 GB (auto, no flash) to 568 MB per context (20×)
  609. */
  610. // Qwen3 reranker template adds ~200 tokens overhead (system prompt, tags, etc.)
  611. // Default 2048 was too small for longer documents (e.g. session transcripts,
  612. // CJK text, or large markdown files) — callers hit "input lengths exceed
  613. // context size" errors even after truncation because the overhead estimate
  614. // was insufficient. 4096 comfortably fits the largest real-world chunks
  615. // while staying well below the 40 960-token auto size.
  616. // Override with QMD_RERANK_CONTEXT_SIZE env var if you need more headroom.
  617. static RERANK_CONTEXT_SIZE = (() => {
  618. const v = parseInt(process.env.QMD_RERANK_CONTEXT_SIZE ?? "", 10);
  619. return Number.isFinite(v) && v > 0 ? v : 4096;
  620. })();
  621. static EMBED_CONTEXT_SIZE = (() => {
  622. const v = parseInt(process.env.QMD_EMBED_CONTEXT_SIZE ?? "", 10);
  623. return Number.isFinite(v) && v > 0 ? v : 2048;
  624. })();
  625. async ensureRerankContexts() {
  626. if (this.rerankContexts.length === 0) {
  627. const model = await this.ensureRerankModel();
  628. // ~960 MB per context with flash attention at contextSize 2048
  629. const n = Math.min(await this.computeParallelism(1000), 4);
  630. const threads = await this.threadsPerContext(n);
  631. for (let i = 0; i < n; i++) {
  632. try {
  633. this.rerankContexts.push(await model.createRankingContext({
  634. contextSize: LlamaCpp.RERANK_CONTEXT_SIZE,
  635. flashAttention: true,
  636. ...(threads > 0 ? { threads } : {}),
  637. }));
  638. }
  639. catch {
  640. if (this.rerankContexts.length === 0) {
  641. // Flash attention might not be supported — retry without it
  642. try {
  643. this.rerankContexts.push(await model.createRankingContext({
  644. contextSize: LlamaCpp.RERANK_CONTEXT_SIZE,
  645. ...(threads > 0 ? { threads } : {}),
  646. }));
  647. }
  648. catch {
  649. throw new Error("Failed to create any rerank context");
  650. }
  651. }
  652. break;
  653. }
  654. }
  655. }
  656. this.touchActivity();
  657. return this.rerankContexts;
  658. }
  659. // ==========================================================================
  660. // Tokenization
  661. // ==========================================================================
  662. /**
  663. * Tokenize text using the embedding model's tokenizer
  664. * Returns tokenizer tokens (opaque type from node-llama-cpp)
  665. */
  666. async tokenize(text) {
  667. await this.ensureEmbedContext(); // Ensure model is loaded
  668. if (!this.embedModel) {
  669. throw new Error("Embed model not loaded");
  670. }
  671. return this.embedModel.tokenize(text);
  672. }
  673. /**
  674. * Count tokens in text using the embedding model's tokenizer
  675. */
  676. async countTokens(text) {
  677. const tokens = await this.tokenize(text);
  678. return tokens.length;
  679. }
  680. /**
  681. * Detokenize token IDs back to text
  682. */
  683. async detokenize(tokens) {
  684. await this.ensureEmbedContext();
  685. if (!this.embedModel) {
  686. throw new Error("Embed model not loaded");
  687. }
  688. return this.embedModel.detokenize(tokens);
  689. }
  690. // ==========================================================================
  691. // Core API methods
  692. // ==========================================================================
  693. /**
  694. * Truncate text to fit within the embedding model's context window.
  695. * Uses the model's own tokenizer for accurate token counting, then
  696. * detokenizes back to text if truncation is needed.
  697. * Returns the (possibly truncated) text and whether truncation occurred.
  698. */
  699. async truncateToContextSize(text) {
  700. if (!this.embedModel)
  701. return { text, truncated: false };
  702. const maxTokens = this.embedModel.trainContextSize;
  703. if (maxTokens <= 0)
  704. return { text, truncated: false };
  705. const tokens = this.embedModel.tokenize(text);
  706. if (tokens.length <= maxTokens)
  707. return { text, truncated: false };
  708. // Leave a small margin (4 tokens) for BOS/EOS overhead
  709. const safeLimit = Math.max(1, maxTokens - 4);
  710. const truncatedTokens = tokens.slice(0, safeLimit);
  711. const truncatedText = this.embedModel.detokenize(truncatedTokens);
  712. return { text: truncatedText, truncated: true };
  713. }
  714. async embed(text, options = {}) {
  715. // Ping activity at start to keep models alive during this operation
  716. this.touchActivity();
  717. try {
  718. const context = await this.ensureEmbedContext();
  719. // Guard: truncate text that exceeds model context window to prevent GGML crash
  720. const { text: safeText, truncated } = await this.truncateToContextSize(text);
  721. if (truncated) {
  722. console.warn(`⚠ Text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
  723. }
  724. const embedding = await context.getEmbeddingFor(safeText);
  725. return {
  726. embedding: Array.from(embedding.vector),
  727. model: options.model ?? this.embedModelUri,
  728. };
  729. }
  730. catch (error) {
  731. console.error("Embedding error:", error);
  732. return null;
  733. }
  734. }
  735. /**
  736. * Batch embed multiple texts efficiently
  737. * Uses Promise.all for parallel embedding - node-llama-cpp handles batching internally
  738. */
  739. async embedBatch(texts, options = {}) {
  740. if (this._ciMode)
  741. throw new Error("LLM operations are disabled in CI (set CI=true)");
  742. // Ping activity at start to keep models alive during this operation
  743. this.touchActivity();
  744. if (texts.length === 0)
  745. return [];
  746. try {
  747. const contexts = await this.ensureEmbedContexts();
  748. const n = contexts.length;
  749. if (n === 1) {
  750. // Single context: sequential (no point splitting)
  751. const context = contexts[0];
  752. const embeddings = [];
  753. for (const text of texts) {
  754. try {
  755. const { text: safeText, truncated } = await this.truncateToContextSize(text);
  756. if (truncated) {
  757. console.warn(`⚠ Batch text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
  758. }
  759. const embedding = await context.getEmbeddingFor(safeText);
  760. this.touchActivity();
  761. embeddings.push({ embedding: Array.from(embedding.vector), model: options.model ?? this.embedModelUri });
  762. }
  763. catch (err) {
  764. console.error("Embedding error for text:", err);
  765. embeddings.push(null);
  766. }
  767. }
  768. return embeddings;
  769. }
  770. // Multiple contexts: split texts across contexts for parallel evaluation
  771. const chunkSize = Math.ceil(texts.length / n);
  772. const chunks = Array.from({ length: n }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize));
  773. const chunkResults = await Promise.all(chunks.map(async (chunk, i) => {
  774. const ctx = contexts[i];
  775. const results = [];
  776. for (const text of chunk) {
  777. try {
  778. const { text: safeText, truncated } = await this.truncateToContextSize(text);
  779. if (truncated) {
  780. console.warn(`⚠ Batch text truncated to fit embedding context (${this.embedModel?.trainContextSize} tokens)`);
  781. }
  782. const embedding = await ctx.getEmbeddingFor(safeText);
  783. this.touchActivity();
  784. results.push({ embedding: Array.from(embedding.vector), model: options.model ?? this.embedModelUri });
  785. }
  786. catch (err) {
  787. console.error("Embedding error for text:", err);
  788. results.push(null);
  789. }
  790. }
  791. return results;
  792. }));
  793. return chunkResults.flat();
  794. }
  795. catch (error) {
  796. console.error("Batch embedding error:", error);
  797. return texts.map(() => null);
  798. }
  799. }
  800. async generate(prompt, options = {}) {
  801. if (this._ciMode)
  802. throw new Error("LLM operations are disabled in CI (set CI=true)");
  803. // Ping activity at start to keep models alive during this operation
  804. this.touchActivity();
  805. // Ensure model is loaded
  806. await this.ensureGenerateModel();
  807. // Create fresh context -> sequence -> session for each call
  808. const context = await this.generateModel.createContext();
  809. const sequence = context.getSequence();
  810. const session = new LlamaChatSession({ contextSequence: sequence });
  811. const maxTokens = options.maxTokens ?? 150;
  812. // Qwen3 recommends temp=0.7, topP=0.8, topK=20 for non-thinking mode
  813. // DO NOT use greedy decoding (temp=0) - causes repetition loops
  814. const temperature = options.temperature ?? 0.7;
  815. let result = "";
  816. try {
  817. await session.prompt(prompt, {
  818. maxTokens,
  819. temperature,
  820. topK: 20,
  821. topP: 0.8,
  822. onTextChunk: (text) => {
  823. result += text;
  824. },
  825. });
  826. return {
  827. text: result,
  828. model: this.generateModelUri,
  829. done: true,
  830. };
  831. }
  832. finally {
  833. // Dispose context (which disposes dependent sequences/sessions per lifecycle rules)
  834. await context.dispose();
  835. }
  836. }
  837. async modelExists(modelUri) {
  838. // For HuggingFace URIs, we assume they exist
  839. // For local paths, check if file exists
  840. if (modelUri.startsWith("hf:")) {
  841. return { name: modelUri, exists: true };
  842. }
  843. const exists = existsSync(modelUri);
  844. return {
  845. name: modelUri,
  846. exists,
  847. path: exists ? modelUri : undefined,
  848. };
  849. }
  850. // ==========================================================================
  851. // High-level abstractions
  852. // ==========================================================================
  853. async expandQuery(query, options = {}) {
  854. if (this._ciMode)
  855. throw new Error("LLM operations are disabled in CI (set CI=true)");
  856. // Ping activity at start to keep models alive during this operation
  857. this.touchActivity();
  858. const llama = await this.ensureLlama();
  859. await this.ensureGenerateModel();
  860. const includeLexical = options.includeLexical ?? true;
  861. const context = options.context;
  862. const grammar = await llama.createGrammar({
  863. grammar: `
  864. root ::= line+
  865. line ::= type ": " content "\\n"
  866. type ::= "lex" | "vec" | "hyde"
  867. content ::= [^\\n]+
  868. `
  869. });
  870. const intent = options.intent;
  871. const prompt = intent
  872. ? `/no_think Expand this search query: ${query}\nQuery intent: ${intent}`
  873. : `/no_think Expand this search query: ${query}`;
  874. // Create a bounded context for expansion to prevent large default VRAM allocations.
  875. const genContext = await this.generateModel.createContext({
  876. contextSize: this.expandContextSize,
  877. });
  878. const sequence = genContext.getSequence();
  879. const session = new LlamaChatSession({ contextSequence: sequence });
  880. try {
  881. // Qwen3 recommended settings for non-thinking mode:
  882. // temp=0.7, topP=0.8, topK=20, presence_penalty for repetition
  883. // DO NOT use greedy decoding (temp=0) - causes infinite loops
  884. const result = await session.prompt(prompt, {
  885. grammar,
  886. maxTokens: 600,
  887. temperature: 0.7,
  888. topK: 20,
  889. topP: 0.8,
  890. repeatPenalty: {
  891. lastTokens: 64,
  892. presencePenalty: 0.5,
  893. },
  894. });
  895. const lines = result.trim().split("\n");
  896. const queryLower = query.toLowerCase();
  897. const queryTerms = queryLower.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
  898. const hasQueryTerm = (text) => {
  899. const lower = text.toLowerCase();
  900. if (queryTerms.length === 0)
  901. return true;
  902. return queryTerms.some(term => lower.includes(term));
  903. };
  904. const queryables = lines.map(line => {
  905. const colonIdx = line.indexOf(":");
  906. if (colonIdx === -1)
  907. return null;
  908. const type = line.slice(0, colonIdx).trim();
  909. if (type !== 'lex' && type !== 'vec' && type !== 'hyde')
  910. return null;
  911. const text = line.slice(colonIdx + 1).trim();
  912. if (!hasQueryTerm(text))
  913. return null;
  914. return { type: type, text };
  915. }).filter((q) => q !== null);
  916. // Filter out lex entries if not requested
  917. const filtered = includeLexical ? queryables : queryables.filter(q => q.type !== 'lex');
  918. if (filtered.length > 0)
  919. return filtered;
  920. const fallback = [
  921. { type: 'hyde', text: `Information about ${query}` },
  922. { type: 'lex', text: query },
  923. { type: 'vec', text: query },
  924. ];
  925. return includeLexical ? fallback : fallback.filter(q => q.type !== 'lex');
  926. }
  927. catch (error) {
  928. console.error("Structured query expansion failed:", error);
  929. // Fallback to original query
  930. const fallback = [{ type: 'vec', text: query }];
  931. if (includeLexical)
  932. fallback.unshift({ type: 'lex', text: query });
  933. return fallback;
  934. }
  935. finally {
  936. await genContext.dispose();
  937. }
  938. }
  939. // Qwen3 reranker chat template overhead (system prompt, tags, separators).
  940. // Measured at ~350 tokens on real queries; use 512 as a safe upper bound so
  941. // the truncation budget never lets a document slip past the context limit.
  942. static RERANK_TEMPLATE_OVERHEAD = 512;
  943. static RERANK_TARGET_DOCS_PER_CONTEXT = 10;
  944. async rerank(query, documents, options = {}) {
  945. if (this._ciMode)
  946. throw new Error("LLM operations are disabled in CI (set CI=true)");
  947. // Ping activity at start to keep models alive during this operation
  948. this.touchActivity();
  949. const contexts = await this.ensureRerankContexts();
  950. const model = await this.ensureRerankModel();
  951. // Truncate documents that would exceed the rerank context size.
  952. // Budget = contextSize - template overhead - query tokens
  953. const queryTokens = model.tokenize(query).length;
  954. const maxDocTokens = LlamaCpp.RERANK_CONTEXT_SIZE - LlamaCpp.RERANK_TEMPLATE_OVERHEAD - queryTokens;
  955. const truncationCache = new Map();
  956. const truncatedDocs = documents.map((doc) => {
  957. const cached = truncationCache.get(doc.text);
  958. if (cached !== undefined) {
  959. return cached === doc.text ? doc : { ...doc, text: cached };
  960. }
  961. const tokens = model.tokenize(doc.text);
  962. const truncatedText = tokens.length <= maxDocTokens
  963. ? doc.text
  964. : model.detokenize(tokens.slice(0, maxDocTokens));
  965. truncationCache.set(doc.text, truncatedText);
  966. if (truncatedText === doc.text)
  967. return doc;
  968. return { ...doc, text: truncatedText };
  969. });
  970. // Deduplicate identical effective texts before scoring.
  971. // This avoids redundant work for repeated chunks and fixes collisions where
  972. // multiple docs map to the same chunk text.
  973. const textToDocs = new Map();
  974. truncatedDocs.forEach((doc, index) => {
  975. const existing = textToDocs.get(doc.text);
  976. if (existing) {
  977. existing.push({ file: doc.file, index });
  978. }
  979. else {
  980. textToDocs.set(doc.text, [{ file: doc.file, index }]);
  981. }
  982. });
  983. // Extract just the text for ranking
  984. const texts = Array.from(textToDocs.keys());
  985. // Split documents across contexts for parallel evaluation.
  986. // Each context has its own sequence with a lock, so parallelism comes
  987. // from multiple contexts evaluating different chunks simultaneously.
  988. const activeContextCount = Math.max(1, Math.min(contexts.length, Math.ceil(texts.length / LlamaCpp.RERANK_TARGET_DOCS_PER_CONTEXT)));
  989. const activeContexts = contexts.slice(0, activeContextCount);
  990. const chunkSize = Math.ceil(texts.length / activeContexts.length);
  991. const chunks = Array.from({ length: activeContexts.length }, (_, i) => texts.slice(i * chunkSize, (i + 1) * chunkSize)).filter(chunk => chunk.length > 0);
  992. const allScores = await Promise.all(chunks.map((chunk, i) => activeContexts[i].rankAll(query, chunk)));
  993. // Reassemble scores in original order and sort
  994. const flatScores = allScores.flat();
  995. const ranked = texts
  996. .map((text, i) => ({ document: text, score: flatScores[i] }))
  997. .sort((a, b) => b.score - a.score);
  998. // Map back to our result format.
  999. const results = [];
  1000. for (const item of ranked) {
  1001. const docInfos = textToDocs.get(item.document) ?? [];
  1002. for (const docInfo of docInfos) {
  1003. results.push({
  1004. file: docInfo.file,
  1005. score: item.score,
  1006. index: docInfo.index,
  1007. });
  1008. }
  1009. }
  1010. return {
  1011. results,
  1012. model: this.rerankModelUri,
  1013. };
  1014. }
  1015. /**
  1016. * Get device/GPU info for status display.
  1017. * Initializes llama if not already done.
  1018. */
  1019. async getDeviceInfo() {
  1020. const llama = await this.ensureLlama();
  1021. const gpuDevices = await llama.getGpuDeviceNames();
  1022. let vram;
  1023. if (llama.gpu) {
  1024. try {
  1025. const state = await llama.getVramState();
  1026. vram = { total: state.total, used: state.used, free: state.free };
  1027. }
  1028. catch { /* no vram info */ }
  1029. }
  1030. return {
  1031. gpu: llama.gpu,
  1032. gpuOffloading: llama.supportsGpuOffloading,
  1033. gpuDevices,
  1034. vram,
  1035. cpuCores: llama.cpuMathCores,
  1036. };
  1037. }
  1038. async dispose() {
  1039. // Prevent double-dispose
  1040. if (this.disposed) {
  1041. return;
  1042. }
  1043. this.disposed = true;
  1044. // Clear inactivity timer
  1045. if (this.inactivityTimer) {
  1046. clearTimeout(this.inactivityTimer);
  1047. this.inactivityTimer = null;
  1048. }
  1049. // Disposing llama cascades to models and contexts automatically
  1050. // See: https://node-llama-cpp.withcat.ai/guide/objects-lifecycle
  1051. // Note: llama.dispose() can hang indefinitely, so we use a timeout
  1052. if (this.llama) {
  1053. const disposePromise = this.llama.dispose();
  1054. const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 1000));
  1055. await Promise.race([disposePromise, timeoutPromise]);
  1056. }
  1057. // Clear references
  1058. this.embedContexts = [];
  1059. this.rerankContexts = [];
  1060. this.embedModel = null;
  1061. this.generateModel = null;
  1062. this.rerankModel = null;
  1063. this.llama = null;
  1064. // Clear any in-flight load/create promises
  1065. this.embedModelLoadPromise = null;
  1066. this.embedContextsCreatePromise = null;
  1067. this.generateModelLoadPromise = null;
  1068. this.rerankModelLoadPromise = null;
  1069. }
  1070. }
  1071. // =============================================================================
  1072. // Session Management Layer
  1073. // =============================================================================
  1074. /**
  1075. * Manages LLM session lifecycle with reference counting.
  1076. * Coordinates with LlamaCpp idle timeout to prevent disposal during active sessions.
  1077. */
  1078. class LLMSessionManager {
  1079. llm;
  1080. _activeSessionCount = 0;
  1081. _inFlightOperations = 0;
  1082. constructor(llm) {
  1083. this.llm = llm;
  1084. }
  1085. get activeSessionCount() {
  1086. return this._activeSessionCount;
  1087. }
  1088. get inFlightOperations() {
  1089. return this._inFlightOperations;
  1090. }
  1091. /**
  1092. * Returns true only when both session count and in-flight operations are 0.
  1093. * Used by LlamaCpp to determine if idle unload is safe.
  1094. */
  1095. canUnload() {
  1096. return this._activeSessionCount === 0 && this._inFlightOperations === 0;
  1097. }
  1098. acquire() {
  1099. this._activeSessionCount++;
  1100. }
  1101. release() {
  1102. this._activeSessionCount = Math.max(0, this._activeSessionCount - 1);
  1103. }
  1104. operationStart() {
  1105. this._inFlightOperations++;
  1106. }
  1107. operationEnd() {
  1108. this._inFlightOperations = Math.max(0, this._inFlightOperations - 1);
  1109. }
  1110. getLlamaCpp() {
  1111. return this.llm;
  1112. }
  1113. }
  1114. /**
  1115. * Error thrown when an operation is attempted on a released or aborted session.
  1116. */
  1117. export class SessionReleasedError extends Error {
  1118. constructor(message = "LLM session has been released or aborted") {
  1119. super(message);
  1120. this.name = "SessionReleasedError";
  1121. }
  1122. }
  1123. /**
  1124. * Scoped LLM session with automatic lifecycle management.
  1125. * Wraps LlamaCpp methods with operation tracking and abort handling.
  1126. */
  1127. class LLMSession {
  1128. manager;
  1129. released = false;
  1130. abortController;
  1131. maxDurationTimer = null;
  1132. name;
  1133. constructor(manager, options = {}) {
  1134. this.manager = manager;
  1135. this.name = options.name || "unnamed";
  1136. this.abortController = new AbortController();
  1137. // Link external abort signal if provided
  1138. if (options.signal) {
  1139. if (options.signal.aborted) {
  1140. this.abortController.abort(options.signal.reason);
  1141. }
  1142. else {
  1143. options.signal.addEventListener("abort", () => {
  1144. this.abortController.abort(options.signal.reason);
  1145. }, { once: true });
  1146. }
  1147. }
  1148. // Set up max duration timer
  1149. const maxDuration = options.maxDuration ?? 10 * 60 * 1000; // Default 10 minutes
  1150. if (maxDuration > 0) {
  1151. this.maxDurationTimer = setTimeout(() => {
  1152. this.abortController.abort(new Error(`Session "${this.name}" exceeded max duration of ${maxDuration}ms`));
  1153. }, maxDuration);
  1154. this.maxDurationTimer.unref(); // Don't keep process alive
  1155. }
  1156. // Acquire session lease
  1157. this.manager.acquire();
  1158. }
  1159. get isValid() {
  1160. return !this.released && !this.abortController.signal.aborted;
  1161. }
  1162. get signal() {
  1163. return this.abortController.signal;
  1164. }
  1165. /**
  1166. * Release the session and decrement ref count.
  1167. * Called automatically by withLLMSession when the callback completes.
  1168. */
  1169. release() {
  1170. if (this.released)
  1171. return;
  1172. this.released = true;
  1173. if (this.maxDurationTimer) {
  1174. clearTimeout(this.maxDurationTimer);
  1175. this.maxDurationTimer = null;
  1176. }
  1177. this.abortController.abort(new Error("Session released"));
  1178. this.manager.release();
  1179. }
  1180. /**
  1181. * Wrap an operation with tracking and abort checking.
  1182. */
  1183. async withOperation(fn) {
  1184. if (!this.isValid) {
  1185. throw new SessionReleasedError();
  1186. }
  1187. this.manager.operationStart();
  1188. try {
  1189. // Check abort before starting
  1190. if (this.abortController.signal.aborted) {
  1191. throw new SessionReleasedError(this.abortController.signal.reason?.message || "Session aborted");
  1192. }
  1193. return await fn();
  1194. }
  1195. finally {
  1196. this.manager.operationEnd();
  1197. }
  1198. }
  1199. async embed(text, options) {
  1200. return this.withOperation(() => this.manager.getLlamaCpp().embed(text, options));
  1201. }
  1202. async embedBatch(texts, options) {
  1203. return this.withOperation(() => this.manager.getLlamaCpp().embedBatch(texts, options));
  1204. }
  1205. async expandQuery(query, options) {
  1206. return this.withOperation(() => this.manager.getLlamaCpp().expandQuery(query, options));
  1207. }
  1208. async rerank(query, documents, options) {
  1209. return this.withOperation(() => this.manager.getLlamaCpp().rerank(query, documents, options));
  1210. }
  1211. }
  1212. // Session manager for the default LlamaCpp instance
  1213. let defaultSessionManager = null;
  1214. /**
  1215. * Get the session manager for the default LlamaCpp instance.
  1216. */
  1217. function getSessionManager() {
  1218. const llm = getDefaultLlamaCpp();
  1219. if (!defaultSessionManager || defaultSessionManager.getLlamaCpp() !== llm) {
  1220. defaultSessionManager = new LLMSessionManager(llm);
  1221. }
  1222. return defaultSessionManager;
  1223. }
  1224. /**
  1225. * Execute a function with a scoped LLM session.
  1226. * The session provides lifecycle guarantees - resources won't be disposed mid-operation.
  1227. *
  1228. * @example
  1229. * ```typescript
  1230. * await withLLMSession(async (session) => {
  1231. * const expanded = await session.expandQuery(query);
  1232. * const embeddings = await session.embedBatch(texts);
  1233. * const reranked = await session.rerank(query, docs);
  1234. * return reranked;
  1235. * }, { maxDuration: 10 * 60 * 1000, name: 'querySearch' });
  1236. * ```
  1237. */
  1238. export async function withLLMSession(fn, options) {
  1239. const manager = getSessionManager();
  1240. const session = new LLMSession(manager, options);
  1241. try {
  1242. return await fn(session);
  1243. }
  1244. finally {
  1245. session.release();
  1246. }
  1247. }
  1248. /**
  1249. * Execute a function with a scoped LLM session using a specific LlamaCpp instance.
  1250. * Unlike withLLMSession, this does not use the global singleton.
  1251. */
  1252. export async function withLLMSessionForLlm(llm, fn, options) {
  1253. const manager = new LLMSessionManager(llm);
  1254. const session = new LLMSession(manager, options);
  1255. try {
  1256. return await fn(session);
  1257. }
  1258. finally {
  1259. session.release();
  1260. }
  1261. }
  1262. /**
  1263. * Check if idle unload is safe (no active sessions or operations).
  1264. * Used internally by LlamaCpp idle timer.
  1265. */
  1266. export function canUnloadLLM() {
  1267. if (!defaultSessionManager)
  1268. return true;
  1269. return defaultSessionManager.canUnload();
  1270. }
  1271. // =============================================================================
  1272. // Singleton for default LlamaCpp instance
  1273. // =============================================================================
  1274. let defaultLlamaCpp = null;
  1275. /**
  1276. * Get the default LlamaCpp instance (creates one if needed)
  1277. */
  1278. export function getDefaultLlamaCpp() {
  1279. if (!defaultLlamaCpp) {
  1280. defaultLlamaCpp = new LlamaCpp();
  1281. }
  1282. return defaultLlamaCpp;
  1283. }
  1284. /**
  1285. * Set a custom default LlamaCpp instance (useful for testing)
  1286. */
  1287. export function setDefaultLlamaCpp(llm) {
  1288. defaultLlamaCpp = llm;
  1289. }
  1290. /**
  1291. * Dispose the default LlamaCpp instance if it exists.
  1292. * Call this before process exit to prevent NAPI crashes.
  1293. */
  1294. export async function disposeDefaultLlamaCpp() {
  1295. if (defaultLlamaCpp) {
  1296. await defaultLlamaCpp.dispose();
  1297. defaultLlamaCpp = null;
  1298. }
  1299. }