anyformer 0.1.0 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,111 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 — 2026-10-23
4
+
5
+ ### Changed
6
+ - Nothing loads from the LocalMind repo any more. diskformer v0.6.4 (095ab20) holds LocalMind's whole-model
7
+ engines too (LFM2.5, Gemma 4 E2B/E4B, Ternary Bonsai 2 27B; MIT, confirmed by their author), and the `localmind`
8
+ back end loads them from diskformer's `engines/`. The id stays `localmind`. `PINS.localmind` and
9
+ `SOURCES.localmind` are gone.
10
+ - LFM2.5 230M through the `localmind` back end from diskformer's copy: the same reply as from LocalMind's commit
11
+ 203288a (the engine file is byte-identical), on topic.
12
+
13
+ ## 0.3.1 — 2026-10-23
14
+
15
+ ### Added
16
+ - MiniCPM-V 4.7 35B-A3B (openbmb/MiniCPM-V-4.7-35B-A3B-gguf, Q4_0, 19.7 GB) on the `diskformer` back end, through
17
+ diskformer's `MiniCpmV47Ssd`: text, and images through `ensureVision()` and `{ type: 'image', rgb, width, height }`
18
+ blocks. Floor 4 GB (Qwen3.6's; the smallest working budget is unmeasured); 14.1 tok/s over short continuations at a
19
+ 4 GiB pool, ~6.1 GB on the GPU (LocalMind's gate). It ranks before `diskformer-llama`, which has no image path.
20
+ Not yet run through anyformer in a browser; LocalMind's live check is that check.
21
+
22
+ ## 0.3.0 — 2026-10-23
23
+
24
+ What LocalMind's worker uses from its disk-tier engines, so its models can move onto anyformer with nothing lost.
25
+
26
+ ### Added
27
+ - `generate()` chunks on the `diskformer` back end carry `token`, the engine's token id.
28
+ - `session.think`: `{ openId, closeId, inPrompt, preamble, always }`, where a model's reasoning starts and ends as
29
+ token ids (Gemma 4's thought channel, Qwen3.6 and Ling's `<think>`); null where the back end has none.
30
+ - `session.prime(system)`, where the engine has it (Qwen3.6 35B): checkpoints a system prompt after load.
31
+ - `session.ensureVision({ onProgress })`, where the engine has it; image blocks `{ type: 'image', rgb, width, height }`
32
+ pass through to such an engine. No vision model is in `DISKFORMER_MODELS` yet.
33
+ - `load(repo, { poolBytes })`: the expert cache size, passed to the engine; the budget then only admits the model.
34
+ - `load(repo, { localFile })`: a GGUF the user picked, ingested instead of downloaded.
35
+
36
+ ## 0.2.5 — 2026-10-23
37
+
38
+ ### Changed
39
+ - diskformer pinned to v0.6.3 (1d8b418): Ling 3.0 Flash loads a complete store without reading the parts' headers
40
+ from Hugging Face; the browser llama.cpp build is rebuilt (split GGUFs still refused there). Granite 4.0 H Tiny
41
+ through `diskformer-llama` on the new build: 9/9, chat path = reference. The hand engines anyformer gated are
42
+ unchanged (`qwen3_moe_ssd.js` only adds an export).
43
+
44
+ ## 0.2.4 — 2026-10-23
45
+
46
+ ### Changed
47
+ - The `diskformer` back end's `generate()` honours `thinking`, as `diskformer-llama` does since 0.2.1: off by default,
48
+ `{ thinking: true }` lets Gemma 4, Qwen3.6 or Ling 3.0 Flash reason first (the engines' `enableThinking`).
49
+
50
+ ## 0.2.3 — 2026-10-23
51
+
52
+ ### Added
53
+ - Ling 3.0 Flash (inclusionAI/Ling-3.0-flash-GGUF, two-part Q4_K_M, 77 GB) on the `diskformer` back end, through
54
+ diskformer's `LingMoeSsd` hand engine: from a 6.8 GB budget, 5.53 tok/s and an 80.7 GB OPFS store (LocalMind's gate).
55
+ Not yet run through anyformer in a browser: LocalMind's live check through anyformer is that check.
56
+ - `describe()` keeps the Hub's last answer per repo in Cache Storage (`anyformer-describe-v1`) and uses it when the
57
+ network fails, so a downloaded model loads offline.
58
+
59
+ ### Changed
60
+ - diskformer pinned to v0.6.2 (f5f1177): adds `ling_moe_ssd.js` and two quant formats to `iq_quants.js`, which no
61
+ engine gated earlier imports; the earlier gates stand.
62
+
63
+ ## 0.2.2 — 2026-10-23
64
+
65
+ ### Added
66
+ - `load(repo, { file })` and `plan(repo, { file })`: load a chosen GGUF from the repo; a back end that cannot load
67
+ that file refuses with code `model`.
68
+
69
+ ### Changed
70
+ - `diskformer-llama` loads the file diskformer checked when the repo has it (`CHECKED_FILES`, from diskformer's
71
+ catalog and coverage table), else the preferred quant. GLM-4.7-Flash now defaults to Q3_K_M (14.6 GB, gated 8/9 by
72
+ diskformer), not Q4_K_M (18.3 GB, not gated). Granite 4.0 H Tiny and Gemma 4 26B keep the files their gates used.
73
+
74
+ ## 0.2.1 — 2026-10-23
75
+
76
+ ### Changed
77
+ - diskformer pinned to v0.6.1 (5bccf32): its llama.cpp build takes a thinking switch. `diskformer-llama`'s
78
+ `generate()` now turns thinking off by default (`thinking: true` turns it on), so a model that reasons first answers
79
+ directly, as on the other back ends.
80
+
81
+ ### Measured (Chrome, M4 Pro; correctness only)
82
+ - Granite 4.0 H Tiny via `diskformer-llama`: 9/9; the chat path matches the reference.
83
+ - Gemma 4 26B-A4B via `diskformer-llama`: 8/9 (miss: system-pirate, as diskformer's own run); the chat path matches the
84
+ reference, with no thought block. The hand engines are unchanged in v0.6.1, so their 9/9 at 0.2.0 stands.
85
+
86
+ ## 0.2.0 — 2026-10-22
87
+
88
+ ### Added
89
+ - `diskformer-llama` back end: mixture-of-experts GGUFs through diskformer's llama.cpp path (`diskformer/llama`,
90
+ llama.cpp with WebGPU in a worker, experts paged from OPFS), for the 17 architectures diskformer has checked
91
+ (`LLAMA_ARCHITECTURES`). A diskformer hand-written engine still ranks first where it covers the model.
92
+ - `probe()` reports `jspi` (WebAssembly JSPI), which the llama.cpp build needs.
93
+ - `stored()` and `remove()` cover the llama.cpp path's GGUFs in OPFS (`gguf/`).
94
+
95
+ ### Changed
96
+ - diskformer pinned to v0.6.0 (439ad4e) for both its hand engines and the llama.cpp path.
97
+
98
+ ### Measured (Chrome, M4 Pro)
99
+ - Granite 4.0 H Tiny through `diskformer-llama`: 9/9 replies identical to diskformer's reference
100
+ (`npm run test:browser -- llama-gate`).
101
+ - Gemma 4 26B-A4B through `diskformer-llama`: 8/9 identical to llama.cpp b9830, the same 8/9 (miss: system-pirate)
102
+ diskformer records for its own Chrome run. The resolver still sends this repo to the hand engine.
103
+ - Gemma 4 26B-A4B on the hand engine at the new pin: 9/9 identical (`diskformer-chat`).
104
+
105
+ ### Known gaps
106
+ - `diskformer-llama`'s chat path does not turn thinking off: a model that reasons first (Gemma 4, Qwen3.6, gpt-oss)
107
+ begins its reply with its thought block. Closed in 0.2.1.
108
+
3
109
  ## 0.1.0 — 2026-10-14
4
110
 
5
111
  The first release with code. `anyformer@0.0.1` reserved the name and held none.
package/README.md CHANGED
@@ -58,7 +58,8 @@ back end's answer: yes with an estimate (GPU bytes, disk bytes, tok/s when measu
58
58
  | `kohra` | kohra, text-diffusion over onnxruntime-web | MDLM and BD3LM repos tagged `text-diffusion` |
59
59
  | `localmind` | LocalMind's hand-written WebGPU kernels, the whole model on the GPU | any LFM2 / LFM2.5 GGUF; Gemma 4 E2B / E4B QAT-mobile; Ternary Bonsai 2 |
60
60
  | `wllama` | llama.cpp in WebAssembly: WebGPU when the budget holds the model, the CPU otherwise (no WebGPU needed) | any GGUF up to about 3.6 GB that no engine above covers; files over 2 GB are split into OPFS shards once |
61
- | `diskformer` | diskformer engines: weights in OPFS, only what a step needs on the GPU | Gemma 4 26B-A4B (from 2.5 GB), Qwen3.6 35B-A3B (from 4 GB) |
61
+ | `diskformer` | diskformer engines: weights in OPFS, only what a step needs on the GPU | Gemma 4 26B-A4B (from 2.5 GB), Qwen3.6 35B-A3B (from 4 GB), Ling 3.0 Flash 124B (from 6.8 GB), MiniCPM-V 4.7 with images (from 4 GB) |
62
+ | `diskformer-llama` | diskformer's llama.cpp build with WebGPU, mixture-of-experts layers paged in from OPFS | GGUFs of the 17 MoE architectures diskformer has checked (Granite 4.0 H, gpt-oss, GLM-4.7-Flash, LFM2 MoE…); a hand engine above wins where it covers the model |
62
63
 
63
64
  Each runtime loads from an exact version or commit in [src/sources.js](src/sources.js); set a back end's `.source`
64
65
  (for example `kohraBackend.source`) to load another copy. `BACKENDS` lists the back ends `load()` asks by default: each joins after its browser test passes.
@@ -66,8 +67,7 @@ Each runtime loads from an exact version or commit in [src/sources.js](src/sourc
66
67
  ## When no back end can run it
67
68
 
68
69
  `load()` throws an `AnyformerError` with code `NO_BACKEND` and the plan, naming each back end, its code and its
69
- reason. Codes are a closed set, each with one
70
- remedy in `REFUSALS`: `format`, `model`, `task`, `webgpu`, `f16`, `opfs`, `budget` (with `need`, the budget that would pass)
70
+ reason. Codes are a closed set, each with one remedy in `REFUSALS`: `format`, `model`, `task`, `webgpu`, `f16`, `opfs`, `budget` (with `need`, the budget that would pass)
71
71
  and `threw`. Every other failure is an `AnyformerError` too, with a code from `ERROR_CODES` (`LOAD_FAILED`,
72
72
  `BUDGET_TOO_SMALL`, `ABORTED`, `EXECUTION_FAILED`, `INVALID_ARGUMENT`, `DISPOSED`); check it with
73
73
  `isAnyformerError(e, code)`. A session runs one `generate()` at a time, in call order.
@@ -76,7 +76,7 @@ and `threw`. Every other failure is an `AnyformerError` too, with a code from `E
76
76
 
77
77
  ```js
78
78
  plan(repo, { budget, task }) // what would run it, and why the others would not; loads nothing
79
- load(repo, { budget, task, maxCtx, onProgress, signal }) // → { backend, task, plan, run(input), generate?, dispose() }
79
+ load(repo, { budget, task, file, maxCtx, poolBytes, localFile, onProgress, signal }) // → { backend, task, plan, run(input), generate?, think?, prime?, dispose() }
80
80
  pipeline(task, repo, { budget }) // transformers.js's call shape: gen(messages) → [{ generated_text }]
81
81
  probe() // WebGPU, shader-f16, subgroups, limits, OPFS quota, memory64
82
82
  describe(repo) // Hub repo → formats, architecture, task, tags, files
@@ -107,6 +107,7 @@ npm run test:browser -- store # list a download, remove it
107
107
  npm run test:browser -- chat --query "repo=LiquidAI/LFM2.5-1.2B-Instruct-GGUF" # a LocalMind engine
108
108
  npm run test:browser -- vision --query "repo=onnx-community/Qwen3.5-4B-ONNX-OPT" # names the two cats
109
109
  npm run test:browser -- diskformer-chat # Gemma 4 26B-A4B at 2.5 GB: 9/9 replies identical to llama.cpp b9830
110
+ npm run test:browser -- llama-gate --query "repo=ibm-granite/granite-4.0-h-tiny-GGUF&refs=diskformer-granite-refs" # 9/9 via diskformer-llama
110
111
  ```
111
112
 
112
113
  The browser tests fail a reply that is off topic or repeats itself, and `diskformer-chat` fails any reply that differs
package/index.js CHANGED
@@ -7,6 +7,7 @@ export { resolve, REFUSALS } from './src/resolve.js';
7
7
  export { transformersBackend } from './src/backends/transformers.js';
8
8
  export { kohraBackend } from './src/backends/kohra.js';
9
9
  export { diskformerBackend, DISKFORMER_MODELS } from './src/backends/diskformer.js';
10
+ export { diskformerLlamaBackend, LLAMA_ARCHITECTURES } from './src/backends/diskformer-llama.js';
10
11
  export { TOOLS, call, registerTools, journal, toolOptions } from './src/tools.js';
11
12
  export { MODELS } from './src/models.js';
12
13
  export { stored, remove } from './src/store.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyformer",
3
- "version": "0.1.0",
3
+ "version": "0.3.2",
4
4
  "description": "One call to run a model in a browser tab: anyformer reads the Hub repo, probes the browser, and picks the back end (transformers.js, LocalMind's WebGPU kernels, diskformer, kohra) that fits the model's format, architecture and your GPU budget.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -0,0 +1,161 @@
1
+ // diskformer-llama: mixture-of-experts GGUFs through diskformer's llama.cpp path (diskformer/llama: llama.cpp with
2
+ // WebGPU in a worker, the experts paged in from OPFS on demand). One engine for every architecture diskformer has
3
+ // checked in Chrome, so a new MoE model of a covered architecture needs no anyformer code. Where diskformer also has a
4
+ // hand-written engine for the model (the diskformer back end), that one carries a measured speed and ranks first.
5
+ // llama.js loads from the CDN at load() time, at the commit src/sources.js pins; set diskformerLlamaBackend.source to
6
+ // load another copy.
7
+
8
+ import { AnyformerError } from '../errors.js';
9
+ import { SOURCES } from '../sources.js';
10
+
11
+ // Architectures that pass diskformer's two checks (docs/coverage.md at the pinned commit): paging is exact natively,
12
+ // and the Chrome replies are compared with llama.cpp. Other models of the same architecture are expected to run.
13
+ // Left out: hy_v3 and afmoe (run, below diskformer's bar), laguna (unknown tokens), dense architectures (no experts
14
+ // to page), and Mixtral-style `llama` MoE (runs, too slow to use).
15
+ export const LLAMA_ARCHITECTURES = [
16
+ 'gemma4', 'qwen3moe', 'qwen3next', 'qwen35moe', 'qwen3vlmoe', 'deepseek2', 'nemotron_h_moe', 'ernie4_5-moe',
17
+ 'gpt-oss', 'cohere2moe', 'lfm2moe', 'mellum', 'bailingmoe2', 'granitehybrid', 'bailingmoe3', 'flint', 'olmoe',
18
+ ];
19
+
20
+ // Quant preference by the token in the file name; the first one the repo has wins. The files diskformer checked are
21
+ // Q4_K_M, Q4_0, Q4_1, MXFP4 and Q3_K_M.
22
+ const QUANTS = ['Q4_K_M', 'Q4_0', 'Q4_1', 'MXFP4', 'Q3_K_M', 'Q4_K_S', 'Q5_K_M', 'Q5_K_S', 'Q6_K', 'Q8_0'];
23
+
24
+ // The exact files diskformer checked (ggml/chat.html CATALOG and docs/coverage.md at the pinned commit). When a repo
25
+ // holds one, it is the default: the coverage numbers were measured on that file.
26
+ export const CHECKED_FILES = [
27
+ 'gemma-4-26B_q4_0-it.gguf', 'Qwen3-30B-A3B-Instruct-2507-Q3_K_M.gguf', 'Qwen3-Next-80B-A3B-Instruct-Q4_K_M.gguf',
28
+ 'Qwen3.6-35B-A3B-UD-Q3_K_M.gguf', 'Qwen3-VL-30B-A3B-Instruct-Q3_K_M.gguf', 'GLM-4.7-Flash-Q3_K_M.gguf',
29
+ 'NVIDIA-Nemotron-3-Nano-30B-A3B-Q4_K_M.gguf', 'ERNIE-4.5-21B-A3B-Thinking-Q4_K_M.gguf', 'gpt-oss-20b-MXFP4.gguf',
30
+ 'North-Mini-Code-1.0-UD-Q3_K_M.gguf', 'LFM2.5-24B-A2B-QAD-Q4_0.gguf', 'LFM2-8B-A1B-Q4_K_M.gguf',
31
+ 'Mellum2-12B-A2.5B-Instruct-Q4_K_M.gguf', 'Ling-mini-2.0-Q4_K_M.gguf', 'granite-4.0-h-tiny-Q4_K_M.gguf',
32
+ 'Ling-3.0-tiny-Q4_K_M.gguf', 'flint-int4-q4_1.gguf', 'OLMoE-1B-7B-0125-Instruct-Q4_K_M.gguf',
33
+ ];
34
+
35
+ // The GGUF to load: the language model (no mmproj vision tower), one file (no split sets). The caller's file when
36
+ // given; else the file diskformer checked; else the preferred quant.
37
+ export function pickGguf(model, file = null) {
38
+ const lms = model.files.gguf.filter((f) => !/mmproj/i.test(f.name) && !/-\d{5}-of-\d{5}\.gguf$/.test(f.name));
39
+ if (file) return lms.find((f) => f.name === file) || null;
40
+ const checked = lms.find((f) => CHECKED_FILES.includes(f.name.split('/').pop()));
41
+ if (checked) return checked;
42
+ for (const q of QUANTS) {
43
+ const f = lms.find((x) => new RegExp(`(^|[-_.])${q}([-_.]|$)`, 'i').test(x.name.replace(/\.gguf$/i, '')));
44
+ if (f) return f;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ // OPFS keeps llama.cpp's GGUFs by file name (gguf/<name>), with no repo. anyformer records name → repo here, so
50
+ // stored() can say which repo a file belongs to.
51
+ export const LLAMA_INDEX = 'anyformer-llama.json';
52
+ async function readIndex() {
53
+ try { return JSON.parse(await (await (await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX)).getFile()).text()); } catch { return {}; }
54
+ }
55
+ async function writeIndex(index) {
56
+ const h = await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX, { create: true });
57
+ const w = await h.createWritable();
58
+ await w.write(JSON.stringify(index));
59
+ await w.close();
60
+ }
61
+ export { readIndex as readLlamaIndex };
62
+
63
+ // Chat content: a string, or text blocks joined. llama.cpp's chat here takes text only.
64
+ function textOnly(messages) {
65
+ return messages.map((m) => {
66
+ if (typeof m.content === 'string') return m;
67
+ const other = m.content.find((b) => b.type !== 'text');
68
+ if (other) throw new AnyformerError('INVALID_ARGUMENT', `diskformer-llama runs text only; got a ${other.type} block`);
69
+ return { ...m, content: m.content.map((b) => b.text).join('\n') };
70
+ });
71
+ }
72
+
73
+ export const diskformerLlamaBackend = {
74
+ id: 'diskformer-llama',
75
+ tasks: ['text-generation', 'image-text-to-text'],
76
+ source: SOURCES.diskformerLlama,
77
+ // Where a GGUF downloads from; a test points it at a local server.
78
+ url: (model, file) => `${model.endpoint || 'https://huggingface.co'}/${model.repo}/resolve/${model.revision || 'main'}/${file.name.split('/').map(encodeURIComponent).join('/')}`,
79
+
80
+ canRun(model, caps, { budget, task, file = null }) {
81
+ if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
82
+ if (task && !diskformerLlamaBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer's llama.cpp path runs text generation only` };
83
+ if (!LLAMA_ARCHITECTURES.includes(model.architecture)) return { ok: false, code: 'model', reason: `architecture ${model.architecture || 'unknown'} is not one diskformer's llama.cpp path has checked (${LLAMA_ARCHITECTURES.join(', ')})` };
84
+ const f = pickGguf(model, file);
85
+ if (!f) return { ok: false, code: 'model', reason: file ? `${file} is not a single-file language-model GGUF in the repo` : `no single-file GGUF at ${QUANTS.join(', ')} in the repo` };
86
+ if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
87
+ if (!caps.memory64 || !caps.jspi) return { ok: false, code: 'webgpu', reason: 'the llama.cpp build needs wasm memory64 and JSPI (Chrome or Edge 137+)' };
88
+ if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
89
+ // The budget is not checked here: llama.cpp plans its expert slots from it at load time, and refuses there.
90
+ return { ok: true, estimate: { gpuBytes: budget, diskBytes: f.size, tokPerSec: null } };
91
+ },
92
+
93
+ // maxCtx sizes the KV cache; 1024 is the context diskformer's gates run at.
94
+ async load(model, { budget, file = null, maxCtx = 1024, onProgress, signal }) {
95
+ const f = pickGguf(model, file);
96
+ const { Llama } = await import(diskformerLlamaBackend.source);
97
+ const llama = new Llama();
98
+ const say = (e) => onProgress?.({ backend: 'diskformer-llama', file: f.name, ...e });
99
+ const stop = () => llama.abort();
100
+ signal?.addEventListener('abort', stop, { once: true });
101
+ let info;
102
+ try {
103
+ const have = (await llama.list()).find((m) => m.name === f.name && !m.partial);
104
+ if (!have || have.size !== f.size) {
105
+ say({ status: 'download', loaded: 0, total: f.size });
106
+ await llama.download({ url: diskformerLlamaBackend.url(model, f), name: f.name, size: f.size },
107
+ (p) => say({ status: 'download', loaded: p.copied, total: p.total }));
108
+ signal?.throwIfAborted();
109
+ }
110
+ await writeIndex({ ...(await readIndex()), [f.name]: model.repo });
111
+ say({ status: 'load' });
112
+ info = await llama.load({ name: f.name, budget, ctx: maxCtx }, (p) => say({ status: 'plan', plan: p }));
113
+ signal?.throwIfAborted();
114
+ } catch (e) {
115
+ llama.terminate();
116
+ throw e;
117
+ } finally {
118
+ signal?.removeEventListener('abort', stop);
119
+ }
120
+ say({ status: 'ready', slots: info.slots });
121
+ return {
122
+ task: 'text-generation',
123
+ llama,
124
+ info,
125
+ // llama.cpp hands back text pieces, not token ids, so there are no think ids to report.
126
+ think: null,
127
+ // thinking: false (the default, as the other engines here) renders the template with thinking off, so a model that
128
+ // reasons first answers directly; true lets it think.
129
+ async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
130
+ const chunks = [];
131
+ let wake = null, finished = false, failed = null, result = null, text = '', tokens = 0;
132
+ const nudge = () => { wake?.(); wake = null; };
133
+ const abort = () => llama.abort();
134
+ signal?.addEventListener('abort', abort, { once: true });
135
+ const job = llama.chat(textOnly(messages), {
136
+ n: maxNewTokens, thinking,
137
+ onPiece: (s) => { text += s; tokens++; chunks.push({ text, tokens }); nudge(); },
138
+ }).then((r) => { result = r; }, (e) => { failed = e; }).finally(() => { finished = true; nudge(); });
139
+ try {
140
+ for (;;) {
141
+ while (chunks.length) yield chunks.shift();
142
+ if (finished) break;
143
+ await new Promise((r) => { wake = r; });
144
+ }
145
+ signal?.throwIfAborted();
146
+ if (failed) throw failed;
147
+ const s = result.stats || {};
148
+ const decode = s.decode_tokens && s.decode_ms ? s.decode_tokens / (s.decode_ms / 1000) : null;
149
+ yield { text: result.text, tokens: s.decode_tokens ?? tokens, ...(decode && { tokPerSec: decode }) };
150
+ } finally {
151
+ signal?.removeEventListener('abort', abort);
152
+ // The caller stopped reading early: stop the reply so the next call does not queue behind it.
153
+ if (!finished) { llama.abort(); await job; }
154
+ }
155
+ },
156
+ // A continuation of an already formatted prompt, greedy: what diskformer's gate replays.
157
+ complete: (prompt, { maxNewTokens = 64 } = {}) => llama.complete(prompt, { n: maxNewTokens }),
158
+ dispose: () => llama.terminate(),
159
+ };
160
+ },
161
+ };
@@ -18,6 +18,28 @@ export const DISKFORMER_MODELS = [
18
18
  engine: 'qwen35_moe_ssd.js', Engine: 'Qwen35MoeSsd', minBudget: 4e9,
19
19
  speeds: [[4e9, 9.2]],
20
20
  },
