@octocodeai/freellama-mcp-server 0.6.1 → 0.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -190,7 +190,7 @@ flowchart TD
190
190
  S --> G["Primary Ollama"]
191
191
  S --> U["Optional CPU Ollama"]
192
192
  B -->|"detail, raw; manage; delete"| G
193
- B -->|"library view"| W["ollama.com library"]
193
+ B -->|"library view"| W["ollama.com library + Hugging Face GGUF"]
194
194
  B -->|"delegate_research"| A["allowlisted local adapter subprocess"]
195
195
  A --> S
196
196
  C -->|"resources/read on demand"| D["packaged docs/*.md"]
@@ -265,7 +265,7 @@ see `FREELLAMA_MCP_AUTOSTART_SERVE` in [Configuration](CONFIGURATION.md#environm
265
265
  | Tool | Needs `freellama serve`? | What it does |
266
266
  |---|---|---|
267
267
  | `doctor` | No (except `status`/`usage`) | Runtime/configuration diagnostic. Target another Ollama with `ollamaEndpoint` and serve with `serveEndpoint`. `summary` (default) returns endpoint, version, resident count, host/profile signals, and next step; `full` adds all non-duplicated diagnostics, including admission and categorized Ollama settings; `status` returns serve's live view (per-backend queues, current and adaptive limits, circuit breakers, loaded models, host memory, Ollama's effective settings, today's usage); `usage` returns task and token totals per day and model (`days`, default 7). The report distinguishes an observed same-user macOS process from configuration hints and never claims remote process visibility. |
268
- | `models` | `installed` (default) and `resident` do; `detail`/`raw`/`library` don't | Views: `installed` (capabilities, use explanations, derived `model_type`, VRAM, context, policy_rank), `resident` (loaded now + managed GPU/CPU split), `detail` (one model, maximum context), paged `raw` (`GET /api/tags`), `library` (two-step ollama.com search: omit `model` for families, pass `model:"<family>"` for all tags/`fitsInMemory`). `includeLibrary:true` enriches installed/detail views with sourced public guidance; `includeReadme:true` returns bounded README text in library step 2 or enriched detail. |
268
+ | `models` | `installed` (default) and `resident` do; `detail`/`raw`/`library` don't | Views: `installed` (capabilities, use explanations, derived `model_type`, VRAM, context, policy_rank), `resident` (loaded now + managed GPU/CPU split), `detail` (one model, maximum context), paged `raw` (`GET /api/tags`), `library` (two-step public search: omit `model` to search ollama.com families and, for a `query`, Hugging Face GGUF repos (`source` limits it to one; `limit` applies per source); pass `model:"<family>"` or `model:"hf.co/<owner>/<repo>"` for every pullable tag with its size and `fitsInMemory`). `includeLibrary:true` enriches installed/detail views with sourced public guidance; `includeReadme:true` returns bounded README text in library step 2 or enriched detail. |
269
269
  | `run_task` | Yes | **Routes or executes** a chat/generate/embed call. `preview: true` accepts routing fields only and returns a decision without generation. Execution omits preview and supplies the payload. Embedding results withhold raw vectors by default (`returnEmbeddings: true` to get them). `tasks: [{id, independent:true, task}]` (max 64, `maxParallelism` bounds fan-out) runs a batch whose nested `task` takes the same fields; it is not a dependency scheduler. `warm: true` warms an exact installed `model` through managed admission with residency/profile controls and optional `defer:true`. |
270
270
  | `scope` | Yes | Creates, inspects, forks, or deletes process-local message history; history inclusion requires `includeMessages:true`. |
271
271
  | `session` | Yes | Creates, deletes, or kills model affinity without storing messages or KV; `kill` cancels the session's in-flight work. |
package/dist/index.js CHANGED
@@ -22377,27 +22377,38 @@ function libraryTrialCandidate(tag, embedding) {
22377
22377
  caution: "Measure this exact tag on representative tasks before choosing it. Include resident runners, context/KV caches and OS headroom in memory fit. Discovery does not authorize a pull."
22378
22378
  };
22379
22379
  }