21
+ // Qwen3.6's language model with a vision encoder (mmproj, ~1.1 GB, loaded on the first image by ensureVision).
22
+ // From LocalMind's gate (plan/2026-10-14-minicpm-harness, 4 GiB pool, context 512): ~6.1 GB on the GPU (dense 1.59,
23
+ // pool 4.29), 14.1 tok/s over short greedy continuations. The smallest working budget is unmeasured; the floor is
24
+ // Qwen3.6's, the same language-model shape. Only this engine reads its images, and it ranks before diskformer-llama.
25
+ {
26
+ repo: 'openbmb/MiniCPM-V-4.7-35B-A3B-gguf', file: 'MiniCPM-V-4_7-35B-A3B-Q4_0.gguf', size: 19714550688,
27
+ engine: 'qwen35_moe_ssd.js', Engine: 'MiniCpmV47Ssd', minBudget: 4e9,
28
+ speeds: [[6.1e9, 14.1]],
29
+ },
30
+ // Two parts, ingested as one file by the engine's splitFetch (engines/ling_moe_ssd.js LING_FLASH, rev 29edce5).
31
+ // Budget and speed from LocalMind's gate (plan/2026-10-22-ling-harness/results.md): 2.51 GB of dense weights on
32
+ // the GPU plus a 4.29 GB expert pool, 5.53 tok/s decode; the OPFS store is 80.7 GB.
33
+ {
34
+ repo: 'inclusionAI/Ling-3.0-flash-GGUF',
35
+ parts: [
36
+ { file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00001-of-00002.gguf', size: 49995534208 },
37
+ { file: 'Q4_K_M/Ling-3.0-flash-Q4_K_M-00002-of-00002.gguf', size: 27014610464 },
38
+ ],
39
+ size: 49995534208 + 27014610464, storeBytes: 80.7e9,
40
+ engine: 'ling_moe_ssd.js', Engine: 'LingMoeSsd', minBudget: 6.8e9,
41
+ speeds: [[6.8e9, 5.53]],
42
+ },
21
43
  ];
22
44
 
23
45
  // diskformer's engines refuse a budget with a plain Error ("GPU budget 2.50 GB is too small: … need 3.04 GB, and the
@@ -27,8 +49,9 @@ function budgetError(e) {
27
49
  return m && new AnyformerError('BUDGET_TOO_SMALL', `diskformer: ${e.message}`, { cause: e, need: Math.ceil((+m[1] + +m[2]) * 1e9) });
28
50
  }
29
51
 
52
+ const has = (model, file, size) => model.files.gguf.some((f) => f.name === file && f.size === size);
30
53
  const match = (model) => DISKFORMER_MODELS.find((m) => m.repo === model.repo &&
31
- model.files.gguf.some((f) => f.name === m.file && f.size === m.size));
54
+ (m.parts ? m.parts.every((p) => has(model, p.file, p.size)) : has(model, m.file, m.size)));
32
55
 
33
56
  // The speed measured at the largest budget not above this one.
34
57
  const speedAt = (m, budget) => m.speeds.filter(([b]) => b <= budget).at(-1)?.[1] ?? null;
@@ -40,40 +63,61 @@ export const diskformerBackend = {
40
63
  // Gemma 4's repo is tagged image-text-to-text; the engines run its text turns.
41
64
  tasks: ['text-generation', 'image-text-to-text'],
42
65
  source: SOURCES.diskformer,
66
+ // Where the engine downloads its GGUF from; null is Hugging Face. A test points it at a local server.
67
+ url: null,
43
68
 
44
69
  canRun(model, caps, { budget, task }) {
45
70
  if (!model.formats.includes('gguf')) return { ok: false, code: 'format', reason: 'no GGUF in the repo' };
46
71
  if (task && !diskformerBackend.tasks.includes(task)) return { ok: false, code: 'task', reason: `task ${task}; diskformer engines run text generation only` };
47
72
  const m = match(model);
48
- if (!m) return { ok: false, code: 'model', reason: `no diskformer engine for this GGUF; engines cover ${DISKFORMER_MODELS.map((x) => x.file).join(', ')}` };
73
+ if (!m) return { ok: false, code: 'model', reason: `no diskformer engine for this GGUF; engines cover ${DISKFORMER_MODELS.map((x) => x.repo).join(', ')}` };
49
74
  if (!caps.webgpu) return { ok: false, code: 'webgpu', reason: 'no WebGPU adapter' };
50
75
  if (!caps.f16) return { ok: false, code: 'f16', reason: 'the GPU lacks shader-f16' };
51
76
  if (!caps.opfs) return { ok: false, code: 'opfs', reason: 'no OPFS (navigator.storage.getDirectory)' };
52
77
  if (budget < m.minBudget) return { ok: false, code: 'budget', need: m.minBudget, reason: `a ${(budget / 1e9).toFixed(1)} GB budget is under the ${m.minBudget / 1e9} GB this model has passed its gate at` };
53
78
  // Storage quota is not checked: Chrome raises it as the download writes, while the disk has room.
54
- return { ok: true, estimate: { gpuBytes: budget, diskBytes: m.size, tokPerSec: speedAt(m, budget) } };
79
+ return { ok: true, estimate: { gpuBytes: budget, diskBytes: m.storeBytes ?? m.size, tokPerSec: speedAt(m, budget) } };
55
80
  },
56
81
 
57
82
  // maxCtx sizes the KV cache, which stays on the GPU: the 2.5 GB minimum and the speeds above hold at 1024 tokens,
58
83
  // the context diskformer's chat gate runs at. A longer context needs a larger budget.
59
- async load(model, { budget, maxCtx = 1024, onProgress, signal }) {
84
+ // poolBytes: the expert cache size, passed to the engine; the budget then only admits the model (the engine sizes
85
+ // its pool from gpuBudgetBytes when given one, so it gets one or the other).
86
+ // localFile: the GGUF as a File the user picked; the engine ingests it instead of downloading, and refuses a file
87
+ // whose size differs from the one it is pinned to.
88
+ async load(model, { budget, maxCtx = 1024, poolBytes, localFile, onProgress, signal }) {
60
89
  const m = match(model);
61
90
  const mod = await import(`${diskformerBackend.source}/${m.engine}`);
62
91
  let engine;
63
92
  try {
64
93
  engine = await mod[m.Engine].load(null, {
65
94
  // The engines' own default root, named so the store module (src/store.js) lists the same folder.
66
- gpuBudgetBytes: budget, maxCtx, signal, root: 'localmind-ssd',
95
+ ...(poolBytes ? { poolBytes } : { gpuBudgetBytes: budget }), maxCtx, signal, root: 'localmind-ssd',
96
+ ...(localFile && { localFile }),
97
+ ...(diskformerBackend.url && (m.parts ? { urls: m.parts.map((p) => diskformerBackend.url(model, { ...m, file: p.file })) } : { url: diskformerBackend.url(model, m) })),
67
98
  onProgress: onProgress && ((e) => onProgress({ backend: 'diskformer', ...e })),
68
99
  });
69
100
  } catch (e) { throw budgetError(e) || e; }
101
+ // Where the model's reasoning starts and ends, as token ids, so a caller can split it out without parsing text.
102
+ const open = engine.thinkOpenTokenId ?? null;
70
103
  return {
71
104
  task: 'text-generation',
72
105
  engine,
73
- async *generate(messages, { maxNewTokens = 512, signal } = {}) {
74
- for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: false }))
75
- yield { text: r.text, tokens: r.tokens };
106
+ think: open === null ? null : {
107
+ openId: open, closeId: engine.thinkCloseTokenId ?? null, inPrompt: engine.thinkInPrompt ?? null,
108
+ preamble: engine.thinkPreamble ?? null, always: engine.alwaysThinks ?? null,
109
+ },
110
+ // thinking: false (the default, as on every back end here) answers directly; true lets a model that reasons
111
+ // (Gemma 4, Qwen3.6, Ling) think first. Image blocks ({ type: 'image', rgb, width, height }) pass through to
112
+ // an engine that reads them.
113
+ async *generate(messages, { maxNewTokens = 512, signal, thinking = false } = {}) {
114
+ for await (const r of engine.generate(messages, { maxNewTokens, signal, enableThinking: thinking === true }))
115
+ yield { text: r.text, token: r.token, tokens: r.tokens };
76
116
  },
117
+ // Checkpoints a system prompt after load, so the first question does not wait for it (Qwen3.6, MiniCPM-V).
118
+ ...(typeof engine.prime === 'function' && { prime: (system) => engine.prime(system) }),
119
+ // Loads the vision tower, for an engine that reads images (MiniCPM-V).
120
+ ...(typeof engine.ensureVision === 'function' && { ensureVision: (o) => engine.ensureVision(o) }),
77
121
  dispose: () => engine.dispose(),
78
122
  };
79
123
  },