22380
+ var SIZE_CHIP = /^(?:\d+x)?(?:e)?\d+(?:\.\d+)?[kmbt]$/i;
22380
22381
  function parseModelSearch(html) {
22381
22382
  const results = [];
22382
- for (const block of html.split(/<li\s/).slice(1)) {
22383
+ for (const block of html.split(/<li\b/).slice(1)) {
22383
22384
  const link = block.match(/href="\/(?:library\/([\w.-]+)|([\w.-]+\/[\w.-]+))"/);
22384
22385
  const name = link?.[1] ?? link?.[2];
22385
22386
  if (!name) continue;
22386
- if (!/<p\b[^>]*class="max-w-lg/.test(block)) continue;
22387
- const rawDescription = block.match(/<p class="max-w-lg[^"]*">([\s\S]*?)<\/p>/)?.[1];
22388
- const description = rawDescription === void 0 ? "" : decodeHtml(rawDescription.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
22389
- const capabilities = [...block.matchAll(/text-(?:indigo-600|cyan-500)[^>]*>([a-z]+)<\/span>/g)].map(
22390
- (m) => m[1]
22391
- );
22392
- const stat = (label) => block.match(
22393
- new RegExp(`<span >([\\d.,KMB]+)<\\/span>\\s*<span class="hidden sm:flex">&nbsp;${label}`)
22394
- )?.[1] ?? null;
22387
+ const rawDescription = block.match(/<p\b[^>]*>([\s\S]*?)<\/p>/)?.[1];
22388
+ if (rawDescription === void 0) continue;
22389
+ const description = decodeHtml(rawDescription.replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
22390
+ const chips = [...block.matchAll(/<span\b[^>]*class="[^"]*\b(?:rounded-md|text-(?:indigo|cyan|blue)-\d+)\b[^"]*"[^>]*>\s*([\w.-]+)\s*<\/span>/g)].map((m) => ({ text: m[1].toLowerCase(), at: m.index }));
22391
+ const first = chips.find((chip) => !SIZE_CHIP.test(chip.text));
22392
+ const groupStart = first ? block.lastIndexOf("<div", first.at) : -1;
22393
+ const groupEnd = groupStart >= 0 ? block.indexOf("</div>", first.at) : -1;
22394
+ const inGroup = (at) => groupStart < 0 || groupEnd < 0 || at > groupStart && at < groupEnd;
22395
+ const words = chips.filter((chip) => !SIZE_CHIP.test(chip.text) && /^[a-z]+$/.test(chip.text));
22396
+ const sizes = chips.filter((chip) => SIZE_CHIP.test(chip.text)).map((chip) => chip.text);
22397
+ const capabilities = [...new Set(words.filter((chip) => inGroup(chip.at)).map((chip) => chip.text))];
22398
+ const labels = [...new Set(words.filter((chip) => !inGroup(chip.at)).map((chip) => chip.text))];
22399
+ const legacyStat = (label) => block.match(new RegExp(`<span >([\\d.,KMB]+)<\\/span>\\s*<span class="hidden sm:flex">&nbsp;${label}`))?.[1] ?? null;
22400
+ const downloads = block.match(/title="([\d,]+) downloads"/)?.[1];
22401
+ const pullsShort = block.match(/title="[\d,]+ downloads"[\s\S]*?<span\s*>([\d.,]+[KMB]?)<\/span>/)?.[1];
22402
+ const tagCount = block.match(/>\s*(\d+)\s+tags?\s*</)?.[1];
22395
22403
  results.push({
22396
22404
  name,
22397
22405
  description: clipText(description, 160),
22398
22406
  capabilities,
22399
- pulls: stat("Pulls"),
22400
- tags: stat("Tag"),
22407
+ ...labels.length ? { labels } : {},
22408
+ ...sizes.length ? { sizes } : {},
22409
+ pulls: pullsShort ?? legacyStat("Pulls"),
22410
+ ...downloads ? { downloads: Number(downloads.replace(/,/g, "")) } : {},
22411
+ tags: tagCount ?? legacyStat("Tag"),
22401
22412
  cloudAvailable: capabilities.includes("cloud")
22402
22413
  });
22403
22414
  }
@@ -22436,6 +22447,130 @@ function parseModelTags(html, family) {
22436
22447
  return { family, tags };
22437
22448
  }
22438
22449
 
22450
+ // src/huggingface.ts
22451
+ var HUB = "https://huggingface.co";
22452
+ var MAX_JSON_BYTES = 4 * 1024 * 1024;
22453
+ function huggingFaceReference(model) {
22454
+ const match = model.match(/^(?:https?:\/\/)?(?:hf\.co|huggingface\.co)\/([\w.-]+)\/([\w.-]+?)(?::([\w.-]+))?$/i);
22455
+ if (!match || [match[1], match[2]].some((part) => part === "." || part === "..")) return null;
22456
+ const repo = `${match[1]}/${match[2]}`;
22457
+ return { repo, family: `hf.co/${repo}`, quant: match[3] ?? null, url: `${HUB}/${repo}` };
22458
+ }
22459
+ var QUANT = /^(?:UD-)?(?:I?Q\d(?:_[A-Z0-9]+)*|BF16|F16|F32|MXFP4(?:_MOE)?|TQ\d_\d)$/i;
22460
+ var SPLIT = /-\d{5}-of-\d{5}$/;
22461
+ function quantOf(path6) {
22462
+ const parts = path6.split("/");
22463
+ const stem = parts.pop().replace(/\.gguf$/i, "").replace(SPLIT, "");
22464
+ for (let index = 0; index <= stem.length; index++) {
22465
+ if (index > 0 && !/[-.]/.test(stem[index - 1])) continue;
22466
+ if (QUANT.test(stem.slice(index))) return stem.slice(index);
22467
+ }
22468
+ return parts.reverse().find((dir) => QUANT.test(dir)) ?? null;
22469
+ }
22470
+ var decimalSize = (bytes) => bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)}GB` : `${Math.round(bytes / 1e6)}MB`;
22471
+ function parseHuggingFaceFiles(tree, repo) {
22472
+ const builds = /* @__PURE__ */ new Map();
22473
+ const projectors = [];
22474
+ for (const entry of tree) {
22475
+ if (entry.type !== "file" || !entry.path || !/\.gguf$/i.test(entry.path)) continue;
22476
+ const file = entry.path.split("/").pop();
22477
+ if (/mmproj/i.test(file)) {
22478
+ projectors.push(entry.path);
22479
+ continue;
22480
+ }
22481
+ const key = entry.path.replace(/-\d{5}-of-(\d{5})\.gguf$/i, "-00001-of-$1.gguf");
22482
+ const bytes = entry.lfs?.size ?? entry.size ?? 0;
22483
+ const build = builds.get(key) ?? { path: key, file: key.split("/").pop(), quant: quantOf(entry.path), bytes: 0, files: 0 };
22484
+ builds.set(key, { ...build, bytes: build.bytes + bytes, files: build.files + 1 });
22485
+ }
22486
+ const perQuant = /* @__PURE__ */ new Map();
22487
+ for (const { quant } of builds.values()) if (quant) perQuant.set(quant.toLowerCase(), (perQuant.get(quant.toLowerCase()) ?? 0) + 1);
22488
+ const tags = [...builds.values()].sort((a, b) => a.bytes - b.bytes).map(({ path: path6, file, quant, bytes, files }) => ({
22489
+ tag: `hf.co/${repo}:${quant && perQuant.get(quant.toLowerCase()) === 1 ? quant : file}`,
22490
+ quant,
22491
+ path: path6,
22492
+ size: bytes ? decimalSize(bytes) : null,
22493
+ sizeBytes: bytes || null,
22494
+ files,
22495
+ cloud: false,
22496
+ // Hugging Face's Ollama guide shows single-file builds; say so rather than promise a pull.
22497
+ ...files > 1 ? { note: `Split into ${files} files; the Hub's Ollama guide documents single-file GGUFs, so confirm the pull.` } : {}
22498
+ }));
22499
+ return { tags, projectors };
22500
+ }
22501
+ function parseHuggingFaceSearch(models) {
22502
+ return models.filter((model) => model.id).map((model) => ({
22503
+ name: `hf.co/${model.id}`,
22504
+ source: "huggingface",
22505
+ task: model.pipeline_tag ?? null,
22506
+ downloads: model.downloads ?? null,
22507
+ likes: model.likes ?? null,
22508
+ updated: model.lastModified ?? null
22509
+ }));
22510
+ }
22511
+ var HUB_EMBEDDING_TASKS = /* @__PURE__ */ new Set(["feature-extraction", "sentence-similarity"]);
22512
+ var HuggingFaceClient = class {
22513
+ constructor(options = {}) {
22514
+ this.options = options;
22515
+ }
22516
+ options;
22517
+ async json(url) {
22518
+ const response = await (this.options.fetch ?? fetch)(url, {
22519
+ method: "GET",
22520
+ headers: { accept: "application/json" },
22521
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? Math.min(DEFAULT_OLLAMA_FETCH_TIMEOUT_SECONDS * 1e3, 1e4))
22522
+ });
22523
+ if (!response.ok) {
22524
+ await response.body?.cancel();
22525
+ throw new Error(response.status === 404 || response.status === 401 ? `Hugging Face has no public model at ${url} (HTTP ${response.status}).` : `Hugging Face returned HTTP ${response.status}.`);
22526
+ }
22527
+ const text = await response.text();
22528
+ if (text.length > MAX_JSON_BYTES) throw new Error("Hugging Face response exceeds the 4 MiB limit.");
22529
+ return JSON.parse(text);
22530
+ }
22531
+ stamp() {
22532
+ return new Date((this.options.now ?? Date.now)()).toISOString();
22533
+ }
22534
+ /** GGUF repositories matching `query`, most downloaded (or newest) first. */
22535
+ async search(query, { limit = 10, order = "popular", task } = {}) {
22536
+ const params = new URLSearchParams({
22537
+ search: query,
22538
+ filter: "gguf",
22539
+ sort: order === "newest" ? "lastModified" : "downloads",
22540
+ direction: "-1",
22541
+ limit: String(limit)
22542
+ });
22543
+ for (const field of ["downloads", "likes", "pipeline_tag", "lastModified"]) params.append("expand[]", field);
22544
+ if (task) params.set("pipeline_tag", task);
22545
+ const url = `${HUB}/api/models?${params}`;
22546
+ return { url, models: parseHuggingFaceSearch(await this.json(url)), fetchedAt: this.stamp() };
22547
+ }
22548
+ /** One repo's pullable quants, task, architecture and context length. */
22549
+ async lookup(model) {
22550
+ const ref = huggingFaceReference(model);
22551
+ if (!ref) throw new Error("Use hf.co/<owner>/<repo>, the name Ollama pulls from Hugging Face.");
22552
+ const info = await this.json(
22553
+ `${HUB}/api/models/${ref.repo}?expand[]=gguf&expand[]=pipeline_tag&expand[]=downloads&expand[]=likes&expand[]=lastModified&expand[]=sha&expand[]=gated`
22554
+ );
22555
+ const tree = await this.json(`${HUB}/api/models/${ref.repo}/tree/${info.sha ?? "main"}?recursive=true`);
22556
+ return {
22557
+ ...ref,
22558
+ task: info.pipeline_tag ?? null,
22559
+ revision: info.sha ?? null,
22560
+ // "auto" or "manual": Hugging Face serves the files only after you accept the repo's terms.
22561
+ gated: info.gated && info.gated !== "false" ? info.gated : false,
22562
+ architecture: info.gguf?.architecture ?? null,
22563
+ contextLength: info.gguf?.context_length ?? null,
22564
+ parameters: info.gguf?.total ?? null,
22565
+ downloads: info.downloads ?? null,
22566
+ likes: info.likes ?? null,
22567
+ updated: info.lastModified ?? null,
22568
+ ...parseHuggingFaceFiles(tree, ref.repo),
22569
+ fetchedAt: this.stamp()
22570
+ };
22571
+ }
22572
+ };
22573
+
22439
22574
  // src/model-knowledge.ts