@@ -1,7 +1,8 @@
1
1
  // LocalMind's hand-written WebGPU engines: whole model resident on the GPU, kernels written per architecture. Each
2
2
  // engine reads its config from the model file, so it runs any checkpoint of its architecture in a format it reads,
3
- // not only the one LocalMind ships. They load from the CDN at load() time, at the commit src/sources.js pins; set
4
- // localmindBackend.source to load another copy.
3
+ // not only the one LocalMind ships. They live in diskformer (engines/, since v0.6.4) and load from the CDN at load()
4
+ // time, at the commit src/sources.js pins; set localmindBackend.source to load another copy. The id stays 'localmind',
5
+ // the name callers and stored() already use, though no code loads from LocalMind's repo any more.
5
6
  //
6
7
  // lfm2 src/lfm2_5.js GGUF, general.architecture 'lfm2' (dense LFM2 / LFM2.5), Q4_0 Q8_0 Q4_K Q5_K Q6_K
7
8
  // gemma4 src/gemma-4-e2b.js Google's QAT-mobile safetensors export of Gemma 4 (E2B, E4B): config.json-driven
@@ -58,7 +59,7 @@ function match(model) {
58
59
  export const localmindBackend = {
59
60
  id: 'localmind',
60
61
  tasks: ['text-generation'],
61
- source: SOURCES.localmind,
62
+ source: SOURCES.diskformer,
62
63
 
63
64
  canRun(model, caps, { budget, task }) {
64
65
  const m = match(model);
package/src/describe.js CHANGED
@@ -3,11 +3,24 @@
3
3
 
4
4
  const HF = 'https://huggingface.co';
5
5
 
6
- export async function describe(repo, { fetch = globalThis.fetch, revision = 'main', endpoint = HF } = {}) {
6
+ // The last answer the Hub gave for each URL, kept in Cache Storage so a model already downloaded still loads offline.
7
+ export const DESCRIBE_CACHE = 'anyformer-describe-v1';
8
+
9
+ export async function describe(repo, { fetch = globalThis.fetch, revision = 'main', endpoint = HF, caches = globalThis.caches } = {}) {
7
10
  const url = `${endpoint}/api/models/${repo}${revision === 'main' ? '' : `/revision/${revision}`}?blobs=true`;
8
- const res = await fetch(url);
9
- if (!res.ok) throw new Error(`describe ${repo}: ${url} returned ${res.status}`);
10
- return fromHubInfo(await res.json(), { endpoint });
11
+ const store = caches ? await caches.open(DESCRIBE_CACHE).catch(() => null) : null;
12
+ let res, failed;
13
+ try { res = await fetch(url); } catch (e) { failed = e; }
14
+ if (res?.ok) {
15
+ const info = await res.json();
16
+ await store?.put(url, new Response(JSON.stringify(info), { headers: { 'Content-Type': 'application/json' } })).catch(() => {});
17
+ return fromHubInfo(info, { endpoint });
18
+ }
19
+ // No network, or the Hub failed: the cached answer, when this browser has one.
20
+ const hit = await store?.match(url).catch(() => null);
21
+ if (hit) return fromHubInfo(await hit.json(), { endpoint });
22
+ throw failed ? new Error(`describe ${repo}: ${url} failed: ${failed.message}`, { cause: failed })
23
+ : new Error(`describe ${repo}: ${url} returned ${res.status}`);
11
24
  }
12
25
 
13
26
  // Pure: Hub API JSON → descriptor. Exported for tests and for callers that already hold the JSON.
package/src/load.js CHANGED
@@ -4,12 +4,15 @@ import { resolve } from './resolve.js';
4
4
  import { transformersBackend } from './backends/transformers.js';
5
5
  import { kohraBackend } from './backends/kohra.js';
6
6
  import { diskformerBackend } from './backends/diskformer.js';
7
+ import { diskformerLlamaBackend } from './backends/diskformer-llama.js';
7
8
  import { localmindBackend } from './backends/localmind.js';
8
9
  import { wllamaBackend } from './backends/wllama.js';
9
10
  import { AnyformerError, wrap } from './errors.js';
10
11
 
11
- // Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties.
12
- export const BACKENDS = [transformersBackend, kohraBackend, diskformerBackend, localmindBackend, wllamaBackend];
12
+ // Back ends land here as each one passes its browser test (test/browser/). The list order breaks ties: a
13
+ // diskformer hand engine carries a measured speed and outranks diskformer-llama for the same model; diskformer-llama
14
+ // comes before wllama because it pages experts instead of holding the whole model on the GPU.
15
+ export const BACKENDS = [transformersBackend, kohraBackend, diskformerBackend, localmindBackend, diskformerLlamaBackend, wllamaBackend];
13
16
 
14
17
  // 2.5 GB: the smallest budget diskformer's chat gate passes at. Browsers do not report GPU memory, so a caller that
15
18
  // knows more passes { budget }.
@@ -23,28 +26,29 @@ function noBackend(plan) {
23
26
 
24
27
  // What load() would do, without loading: the descriptor, the caps, and every back end's answer.
25
28
  // task: a Hugging Face pipeline task ('text-generation', 'feature-extraction', …); null runs the repo's own.
29
+ // file: a weights file in the repo to load (a GGUF name); null lets each back end pick.
26
30
  // repo: a Hub id, or an entry from MODELS (its task and budget apply unless opts set them).
27
31
  const entry = (repo, opts) => (typeof repo === 'object' && repo?.repo
28
32
  ? [repo.repo, { ...(repo.task && { task: repo.task }), ...(repo.budget && { budget: repo.budget }), ...opts }]
29
33
  : [repo, opts]);
30
34
 
31
35
  export async function plan(repoOrEntry, opts = {}) {
32
- let [repo, { budget = DEFAULT_BUDGET, task = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
36
+ let [repo, { budget = DEFAULT_BUDGET, task = null, file = null, backends = BACKENDS, caps, model, fetch }] = entry(repoOrEntry, opts);
33
37
  caps ??= await probe();
34
38
  model ??= await describe(repo, fetch ? { fetch } : {});
35
- return { model, caps, budget, task, ...(await resolve(model, caps, backends, { budget, task })) };
39
+ return { model, caps, budget, task, file, ...(await resolve(model, caps, backends, { budget, task, file })) };
36
40
  }
37
41
 
38
42
  export async function load(repoOrEntry, options = {}) {
39
43
  const [repo, opts] = entry(repoOrEntry, options);
40
- const { backends = BACKENDS, maxCtx, dtype, onProgress, signal } = opts;
44
+ const { backends = BACKENDS, maxCtx, dtype, poolBytes, localFile, onProgress, signal } = opts;
41
45
  let p;
42
46
  try { p = await plan(repo, opts); } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${repo}: `); }
43
47
  if (!p.chosen) throw noBackend(p);
44
48
  const backend = backends.find((b) => b.id === p.chosen);
45
49
  let session;
46
50
  try {
47
- session = await backend.load(p.model, { budget: p.budget, task: p.task, caps: p.caps, maxCtx, dtype, onProgress, signal });
51
+ session = await backend.load(p.model, { budget: p.budget, task: p.task, file: p.file, caps: p.caps, maxCtx, dtype, poolBytes, localFile, onProgress, signal });
48
52
  } catch (e) { throw wrap(e, 'LOAD_FAILED', signal, `${backend.id}: `); }
49
53
  return { backend: backend.id, task: session.task || p.task || p.model.task || null, ...(session.device && { device: session.device }), plan: p, ...serialize(session) };
50
54
  }
@@ -80,8 +84,25 @@ function serialize(session) {
80
84
  release();
81
85
  }
82
86
  };
87
+ // A call that waits its turn like generate(): prime and ensureVision share the engine with it.
88
+ const queued = (fn) => async (...args) => {
89
+ if (disposed) throw gone();
90
+ const release = await turn();
91
+ try {
92
+ if (disposed) throw gone();
93
+ return await fn(...args);
94
+ } catch (e) {
95
+ throw wrap(e, 'EXECUTION_FAILED');
96
+ } finally {
97
+ release();
98
+ }
99
+ };
83
100
  return {
84
101
  ...(generate && { generate }),
102
+ // Where reasoning starts and ends as token ids ({ openId, closeId, inPrompt, preamble, always }), or null.
103
+ ...('think' in session && { think: session.think }),
104
+ ...(session.prime && { prime: queued(session.prime) }),
105
+ ...(session.ensureVision && { ensureVision: queued(session.ensureVision) }),
85
106
  // The one call every task has. For text generation it is the last chunk of generate().
86
107
  async run(input, o = {}) {
87
108
  if (generate) { let last; for await (const c of generate(input, o)) last = c; return last; }
package/src/probe.js CHANGED
@@ -14,6 +14,8 @@ export async function probe({ gpu = globalThis.navigator?.gpu, storage = globalT
14
14
  maxBufferSize: 0, maxStorageBufferBindingSize: 0, adapter: null,
15
15
  opfs: false, quotaBytes: 0, usageBytes: 0,
16
16
  memory64: memory64(),
17
+ // JSPI: wasm that suspends on a promise (diskformer's llama.cpp build reads experts from OPFS this way).
18
+ jspi: typeof globalThis.WebAssembly?.Suspending === 'function',
17
19
  };
18
20
  const adapter = gpu ? await gpu.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null) : null;
19
21
  if (adapter) {
package/src/resolve.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // Back-end contract (each back end is a plain object):
4
4
  // id string, stable, e.g. 'diskformer'
5
5
  // tasks the Hugging Face pipeline tasks it runs, e.g. ['text-generation']
6
- // canRun(model, caps, { budget, task }) → { ok: true, estimate } | { ok: false, code, reason, need? }
6
+ // canRun(model, caps, { budget, task, file }) → { ok: true, estimate } | { ok: false, code, reason, need? }
7
+ // file: a weights file the caller chose (a GGUF name); a back end that cannot load that file refuses.
7
8
  // estimate = { gpuBytes, diskBytes, tokPerSec | null }. No network, no GPU allocation: it reads the
8
9
  // descriptor and caps only. code is one of REFUSALS below; need is the budget that would pass, with
9
10
  // code 'budget'. A throw counts as code 'threw' with the message as the reason.
@@ -26,11 +27,11 @@ export const REFUSALS = {
26
27
  threw: 'the back end crashed deciding; a bug in that back end, report it with the reason',
27
28
  };
28
29
 
29
- export async function resolve(model, caps, backends, { budget, task = null } = {}) {
30
+ export async function resolve(model, caps, backends, { budget, task = null, file = null } = {}) {
30
31
  const candidates = [];
31
32
  for (const [order, b] of backends.entries()) {
32
33
  let r;
33
- try { r = await b.canRun(model, caps, { budget, task }); } catch (e) { r = { ok: false, code: 'threw', reason: `canRun threw: ${e.message}` }; }
34
+ try { r = await b.canRun(model, caps, { budget, task, file }); } catch (e) { r = { ok: false, code: 'threw', reason: `canRun threw: ${e.message}` }; }
34
35
  candidates.push(r.ok ? { backend: b.id, order, ok: true, estimate: r.estimate } : { backend: b.id, order, ok: false, code: r.code || 'model', reason: r.reason, ...(r.need && { need: r.need }) });
35
36
  }
36
37
  const ranked = candidates.filter((c) => c.ok).sort(rank);
package/src/sources.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Where each back end's runtime loads from: exact versions and commits, so a given anyformer behaves the same tomorrow.
2
- // kohra, diskformer and LocalMind move fast on their main branches; `node scripts/pin-sources.mjs` moves these pins
2
+ // kohra and diskformer move fast on their main branches; `node scripts/pin-sources.mjs` moves these pins
3
3
  // to each repo's current main, and the browser tests then prove the new pins (test/browser/README.md).
4
4
  // transformers.js and wllama move only by hand, in step with LocalMind's pins (plan/history.md, 2026-10-08).
5
5
 
@@ -7,8 +7,9 @@ export const PINS = {
7
7
  transformers: '4.3.1',
8
8
  wllama: '3.4.1', // LocalMind's pin; its stream-drain workaround (src/backends/wllama.js) is for this version
9
9
  kohra: 'f678fac7342c79b58b640540b8b3a7d078b7ab1a',
10
- diskformer: '524bc50e2edd186403403090dd83d13f66a8f73f',
11
- localmind: '203288a0f0e23919b43c76bff8dc5d49f48ed300',
10
+ // v0.6.4: every engine (the disk-tier MoE engines, Ling 3.0 Flash, and LocalMind's whole-model engines LFM2.5,
11
+ // Gemma 4 E2B/E4B and Ternary Bonsai 2) under engines/, and the llama.cpp path under ggml/.
12
+ diskformer: '095ab2058be9de56841c51b9369ba556d625392b',
12
13
  };
13
14
 
14
15
  const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
@@ -18,5 +19,5 @@ export const SOURCES = {
18
19
  wllama: `https://cdn.jsdelivr.net/npm/@wllama/wllama@${PINS.wllama}/esm`,
19
20
  kohra: gh('kohra', PINS.kohra, 'kohra.js'),
20
21
  diskformer: gh('diskformer.js', PINS.diskformer, 'engines'),
21
- localmind: gh('LocalMind', PINS.localmind, 'src'),
22
+ diskformerLlama: gh('diskformer.js', PINS.diskformer, 'ggml/llama.js'),
22
23
  };
package/src/store.js CHANGED
@@ -10,10 +10,13 @@
10
10
  // system-prompt snapshot).
11
11
  // OPFS 'localmind-ssd/<key>/' (diskformer's MoE stores, Gemma 4's PLE table) with manifest.json naming the
12
12
  // source; 'cache/' (wllama: '__metadata__<name>' names each file's URL); 'anyformer-gguf/<key>/'
13
- // (GGUFs over 2 GB as shards, meta.json or done.json naming the repo; src/gguf-split.js).
13
+ // (GGUFs over 2 GB as shards, meta.json or done.json naming the repo; src/gguf-split.js);
14
+ // 'gguf/<name>' (diskformer-llama: llama.cpp's GGUFs, with <name>.part and .part.json while downloading;
15
+ // anyformer-llama.json maps each name to its repo).
14
16
 
15
17
  import { DISKFORMER_MODELS } from './backends/diskformer.js';
16
18
  import { SPLIT_ROOT } from './gguf-split.js';
19
+ import { readLlamaIndex, LLAMA_INDEX } from './backends/diskformer-llama.js';
17
20
 
18
21
  const CACHE_BACKEND = { 'transformers-cache': 'transformers', 'gguf-v1': 'localmind', 'gguf-v1-headers': 'localmind', 'bonsai-pipeline-v1': 'localmind' };
19
22
  const IDB_STORES = ['gguf-cache-v1', 'safetensors-cache-v1'];
@@ -86,7 +89,7 @@ async function inventory() {
86
89
  if (ssd) for await (const [key, h] of ssd.entries()) {
87
90
  if (h.kind !== 'directory') continue;
88
91
  const src = (await readJson(h, 'manifest.json'))?.source;
89
- const repo = src?.repo || repoOf(src?.url) || DISKFORMER_MODELS.find((m) => m.file.toLowerCase().startsWith(key))?.repo;
92
+ const repo = src?.repo || repoOf(src?.url) || DISKFORMER_MODELS.find((m) => m.file?.toLowerCase().startsWith(key))?.repo;
90
93
  add(DISKFORMER_MODELS.some((m) => m.repo === repo) ? 'diskformer' : 'localmind', repo, await dirBytes(h), () => ssd.removeEntry(key, { recursive: true }));
91
94
  }
92
95
  const wcache = await opfsDir(['cache']);
@@ -103,6 +106,22 @@ async function inventory() {
103
106
  const repo = (await readJson(h, 'meta.json'))?.repo || (await readJson(h, 'done.json'))?.repo;
104
107
  add('wllama', repo, await dirBytes(h), () => split.removeEntry(key, { recursive: true }));
105
108
  }
109
+ const llama = await opfsDir(['gguf']);
110
+ if (llama) {
111
+ const index = await readLlamaIndex();
112
+ for await (const [name, h] of llama.entries()) {
113
+ if (h.kind !== 'file' || name.endsWith('.part.json') || name.endsWith('.crswap')) continue;
114
+ const base = name.replace(/\.part$/, '');
115
+ add('diskformer-llama', index[base], (await h.getFile()).size, async () => {
116
+ for (const n of [base, `${base}.part`, `${base}.part.json`]) await llama.removeEntry(n).catch(() => {});
117
+ const rest = await readLlamaIndex();
118
+ delete rest[base];
119
+ const w = await (await (await navigator.storage.getDirectory()).getFileHandle(LLAMA_INDEX, { create: true })).createWritable();
120
+ await w.write(JSON.stringify(rest));
121
+ await w.close();
122
+ });
123
+ }
124
+ }
106
125
  return out;
107
126
  }
108
127