22440
22575
  var CACHE_TTL_MS = 60 * 60 * 1e3;
22441
22576
  var FAILURE_TTL_MS = 60 * 1e3;
@@ -22713,12 +22848,32 @@ var modelsResultSchema = external_exports.object({
22713
22848
  library: external_exports.unknown().optional()
22714
22849
  }).passthrough();
22715
22850
  var ollamaLibrary = new OllamaLibraryClient();
22851
+ var huggingFace = new HuggingFaceClient();
22716
22852
  var HOST_MEMORY_FIT_FRACTION = 0.6;
22853
+ var HUB_TASKS = /* @__PURE__ */ new Map([
22854
+ ["embedding", ["feature-extraction", "sentence-similarity"]],
22855
+ ["vision", ["image-text-to-text"]]
22856
+ ]);
22857
+ function annotateTags(tags, installed, memoryBytes, embedding) {
22858
+ const budget = memoryBytes ? memoryBytes * HOST_MEMORY_FIT_FRACTION : null;
22859
+ const local = installed ? new Set([...installed].map((name) => name.toLowerCase())) : null;
22860
+ const annotated = tags.map((t) => ({
22861
+ ...t,
22862
+ installed: local ? local.has(t.tag.toLowerCase()) : null,
22863
+ fitsInMemory: budget && t.sizeBytes ? t.sizeBytes <= budget : null,
22864
+ fitScope: "host_memory_budget_only"
22865
+ }));
22866
+ const sized = annotated.filter((t) => t.fitsInMemory === true && !t.cloud && t.sizeBytes);
22867
+ const best = budget ? sized.sort((a, b) => embedding ? (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0) : (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0))[0] : void 0;
22868
+ return { annotated, budget, best };
22869
+ }
22870
+ var NO_PROFILE = "No machine profile (freellama serve unreachable), so memory fit could not be checked and no tag is recommended. Start serve, or read the sizes yourself.";
22717
22871
  async function libraryLookup({
22718
22872
  model,
22719
22873
  query,
22720
22874
  capabilities,
22721
22875
  order,
22876
+ source,
22722
22877
  limit,
22723
22878
  cursor,
22724
22879
  includeReadme,
@@ -22746,6 +22901,40 @@ async function libraryLookup({
22746
22901
  return errorResult(new Error("Library step 1 has no `cursor`; it pages step-2 tags. Use `limit` for search, or pass `model`."));
22747
22902
  }
22748
22903
  try {
22904
+ if (model && huggingFaceReference(model)) {
22905
+ const hub2 = await huggingFace.lookup(model);
22906
+ const { installed: installed2, installedUnavailable: installedUnavailable2, memoryBytes } = await localState();
22907
+ const embedding = HUB_EMBEDDING_TASKS.has(hub2.task ?? "");
22908
+ const { annotated, budget, best } = annotateTags(hub2.tags, installed2, memoryBytes, embedding);
22909
+ const tagPage = pageLiveList(annotated, limit, cursor, (tag) => tag.tag);
22910
+ return structuredResult({
22911
+ family: hub2.family,
22912
+ source: "huggingface",
22913
+ sourcePage: hub2.url,
22914
+ library: {
22915
+ status: hub2.tags.length ? "available" : "no_gguf",
22916
+ fetchedAt: hub2.fetchedAt,
22917
+ task: hub2.task,
22918
+ revision: hub2.revision,
22919
+ ...hub2.gated ? { gated: hub2.gated, gatedNote: "Accept the repo's terms on Hugging Face first; its Ollama guide documents private pulls through your Ollama SSH key added to your Hugging Face account." } : {},
22920
+ architecture: hub2.architecture,
22921
+ contextLength: hub2.contextLength,
22922
+ parameters: hub2.parameters,
22923
+ downloads: hub2.downloads,
22924
+ likes: hub2.likes,
22925
+ updated: hub2.updated,
22926
+ ...hub2.projectors.length ? { visionProjectors: hub2.projectors } : {}
22927
+ },
22928
+ ...installedUnavailable2 ? { installedUnavailable: installedUnavailable2 } : {},
22929
+ machineMemoryBytes: memoryBytes,
22930
+ fitBudgetBytes: budget,
22931
+ tags: tagPage.items,
22932
+ page: { returned: tagPage.returned, total: tagPage.total, next_cursor: tagPage.next_cursor },
22933
+ pull: hub2.tags.length ? `ollama_manage {action:"pull", model:"<tag>"} after approval; Ollama picks Q4_K_M for a bare ${hub2.family} when the repo has it.` : "This repository has no GGUF files Ollama can pull.",
22934
+ recommendationUnavailable: budget ? void 0 : NO_PROFILE,
22935
+ recommendation: best ? libraryTrialCandidate(best.tag, embedding) : null
22936
+ });
22937
+ }
22749
22938
  if (model) {
22750
22939
  const ref = libraryReference(model);
22751
22940
  if (!ref) throw new Error("Use an exact Ollama family or namespace/model name, not a URL or path.");
@@ -22761,20 +22950,12 @@ async function libraryLookup({
22761
22950
  });
22762
22951
  const tags = library.tags;
22763
22952
  const { installed: installed2, installedUnavailable: installedUnavailable2, memoryBytes } = await localState();
22764
- const budget = memoryBytes ? memoryBytes * HOST_MEMORY_FIT_FRACTION : null;
22765
- const annotated = tags.map((t) => ({
22766
- ...t,
22767
- installed: installed2 ? installed2.has(t.tag) : null,
22768
- fitsInMemory: budget && t.sizeBytes ? t.sizeBytes <= budget : null,
22769
- fitScope: "host_memory_budget_only"
22770
- }));
22771
- const runnable = annotated.filter((t) => t.fitsInMemory === true && !t.cloud);
22772
22953
  const isEmbeddingFamily = library.card.capabilities.includes("embedding");
22773
- const sized = runnable.filter((t) => t.sizeBytes);
22774
- const best = budget ? isEmbeddingFamily ? sized.sort((a, b) => (a.sizeBytes ?? 0) - (b.sizeBytes ?? 0))[0] : sized.sort((a, b) => (b.sizeBytes ?? 0) - (a.sizeBytes ?? 0))[0] : void 0;
22954
+ const { annotated, budget, best } = annotateTags(tags, installed2, memoryBytes, isEmbeddingFamily);
22775
22955
  const tagPage = pageLiveList(annotated, limit, cursor, (tag) => tag.tag);
22776
22956
  return structuredResult({
22777
22957
  family,
22958
+ source: "ollama",
22778
22959
  sourcePage: ref.url,
22779
22960
  library: {
22780
22961
  status: library.status,
@@ -22790,7 +22971,7 @@ async function libraryLookup({
22790
22971
  fitBudgetBytes: budget,
22791
22972
  tags: tagPage.items,
22792
22973
  page: { returned: tagPage.returned, total: tagPage.total, next_cursor: tagPage.next_cursor },
22793
- recommendationUnavailable: budget ? void 0 : "No machine profile (freellama serve unreachable), so memory fit could not be checked and no tag is recommended. Start serve, or read the sizes yourself.",
22974
+ recommendationUnavailable: budget ? void 0 : NO_PROFILE,
22794
22975
  recommendation: best ? libraryTrialCandidate(best.tag, isEmbeddingFamily) : null
22795
22976
  });
22796
22977
  }
@@ -22798,20 +22979,44 @@ async function libraryLookup({
22798
22979
  for (const c of capabilities ?? []) params.append("c", c);
22799
22980
  if (query) params.set("q", query);
22800
22981
  if (order === "newest") params.set("o", "newest");
22801
- const search = await ollamaLibrary.search(params);
22802
- const url = search.url;
22803
- const parsed = search.models.slice(0, limit ?? 10);
22982
+ const hubTasks = (capabilities ?? []).map((c) => HUB_TASKS.get(c));
22983
+ const hubSkipped = source === "ollama" ? "source is ollama" : !query ? "the Hub is searched only with a query" : hubTasks.length > 1 || hubTasks.some((tasks) => !tasks) ? `the Hub has no filter for ${(capabilities ?? []).filter((c) => !HUB_TASKS.has(c)).join(", ") || "several capabilities at once"}` : null;
22984
+ if (source === "huggingface" && hubSkipped) throw new Error(`source "huggingface" cannot be searched: ${hubSkipped}.`);
22985
+ const searchHub = async () => {
22986
+ const results = await Promise.all((hubTasks[0] ?? [void 0]).map((task) => huggingFace.search(query, { limit: limit ?? 10, order, task })));
22987
+ const seen = /* @__PURE__ */ new Set();
22988
+ const models = results.flatMap((r) => r.models).filter((m) => !seen.has(m.name) && seen.add(m.name)).sort((a, b) => order === "newest" ? String(b.updated).localeCompare(String(a.updated)) : (b.downloads ?? 0) - (a.downloads ?? 0)).slice(0, limit ?? 10);
22989
+ return { url: results[0].url, models, fetchedAt: results[0].fetchedAt };
22990
+ };
22991
+ const [ollamaSearch, hubSearch] = await Promise.allSettled([
22992
+ source === "huggingface" ? Promise.resolve(null) : ollamaLibrary.search(params),
22993
+ hubSkipped ? Promise.resolve(null) : searchHub()
22994
+ ]);
22995
+ const tried = [source === "huggingface" ? null : ollamaSearch, hubSkipped ? null : hubSearch].filter(Boolean);
22996
+ if (tried.every((result) => result.status === "rejected")) {
22997
+ throw new Error(tried.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason)).join("; "));
22998
+ }
22999
+ const ollama = ollamaSearch.status === "fulfilled" ? ollamaSearch.value : null;
23000
+ const hub = hubSearch.status === "fulfilled" ? hubSearch.value : null;
23001
+ const ollamaRows = (ollama?.models ?? []).slice(0, limit ?? 10).map((m) => ({ ...m, source: "ollama" }));
23002
+ const hubRows = hub?.models ?? [];
22804
23003
  const { installed, installedUnavailable } = await localState();
22805
- const installedFamilies = installed ? new Set([...installed].map((n) => n.split(":")[0])) : null;
23004
+ const local = installed ? [...installed].map((n) => n.toLowerCase()) : null;
23005
+ const isInstalled = (name) => local ? local.some((n) => n.split(":")[0] === name.toLowerCase()) : null;
23006
+ const reason = (result) => result.status === "rejected" ? result.reason instanceof Error ? result.reason.message : String(result.reason) : void 0;
22806
23007
  return structuredResult({
22807
- query: url,
22808
- fetchedAt: search.fetchedAt,
22809
- ...parsed.length === 0 ? { searchUnavailable: "No result cards were recognized. The query may have no matches or Ollama's markup may have changed; inspect the source URL." } : {},
23008
+ query: ollama?.url ?? hub?.url ?? null,
23009
+ ...ollama && hub ? { hubQuery: hub.url } : {},
23010
+ fetchedAt: ollama?.fetchedAt ?? hub?.fetchedAt ?? null,
23011
+ ...ollama && ollamaRows.length === 0 ? { searchUnavailable: "No ollama.com result cards were recognized. The query may have no matches or Ollama's markup may have changed; inspect the source URL." } : {},
23012
+ ...reason(ollamaSearch) ? { ollamaUnavailable: reason(ollamaSearch) } : {},
23013
+ ...reason(hubSearch) ? { huggingFaceUnavailable: reason(hubSearch) } : {},
23014
+ ...hubSkipped && source !== "ollama" ? { huggingFaceSkipped: hubSkipped } : {},
22810
23015
  order: order ?? "popular",
22811
- count: parsed.length,
23016
+ count: ollamaRows.length + hubRows.length,
22812
23017
  ...installedUnavailable ? { installedUnavailable } : {},
22813
- nextStep: 'Not pullable yet. Call again with view:"library", model:"<name>" to get tags, sizes and memory fit.',
22814
- models: parsed.map((m) => ({ ...m, installed: installedFamilies ? installedFamilies.has(m.name) : null }))
23018
+ nextStep: 'Not pullable yet. Call again with view:"library", model:"<name>" (an Ollama family or hf.co/<owner>/<repo>) to get tags, sizes and memory fit.',
23019
+ models: [...ollamaRows, ...hubRows].map((m) => ({ ...m, installed: isInstalled(m.name) }))
22815
23020
  });
22816
23021
  } catch (error2) {
22817
23022
  return errorResult(error2);
@@ -22821,7 +23026,7 @@ function registerModelsTool(server2) {
22821
23026
  server2.registerTool(
22822
23027
  "models",
22823
23028
  {
22824
- description: "Use when: listing installed or loaded models with capabilities, policy rank and memory fit, inspecting one model, or searching ollama.com (view:library). Do not use when: running work (run_task) or pulling (ollama_manage). Returns: models; library lists families first, then one family's tags.",
23029
+ description: "Use when: listing installed or loaded models with capabilities, policy rank and memory fit, inspecting one model, or searching ollama.com and Hugging Face GGUF repos (view:library). Do not use when: running work (run_task) or pulling (ollama_manage). Returns: models; library lists families first, then one family's tags.",
22825
23030
  inputSchema: external_exports.object({
22826
23031
  view: external_exports.enum(["installed", "resident", "detail", "raw", "library"]).optional(),
22827
23032
  model: external_exports.string().min(1).optional(),
@@ -22829,8 +23034,9 @@ function registerModelsTool(server2) {
22829
23034
  includeLibrary: external_exports.boolean().optional(),
22830
23035
  includeReadme: external_exports.boolean().optional(),
22831
23036
  query: external_exports.string().min(1).optional(),
22832
- capabilities: external_exports.array(external_exports.enum(["vision", "tools", "thinking", "embedding", "cloud"])).min(1).max(5).optional(),
23037
+ capabilities: external_exports.array(external_exports.string().regex(/^[a-z][a-z-]*$/).describe("ollama.com capability word, e.g. vision, tools, thinking, embedding, audio, decision, cloud")).min(1).max(8).optional(),
22833
23038
  order: external_exports.enum(["popular", "newest"]).optional(),
23039
+ source: external_exports.enum(["ollama", "huggingface"]).optional(),
22834
23040
  limit: external_exports.number().int().positive().max(50).optional(),
22835
23041
  cursor: external_exports.string().min(1).optional(),
22836
23042
  endpoint: endpointParam,
@@ -22839,7 +23045,7 @@ function registerModelsTool(server2) {
22839
23045
  outputSchema: modelsResultSchema,
22840
23046
  annotations: { readOnlyHint: true }
22841
23047
  },
22842
- async ({ view, model, includeVerbose, includeLibrary, includeReadme, query, capabilities, order, limit, cursor, endpoint, ollamaEndpoint }) => {
23048
+ async ({ view, model, includeVerbose, includeLibrary, includeReadme, query, capabilities, order, source, limit, cursor, endpoint, ollamaEndpoint }) => {
22843
23049
  try {
22844
23050
  const selectedView = view ?? "installed";
22845
23051
  if (includeLibrary !== void 0 && selectedView !== "installed" && selectedView !== "detail") {
@@ -22848,7 +23054,7 @@ function registerModelsTool(server2) {
22848
23054
  if (includeReadme !== void 0 && !(selectedView === "library" && model || selectedView === "detail" && includeLibrary === true)) {
22849
23055
  return errorResult(new Error('`includeReadme` needs library step 2, or view "detail" with `includeLibrary:true`.'));
22850
23056
  }
22851
- if (selectedView !== "library" && [query, capabilities, order].some((value) => value !== void 0)) {
23057
+ if (selectedView !== "library" && [query, capabilities, order, source].some((value) => value !== void 0)) {
22852
23058
  return errorResult(new Error(`view "${selectedView}" does not accept library search fields.`));
22853
23059
  }
22854
23060
  if (selectedView !== "library" && selectedView !== "raw" && [limit, cursor].some((value) => value !== void 0)) {
@@ -22860,7 +23066,7 @@ function registerModelsTool(server2) {
22860
23066
  if (selectedView !== "detail" && selectedView !== "library" && model !== void 0) {
22861
23067
  return errorResult(new Error('`model` is valid only for views "detail" and "library".'));
22862
23068
  }
22863
- if (selectedView === "library" && model && [query, capabilities, order].some((value) => value !== void 0)) {
23069
+ if (selectedView === "library" && model && [query, capabilities, order, source].some((value) => value !== void 0)) {
22864
23070
  return errorResult(
22865
23071
  new Error("Library step 2 accepts only `model` plus endpoint overrides; omit step-1 search fields.")
22866
23072
  );
@@ -22923,7 +23129,7 @@ function registerModelsTool(server2) {
22923
23129
  });
22924
23130
  }
22925
23131
  case "library":
22926
- return await libraryLookup({ model, query, capabilities, order, limit, cursor, includeReadme, endpoint, ollamaEndpoint });
23132
+ return await libraryLookup({ model, query, capabilities, order, source, limit, cursor, includeReadme, endpoint, ollamaEndpoint });
22927
23133
  default: {
22928
23134
  const result = parsedResult(await listModels2(endpoint));
22929
23135
  if (!isStructuredSuccess(result)) return result;
@@ -395,9 +395,11 @@ authority. Configured settings alone do not prove physical processor placement.
395
395
  The shared [resource governor](https://github.com/bgauryy/FreeLlama/blob/main/packages/rust-core/src/platform/resources/mod.rs) samples local
396
396
  available-memory estimates, load per logical CPU, OS memory pressure, active swap-out growth, and
397
397
  thermal throttling when reported. OS memory pressure holds every local demand; active swap-out
398
- growth holds new loads; CPU-load and thermal holds apply to CPU-assigned work and to cold loads
399
- (decoding weights is CPU work and a new heat source) but not to a bounded request on a runner
400
- already resident on the GPU, which neither waits for the cores nor cools the package by waiting (`ResourceDemand::is_held_by`; the assessment's
398
+ growth holds new loads; the CPU-load hold applies only to CPU-assigned work (saturated cores slow
399
+ nothing on the GPU, so raw passthrough, which cannot know residency, is not refused for it), and to
400
+ all work on a host whose sampler sees no accelerator; the
401
+ thermal hold applies to CPU-assigned work and to cold loads (a new heat source) but not to a bounded
402
+ request on a runner already resident on the GPU (`ResourceDemand::is_held_by`; the assessment's
401
403
  `held_by` lists the holds that applied). Low available RAM (below the larger of 15% or 1 GiB, clearing after two
402
404
  healthy samples above the larger of 20% or 2 GiB) depends on the platform (`ResourcePolicy::low_memory`).
403
405
  A new load must leave that reserve free, but never more than its own size above the 1 GiB floor.
package/docs/CLI.md CHANGED
@@ -148,10 +148,13 @@ cache (and is the hard cap in a container), the same shortfall unloads idle runn
148
148
  new load with `held_insufficient_capacity` until capacity returns, refusing with 503 at its deadline.
149
149
  OS memory pressure holds all work where the OS reports it (macOS, and Linux when the kernel
150
150
  exposes `/proc/pressure/memory`; Windows has no host collector yet). Active swapping and low RAM hold new loads only:
151
- an already-resident runner adds no memory and stays admitted. CPU-load (one-minute load at or above
152
- 1.5 per logical CPU) and thermal-throttling holds apply to CPU-assigned work and to cold loads, which
153
- decode weights on the host cores and add a new heat source; a bounded request on a runner already
154
- resident on the GPU neither waits for the cores nor cools the package by waiting, so it proceeds. The receipt's `resource_admission.assessment.held_by`
151
+ an already-resident runner adds no memory and stays admitted. The CPU-load hold (one-minute load at
152
+ or above 1.5 per logical CPU) applies only to CPU-assigned work, which decodes on those cores; GPU
153
+ work, loaded or not, proceeds, so another program's build never blocks a GPU model. On a host where
154
+ the sampler sees no accelerator (no GPU device or GPU memory), every model runs on the CPU, so the
155
+ hold applies to all work there. The
156
+ thermal-throttling hold applies to CPU-assigned work and to cold loads, a new heat source; a bounded
157
+ request on a runner already resident on the GPU proceeds. The receipt's `resource_admission.assessment.held_by`
155
158
  lists the holds that applied to the request.
156
159
  Both `serve` and `proxy` accept `--resource-telemetry-policy`:
157
160
 
@@ -4,8 +4,9 @@ To offload a task, select an installed model with the required capabilities and
4
4
  then check its memory fit under the intended concurrency. FreeLlama applies model eligibility
5
5
  and admission; the calling agent still verifies the output.
6
6
 
7
- The measurements in this guide come from one Apple M4 Pro machine with 48 GB unified memory and
8
- are not compiled into routing. Re-run
7
+ The measurements in this guide come from single machines and are not compiled into routing:
8
+ [Recommended starting points](#recommended-starting-points-by-form) from an Apple M2 Pro with 32 GB,
9
+ the sections after it from an Apple M4 Pro with 48 GB unified memory. Re-run
9
10
  `npx @octocodeai/freellama doctor`, `models`, `bench-all`, and your quality suite on the target hardware.
10
11
  `doctor.machine.memory_bytes` is host RAM; a discrete GPU's usable VRAM must be observed separately.
11
12
 
@@ -26,6 +27,46 @@ flowchart TD
26
27
  M -->|"Yes"| B["Benchmark locally and add policy evidence"]
27
28
  ```
28
29
 
30
+ ## Recommended starting points by form
31
+
32
+ Measured on an Apple M2 Pro with 32 GB, Ollama 0.40.8 and FreeLlama 0.6.1 on 2026-10-30, while
33
+ other builds kept the one-minute load near 14 on 12 cores. Every check had a known answer; the
34
+ batteries are small, so treat these as first picks to re-check on your machine, not rankings.
35
+
36
+ | Form | Model (download) | Measured | Speed (warm) | How an agent calls it |
37
+ |---|---|---|---|---|
38
+ | Text, tools, JSON, reasoning | `gpt-oss:20b` (13.8 GB) | 7/7: exact output, arithmetic, bat-and-ball, code reading, JSON extraction, tool call, 9/9 ticket routing | 1.4 s per routed ticket | `run_task {model:"gpt-oss:20b", prompt, think:"low"}`; add `tools` or `format:"json"` |
39
+ | Embeddings | `nomic-embed-text:v1.5` (274 MB) | Retrieval 4/4 top-1; ticket routing 9/9 from two examples per team | 51 ms per item, batched | `run_task {task:"embedding", input:[...], returnEmbeddings:true}` |
40
+ | Decisions (classify, route, score) | `strands-decider:2b-nvfp4` (1.3 GB) | Ticket routing 9/9, return-policy yes/no 3/3, damage rubric 2/3 | 0.17 s per question | `POST <serve>/v1/systemone {model, state, questions}`: passthrough, no MCP tool yet |
41
+ | OCR | `glm-ocr:latest` (2.2 GB) | 4/4 phrases from a dense report strip; 0/7 on text tasks | 11 s per strip at a 400-token cap | `run_task {task:"vision", model:"glm-ocr", prompt:"Text Recognition:", images:[...], stopOnRepetition:true}` |
42
+
43
+ `gpt-oss:20b` reasons before answering; the battery ran it with `think:"low"` and a 2,048-token
44
+ output cap, so leave room for the reasoning when you set `num_predict`. `glm-ocr` reads text from images only and keeps generating
45
+ after the visible text ends, so cap its output or set `stopOnRepetition`. The decision model needs
46
+ Apple silicon and answers only through Ollama's decision API; reach it through `serve` so the call
47
+ still passes FreeLlama's memory checks and stream cap (from 0.6.2 a busy CPU no longer refuses it).
48
+
49
+ ### Classify text three ways
50
+
51
+ The same nine support tickets, routed to billing, technical or returns:
52
+
53
+ | Approach | Model | Correct | Per item | Use when |
54
+ |---|---|---:|---:|---|
55
+ | Embeddings and per-class centroids | `nomic-embed-text:v1.5` | 9/9 | 51 ms | Many items, stable classes, a few labeled examples per class |
56
+ | Decision model | `strands-decider:2b-nvfp4` | 9/9 | 0.5 s (first call includes the load) | You need probabilities, yes/no policy checks or rubric scores |
57
+ | Instruction model with JSON output | `gpt-oss:20b` | 9/9 | 1.4 s | Classes change per request or need explanation |
58
+
59
+ A decision API request names its questions; each answer comes back under the same name:
60
+
61
+ ```bash
62
+ curl -s http://127.0.0.1:11435/v1/systemone -H 'content-type: application/json' -d '{
63
+ "model": "strands-decider:2b-nvfp4",
64
+ "state": "Why did my credit card get billed two times?",
65
+ "questions": {"team": {"type": "choice", "instructions": "Which team handles this ticket?",
66
+ "criteria": {"billing": "Charges and invoices", "technical": "App errors", "returns": "Returns and refunds"}}}
67
+ }'
68
+ ```
69
+
29
70
  ## Text, code, and vision
30
71
 
31
72
  `qwen3.8:27b-mlx` was the strongest general model in the local evaluations. It handled coding,
@@ -42,8 +42,10 @@ release-artifacts release`, then `yarn release:verify:publish`. The publish veri
42
42
  Live E2E and hardware qualification remain separate operator checks; verify those receipts before tagging.
43
43
 
44
44
  Attach `release/SHA256SUMS` and binaries to the release. Publish the eight `@octocodeai/freellama-native-*` packages
45
- first, followed by the exact-version `@octocodeai/freellama` and `@octocodeai/freellama-mcp-server` packages. Configure
46
- registry credentials in the release environment; never store them in the repository.
45
+ first, followed by the exact-version `@octocodeai/freellama` and `@octocodeai/freellama-mcp-server` packages. Publish
46
+ the Rust crates from the same commit with `cargo publish --workspace` (it uploads `freellama-core`
47
+ before `freellama-cli`; run it with `--dry-run` first). Configure registry credentials (`npm login`,
48
+ `cargo login`) in the release environment; never store them in the repository.
47
49
 
48
50
  ## Configure Ollama explicitly
49
51
 
package/docs/TESTING.md CHANGED
@@ -210,7 +210,8 @@ yarn release:verify:publish
210
210
  Build every claimed CLI/native target in a clean environment, assemble `SHA256SUMS`, run the full
211
211
  deterministic suite, and inspect every platform package dry run before publishing explicitly.
212
212
  Publish the eight `@octocodeai/freellama-native-*` packages before `@octocodeai/freellama` and `@octocodeai/freellama-mcp-server`, all
213
- at the exact same version. A successful local pack dry run proves package contents; it does not
213
+ at the exact same version, then the crates with `cargo publish --workspace` (`--dry-run` first packages
214
+ and compiles both crates from their published sources). A successful local pack dry run proves package contents; it does not
214
215
  prove registry publication or another hardware class.
215
216
 
216
217
  ## Test one layer while iterating
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octocodeai/freellama-mcp-server",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "MCP server exposing FreeLlama core as tools, via native NAPI bindings into packages/rust-core.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -50,14 +50,14 @@
50
50
  "zod": "^3.24.1"
51
51
  },
52
52
  "optionalDependencies": {
53
- "@octocodeai/freellama-native-darwin-arm64": "0.6.1",
54
- "@octocodeai/freellama-native-darwin-x64": "0.6.1",
55
- "@octocodeai/freellama-native-linux-arm64-gnu": "0.6.1",
56
- "@octocodeai/freellama-native-linux-arm64-musl": "0.6.1",
57
- "@octocodeai/freellama-native-linux-x64-gnu": "0.6.1",
58
- "@octocodeai/freellama-native-linux-x64-musl": "0.6.1",
59
- "@octocodeai/freellama-native-win32-arm64-msvc": "0.6.1",
60
- "@octocodeai/freellama-native-win32-x64-msvc": "0.6.1"
53
+ "@octocodeai/freellama-native-darwin-arm64": "0.6.2",
54
+ "@octocodeai/freellama-native-darwin-x64": "0.6.2",
55
+ "@octocodeai/freellama-native-linux-arm64-gnu": "0.6.2",
56
+ "@octocodeai/freellama-native-linux-arm64-musl": "0.6.2",
57
+ "@octocodeai/freellama-native-linux-x64-gnu": "0.6.2",
58
+ "@octocodeai/freellama-native-linux-x64-musl": "0.6.2",
59
+ "@octocodeai/freellama-native-win32-arm64-msvc": "0.6.2",
60
+ "@octocodeai/freellama-native-win32-x64-msvc": "0.6.2"
61
61
  },
62
62
  "author": "Guy Bary",
63
63
  "license": "Apache-2.0 OR MIT",