anyformer 0.0.0-stage → 0.1.0

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/src/models.js ADDED
@@ -0,0 +1,50 @@
1
+ // Models that pass a browser test through anyformer, grouped by what they do. Pass an entry to load() in place of a
2
+ // repo id; it carries the task and, where the back end needs one, the budget. DEFAULT is the smallest download that
3
+ // passed. `test` names the page under test/browser/ that proves the entry; test/models.test.js checks it exists.
4
+
5
+ const M = (repo, backend, task, test, extra = {}) => ({ repo, backend, task, test, ...extra });
6
+
7
+ const QWEN2_5_0_5B = M('onnx-community/Qwen2.5-0.5B-Instruct', 'transformers', 'text-generation', 'transformers', { downloadBytes: 786e6 });
8
+ const TERNARY_BONSAI_4B = M('onnx-community/Ternary-Bonsai-4B-ONNX', 'transformers', 'text-generation', 'transformers', { downloadBytes: 1.16e9 });
9
+ const LFM2_5_230M = M('LiquidAI/LFM2.5-230M-GGUF', 'localmind', 'text-generation', 'chat', { downloadBytes: 149e6 });
10
+ const LFM2_5_1_2B = M('LiquidAI/LFM2.5-1.2B-Instruct-GGUF', 'localmind', 'text-generation', 'chat', { downloadBytes: 696e6 });
11
+ const GEMMA4_E2B = M('google/gemma-4-E2B-it-qat-mobile-transformers', 'localmind', 'text-generation', 'chat', { downloadBytes: 2.46e9 });
12
+ const GEMMA4_E4B = M('google/gemma-4-E4B-it-qat-mobile-transformers', 'localmind', 'text-generation', 'chat', { budget: 4e9, downloadBytes: 3.53e9 });
13
+ const TERNARY_BONSAI_2_27B = M('prism-ml/Ternary-Bonsai-2-27B-gguf', 'localmind', 'text-generation', 'chat', { budget: 7e9, downloadBytes: 5.94e9 });
14
+ const QWEN3_5_4B = M('onnx-community/Qwen3.5-4B-ONNX-OPT', 'transformers', 'text-generation', 'vision', { budget: 3.1e9, downloadBytes: 3.0e9 });
15
+ const GEMMA4_E2B_ONNX = M('onnx-community/gemma-4-E2B-it-ONNX', 'transformers', 'text-generation', 'vision', { budget: 3.5e9, downloadBytes: 3.38e9 });
16
+ const GEMMA4_E4B_ONNX = M('onnx-community/gemma-4-E4B-it-ONNX', 'transformers', 'text-generation', 'transformers', { budget: 5.3e9, downloadBytes: 5.18e9 });
17
+ const MINICPM5_2B = M('openbmb/MiniCPM5-2B-GGUF', 'wllama', 'text-generation', 'chat', { downloadBytes: 1.56e9 });
18
+ const QWEN3_0_6B_MDLM = M('naklitechie/Qwen3-0.6B-diffusion-mdlm-ONNX', 'kohra', 'text-generation', 'kohra', { downloadBytes: 1.54e9 });
19
+ const GEMMA4_26B_A4B = M('google/gemma-4-26B-A4B-it-qat-q4_0-gguf', 'diskformer', 'text-generation', 'diskformer-chat', { budget: 2.5e9, downloadBytes: 14.44e9 });
20
+ const MINILM_L6 = M('Xenova/all-MiniLM-L6-v2', 'transformers', 'feature-extraction', 'embed', { downloadBytes: 90e6 });
21
+ const DETR_RESNET_50 = M('Xenova/detr-resnet-50', 'transformers', 'object-detection', 'detect', { downloadBytes: 167e6 });
22
+
23
+ export const MODELS = {
24
+ chat: {
25
+ DEFAULT: QWEN2_5_0_5B,
26
+ QWEN2_5_0_5B,
27
+ /** Ternary weights (q2 graph), 4B parameters in 1.16 GB. */
28
+ TERNARY_BONSAI_4B,
29
+ /** LocalMind's hand-written LFM2 kernels: 149 MB, the fastest chat here. */
30
+ LFM2_5_230M,
31
+ /** The same LFM2 engine on the 1.2B GGUF. */
32
+ LFM2_5_1_2B,
33
+ /** Gemma 4 E2B, Google's QAT-mobile export, on LocalMind's Gemma kernels. */
34
+ GEMMA4_E2B,
35
+ /** Gemma 4 E4B on the same kernels; the whole 3.5 GB stays on the GPU. */
36
+ GEMMA4_E4B,
37
+ /** 27B parameters at 1.6 bits (Prism ternary), 5.9 GB on the GPU. */
38
+ TERNARY_BONSAI_2_27B,
39
+ /** llama.cpp in WebAssembly (wllama): WebGPU when the budget holds it, the CPU otherwise. */
40
+ MINICPM5_2B,
41
+ /** Text diffusion: the whole reply denoises at once (kohra). */
42
+ QWEN3_0_6B_MDLM,
43
+ /** 26B parameters in 2.5 GB of GPU memory; 14.4 GB on disk (diskformer). */
44
+ GEMMA4_26B_A4B,
45
+ },
46
+ /** Chat with images in the messages: content [{ type: 'image', image }, { type: 'text', text }]. Gemma 4 takes audio too. */
47
+ vision: { DEFAULT: QWEN3_5_4B, QWEN3_5_4B, GEMMA4_E2B_ONNX, GEMMA4_E4B_ONNX },
48
+ embedding: { DEFAULT: MINILM_L6, MINILM_L6 },
49
+ objectDetection: { DEFAULT: DETR_RESNET_50, DETR_RESNET_50 },
50
+ };
@@ -0,0 +1,27 @@
1
+ // transformers.js's pipeline() call shape over anyformer's resolver, so `import { pipeline } from 'anyformer'` replaces
2
+ // `from '@huggingface/transformers'` for the calls below. Any repo anyformer can plan works: GGUF on diskformer,
3
+ // text diffusion on kohra, ONNX on transformers.js.
4
+ //
5
+ // const gen = await pipeline('text-generation', 'google/gemma-4-26B-A4B-it-qat-q4_0-gguf', { budget: 2.5e9 });
6
+ // const [{ generated_text }] = await gen(messages, { max_new_tokens: 128 }); // messages in → messages + reply out
7
+ // const [{ generated_text: text }] = await gen('Once upon a time'); // a string in → the reply string out
8
+ //
9
+ // Other tasks return what the session's run() returns (feature-extraction: plain arrays, not a Tensor).
10
+ // Options: budget, maxCtx, backends, progress_callback (transformers.js's name) or onProgress, signal.
11
+
12
+ import { load } from './load.js';
13
+ import { AnyformerError } from './errors.js';
14
+
15
+ export async function pipeline(task, repo, options = {}) {
16
+ if (!repo) throw new AnyformerError('INVALID_ARGUMENT', 'pipeline(task, repo): anyformer needs a repo id or a MODELS entry');
17
+ const { progress_callback, ...opts } = options;
18
+ const session = await load(repo, { ...opts, task, onProgress: opts.onProgress || progress_callback });
19
+ const call = task === 'text-generation'
20
+ ? async (input, o = {}) => {
21
+ const messages = typeof input === 'string' ? [{ role: 'user', content: input }] : input;
22
+ const reply = await session.run(messages, { maxNewTokens: o.max_new_tokens ?? o.maxNewTokens ?? 256, signal: o.signal });
23
+ return [{ generated_text: typeof input === 'string' ? reply.text : [...messages, { role: 'assistant', content: reply.text }] }];
24
+ }
25
+ : (input, o) => session.run(input, o);
26
+ return Object.assign(call, { task, backend: session.backend, plan: session.plan, session, dispose: () => session.dispose() });
27
+ }
package/src/probe.js ADDED
@@ -0,0 +1,35 @@
1
+ // What this browser can do, read once per page. Every field has a safe value when an API is missing (Node, Firefox,
2
+ // a page without WebGPU), so a back end's canRun() reads caps without guarding.
3
+
4
+ // A wasm module with one memory whose flags byte is 0x04 (memory64). It validates only where memory64 is supported.
5
+ const MEMORY64_PROBE = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 5, 3, 1, 4, 0]);
6
+
7
+ export function memory64() {
8
+ try { return WebAssembly.validate(MEMORY64_PROBE); } catch { return false; }
9
+ }
10
+
11
+ export async function probe({ gpu = globalThis.navigator?.gpu, storage = globalThis.navigator?.storage } = {}) {
12
+ const caps = {
13
+ webgpu: false, f16: false, subgroups: false,
14
+ maxBufferSize: 0, maxStorageBufferBindingSize: 0, adapter: null,
15
+ opfs: false, quotaBytes: 0, usageBytes: 0,
16
+ memory64: memory64(),
17
+ };
18
+ const adapter = gpu ? await gpu.requestAdapter({ powerPreference: 'high-performance' }).catch(() => null) : null;
19
+ if (adapter) {
20
+ caps.webgpu = true;
21
+ caps.f16 = adapter.features.has('shader-f16');
22
+ caps.subgroups = adapter.features.has('subgroups');
23
+ caps.maxBufferSize = adapter.limits.maxBufferSize;
24
+ caps.maxStorageBufferBindingSize = adapter.limits.maxStorageBufferBindingSize;
25
+ const info = adapter.info || {};
26
+ caps.adapter = { vendor: info.vendor || '', architecture: info.architecture || '', description: info.description || '' };
27
+ }
28
+ if (storage?.getDirectory) {
29
+ caps.opfs = true;
30
+ const est = storage.estimate ? await storage.estimate().catch(() => null) : null;
31
+ caps.quotaBytes = est?.quota || 0;
32
+ caps.usageBytes = est?.usage || 0;
33
+ }
34
+ return caps;
35
+ }
package/src/resolve.js ADDED
@@ -0,0 +1,48 @@
1
+ // Ask every back end whether it can run the model under the budget, then rank the ones that can.
2
+ //
3
+ // Back-end contract (each back end is a plain object):
4
+ // id string, stable, e.g. 'diskformer'
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? }
7
+ // estimate = { gpuBytes, diskBytes, tokPerSec | null }. No network, no GPU allocation: it reads the
8
+ // descriptor and caps only. code is one of REFUSALS below; need is the budget that would pass, with
9
+ // code 'budget'. A throw counts as code 'threw' with the message as the reason.
10
+ // load(model, { budget, caps, maxCtx, dtype, onProgress, signal }) → session (maxCtx: tokens; dtype: a precision
11
+ // the back end offers, e.g. 'q4f16' for transformers.js; undefined = the back end's default)
12
+ // session = { run(input, opts) → result, dispose() }; a text-generation session also has
13
+ // generate(messages, opts) → async iterable of { text, tokens }, and run(messages) is its last chunk.
14
+ // task is the requested pipeline task, or null for the repo's own (model.task).
15
+ // The back end applies the model's own chat template.
16
+
17
+ // Why a back end said no: a closed set, each with the move that changes the answer.
18
+ export const REFUSALS = {
19
+ format: 'the repo has no weights in a format this back end reads; pick a repo in another format',
20
+ model: 'this back end reads the format but not this model (architecture or file); pick another model',
21
+ task: 'this back end does not run the requested task; another back end or another repo does',
22
+ webgpu: 'no WebGPU adapter; use Chrome or Edge 121+ on a GPU with WebGPU',
23
+ f16: 'the GPU lacks shader-f16; use a GPU that has it',
24
+ opfs: 'no OPFS; use a browser with navigator.storage.getDirectory, outside private mode',
25
+ budget: 'the GPU budget is too small; pass { budget } at least `need` bytes',
26
+ threw: 'the back end crashed deciding; a bug in that back end, report it with the reason',
27
+ };
28
+
29
+ export async function resolve(model, caps, backends, { budget, task = null } = {}) {
30
+ const candidates = [];
31
+ for (const [order, b] of backends.entries()) {
32
+ 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
+ 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
+ const ranked = candidates.filter((c) => c.ok).sort(rank);
37
+ return { chosen: ranked[0]?.backend ?? null, ranked: ranked.map((c) => c.backend), candidates };
38
+ }
39
+
40
+ // A back end with a speed estimate ranks above one without; two estimates rank by tok/s; otherwise the order the
41
+ // caller listed them in, which is the default preference.
42
+ function rank(a, b) {
43
+ const ta = a.estimate?.tokPerSec ?? null;
44
+ const tb = b.estimate?.tokPerSec ?? null;
45
+ if (ta !== null && tb !== null && ta !== tb) return tb - ta;
46
+ if ((ta === null) !== (tb === null)) return ta === null ? 1 : -1;
47
+ return a.order - b.order;
48
+ }
package/src/sources.js ADDED
@@ -0,0 +1,22 @@
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
3
+ // to each repo's current main, and the browser tests then prove the new pins (test/browser/README.md).
4
+ // transformers.js and wllama move only by hand, in step with LocalMind's pins (plan/history.md, 2026-10-08).
5
+
6
+ export const PINS = {
7
+ transformers: '4.3.1',
8
+ wllama: '3.4.1', // LocalMind's pin; its stream-drain workaround (src/backends/wllama.js) is for this version
9
+ kohra: 'f678fac7342c79b58b640540b8b3a7d078b7ab1a',
10
+ diskformer: '524bc50e2edd186403403090dd83d13f66a8f73f',
11
+ localmind: '203288a0f0e23919b43c76bff8dc5d49f48ed300',
12
+ };
13
+
14
+ const gh = (repo, sha, path) => `https://cdn.jsdelivr.net/gh/NakliTechie/${repo}@${sha}/${path}`;
15
+
16
+ export const SOURCES = {
17
+ transformers: `https://cdn.jsdelivr.net/npm/@huggingface/transformers@${PINS.transformers}/+esm`,
18
+ wllama: `https://cdn.jsdelivr.net/npm/@wllama/wllama@${PINS.wllama}/esm`,
19
+ kohra: gh('kohra', PINS.kohra, 'kohra.js'),
20
+ diskformer: gh('diskformer.js', PINS.diskformer, 'engines'),
21
+ localmind: gh('LocalMind', PINS.localmind, 'src'),
22
+ };
package/src/store.js ADDED
@@ -0,0 +1,126 @@
1
+ // What anyformer's back ends have downloaded into this browser, and removing it. Each back end keeps its own store;
2
+ // this module reads them all into one shape: { backend, repo, bytes }. Modelled on LocalMind's
3
+ // inventoryModelCaches() (index.html), which covers the same engines' stores.
4
+ // Cache Storage 'transformers-cache' (transformers.js; kohra's tokenizer too); 'gguf-v1', 'gguf-v1-headers' and
5
+ // 'bonsai-pipeline-v1' (LocalMind's engines; 'gguf-v1' holds the LFM2
6
+ // engine): one entry per file URL (…/<repo>/resolve/<rev>/<file>). kohra's ONNX graph sits in the
7
+ // browser's HTTP cache, which a page cannot list.
8
+ // IndexedDB 'gguf-cache-v1' (Bonsai 2) and 'safetensors-cache-v1' (Gemma 4 QAT-mobile): a store keyed by URL
9
+ // with { size } and a chunk store keyed [url, start, end]; 'webgpu-prefix-snapshots' (Bonsai 2's
10
+ // system-prompt snapshot).
11
+ // OPFS 'localmind-ssd/<key>/' (diskformer's MoE stores, Gemma 4's PLE table) with manifest.json naming the
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).
14
+
15
+ import { DISKFORMER_MODELS } from './backends/diskformer.js';
16
+ import { SPLIT_ROOT } from './gguf-split.js';
17
+
18
+ const CACHE_BACKEND = { 'transformers-cache': 'transformers', 'gguf-v1': 'localmind', 'gguf-v1-headers': 'localmind', 'bonsai-pipeline-v1': 'localmind' };
19
+ const IDB_STORES = ['gguf-cache-v1', 'safetensors-cache-v1'];
20
+ const BONSAI2 = 'prism-ml/Ternary-Bonsai-2-27B-gguf';
21
+
22
+ const repoOf = (url) => /^https:\/\/huggingface\.co\/([^/]+\/[^/]+)\/resolve\//.exec(url || '')?.[1] ?? null;
23
+ const req = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
24
+ const openDb = (name) => new Promise((res, rej) => {
25
+ const r = indexedDB.open(name);
26
+ r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
27
+ r.onupgradeneeded = () => { r.transaction.abort(); rej(new Error(`${name} does not exist`)); };
28
+ });
29
+
30
+ async function opfsDir(path) {
31
+ if (!globalThis.navigator?.storage?.getDirectory) return null;
32
+ let dir = await navigator.storage.getDirectory();
33
+ for (const name of path) {
34
+ try { dir = await dir.getDirectoryHandle(name); } catch { return null; }
35
+ }
36
+ return dir;
37
+ }
38
+ async function dirBytes(dir) {
39
+ let n = 0;
40
+ for await (const [, h] of dir.entries()) n += h.kind === 'file' ? (await h.getFile()).size : await dirBytes(h);
41
+ return n;
42
+ }
43
+ const readJson = async (dir, name) => { try { return JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text()); } catch { return null; } };
44
+
45
+ // Every stored piece: { backend, repo, bytes, remove }.
46
+ async function inventory() {
47
+ const out = [];
48
+ const add = (backend, repo, bytes, remove) => { if (repo) out.push({ backend, repo, bytes: bytes || 0, remove }); };
49
+
50
+ if (typeof caches !== 'undefined') for (const name of await caches.keys()) {
51
+ const cache = await caches.open(name);
52
+ for (const r of await cache.keys()) {
53
+ // Content-Length is in the stored headers; reading the body to size it would load every file into memory.
54
+ const res = await cache.match(r);
55
+ add(CACHE_BACKEND[name] || name, repoOf(r.url), Number(res?.headers.get('content-length')) || 0, () => cache.delete(r));
56
+ }
57
+ }
58
+
59
+ let dbs = [];
60
+ try { dbs = (await indexedDB.databases()).map((d) => d.name); } catch { /* no IndexedDB */ }
61
+ for (const dbName of IDB_STORES.filter((n) => dbs.includes(n))) {
62
+ const db = await openDb(dbName);
63
+ let meta = null, chunks = null;
64
+ for (const s of db.objectStoreNames) {
65
+ const [k] = await req(db.transaction(s, 'readonly').objectStore(s).getAllKeys(null, 1));
66
+ if (Array.isArray(k)) chunks = s; else if (typeof k === 'string') meta = s;
67
+ }
68
+ if (meta) for (const url of await req(db.transaction(meta, 'readonly').objectStore(meta).getAllKeys())) {
69
+ const m = await req(db.transaction(meta, 'readonly').objectStore(meta).get(url));
70
+ add('localmind', repoOf(url), m?.size, async () => {
71
+ if (chunks) await req(db.transaction(chunks, 'readwrite').objectStore(chunks).delete(IDBKeyRange.bound([url], [url, [], []])));
72
+ await req(db.transaction(meta, 'readwrite').objectStore(meta).delete(url));
73
+ });
74
+ }
75
+ }
76
+ if (dbs.includes('webgpu-prefix-snapshots')) {
77
+ const db = await openDb('webgpu-prefix-snapshots');
78
+ const stores = [...db.objectStoreNames];
79
+ const slot = stores.includes('meta') ? await req(db.transaction('meta', 'readonly').objectStore('meta').get('prefix-snapshot-slot')) : null;
80
+ if (slot) add('localmind', BONSAI2, (slot.sizes || []).reduce((n, s) => n + s, 0), async () => {
81
+ for (const s of stores) await req(db.transaction(s, 'readwrite').objectStore(s).clear());
82
+ });
83
+ }
84
+
85
+ const ssd = await opfsDir(['localmind-ssd']);
86
+ if (ssd) for await (const [key, h] of ssd.entries()) {
87
+ if (h.kind !== 'directory') continue;
88
+ 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;
90
+ add(DISKFORMER_MODELS.some((m) => m.repo === repo) ? 'diskformer' : 'localmind', repo, await dirBytes(h), () => ssd.removeEntry(key, { recursive: true }));
91
+ }
92
+ const wcache = await opfsDir(['cache']);
93
+ if (wcache) for await (const [name, h] of wcache.entries()) {
94
+ if (h.kind !== 'file' || name.startsWith('__metadata__')) continue;
95
+ add('wllama', repoOf((await readJson(wcache, `__metadata__${name}`))?.originalURL), (await h.getFile()).size, async () => {
96
+ await wcache.removeEntry(name);
97
+ await wcache.removeEntry(`__metadata__${name}`).catch(() => {});
98
+ });
99
+ }
100
+ const split = await opfsDir([SPLIT_ROOT]);
101
+ if (split) for await (const [key, h] of split.entries()) {
102
+ if (h.kind !== 'directory') continue;
103
+ const repo = (await readJson(h, 'meta.json'))?.repo || (await readJson(h, 'done.json'))?.repo;
104
+ add('wllama', repo, await dirBytes(h), () => split.removeEntry(key, { recursive: true }));
105
+ }
106
+ return out;
107
+ }
108
+
109
+ // Every stored model, one row per back end and repo. A store of 0 bytes (an interrupted first write) is left out.
110
+ export async function stored() {
111
+ const rows = new Map();
112
+ for (const e of await inventory()) {
113
+ const k = `${e.backend} ${e.repo}`;
114
+ const r = rows.get(k) || { backend: e.backend, repo: e.repo, bytes: 0 };
115
+ r.bytes += e.bytes;
116
+ rows.set(k, r);
117
+ }
118
+ return [...rows.values()].filter((r) => r.bytes > 0);
119
+ }
120
+
121
+ // Remove a repo from every store that holds it. Returns the bytes freed. The next load downloads it again.
122
+ export async function remove(repo) {
123
+ let bytes = 0;
124
+ for (const e of await inventory()) if (e.repo === repo) { bytes += e.bytes; await e.remove(); }
125
+ return { repo, bytes };
126
+ }
package/src/tools.js ADDED
@@ -0,0 +1,148 @@
1
+ // The agent face: every public anyformer call as a tool (name, description, JSON-schema input, handler), published
2
+ // through WebMCP (navigator.modelContext) where the browser has it and as window.anyformer.tools otherwise. Tests drive
3
+ // this manifest, not the functions behind it.
4
+ //
5
+ // Consequence classes: 'read' runs freely; 'stages' lands only when the call accepts the consequence, otherwise it
6
+ // returns what would happen: anyformer_load (downloads weights, fills the GPU) needs acceptDiskBytes, anyformer_remove
7
+ // (deletes a downloaded model; the next load downloads it again) needs confirm.
8
+ // Person-only acts: none. anyformer has no act whose point is the person's own authorship or consent.
9
+
10
+ import { probe } from './probe.js';
11
+ import { plan, load } from './load.js';
12
+ import { MODELS } from './models.js';
13
+ import { stored, remove } from './store.js';
14
+
15
+ // Options every tool call passes to plan() and load(): a page sets backends or budget here; tests inject caps and fetch.
16
+ export const toolOptions = {};
17
+
18
+ const sessions = new Map();
19
+ let nextId = 1;
20
+ // The trajectory: the last 50 calls, with the door each came through. Bounded, so it never grows with use.
21
+ export const journal = [];
22
+ const record = (entry) => { journal.push({ at: new Date().toISOString(), ...entry }); if (journal.length > 50) journal.shift(); };
23
+
24
+ const repo = { type: 'string', description: 'Hugging Face repo id, e.g. onnx-community/Qwen2.5-0.5B-Instruct' };
25
+ const budget = { type: 'number', description: 'GPU memory budget in bytes; default 2.5e9' };
26
+
27
+ // A plan without the file list: bounded by the number of back ends, not the size of the repo.
28
+ function brief(p) {
29
+ return {
30
+ repo: p.model.repo, formats: p.model.formats, architecture: p.model.architecture, task: p.model.task,
31
+ budget: p.budget, chosen: p.chosen, ranked: p.ranked,
32
+ candidates: p.candidates.map(({ order, ...c }) => c),
33
+ diskBytes: p.candidates.find((c) => c.backend === p.chosen)?.estimate?.diskBytes ?? null,
34
+ };
35
+ }
36
+
37
+ export const TOOLS = [
38
+ {
39
+ name: 'anyformer_probe', consequence: 'read',
40
+ description: 'What this browser can run: WebGPU, shader-f16, subgroups, buffer limits, adapter, OPFS quota, memory64.',
41
+ inputSchema: { type: 'object', properties: {} },
42
+ handler: () => probe(),
43
+ },
44
+ {
45
+ name: 'anyformer_models', consequence: 'read',
46
+ description: 'Models that pass a browser test through anyformer, by family (chat, embedding, objectDetection): repo, back end, task, budget, download size. Pass a repo to anyformer_plan or anyformer_load.',
47
+ inputSchema: { type: 'object', properties: {} },
48
+ handler: () => MODELS,
49
+ },
50
+ {
51
+ name: 'anyformer_plan', consequence: 'read',
52
+ description: 'Which back end would run a model under a GPU budget, and why every other back end refused (closed codes: format, model, webgpu, f16, opfs, budget, threw). Loads nothing.',
53
+ inputSchema: { type: 'object', properties: { repo, budget }, required: ['repo'] },
54
+ handler: async ({ repo, budget }) => brief(await plan(repo, { ...toolOptions, ...(budget && { budget }) })),
55
+ },
56
+ {
57
+ name: 'anyformer_load', consequence: 'stages',
58
+ description: 'Load a model. Downloads its weights to browser storage on first use. Pass acceptDiskBytes >= the plan\'s diskBytes to load; without it the call returns the plan and loads nothing.',
59
+ inputSchema: {
60
+ type: 'object', required: ['repo'],
61
+ properties: { repo, budget, acceptDiskBytes: { type: 'number', description: 'disk bytes the caller accepts this load may write' } },
62
+ },
63
+ handler: async ({ repo, budget, acceptDiskBytes }) => {
64
+ const p = brief(await plan(repo, { ...toolOptions, ...(budget && { budget }) }));
65
+ if (!p.chosen) return { loaded: false, plan: p };
66
+ if (!(acceptDiskBytes >= (p.diskBytes || 0))) return { loaded: false, staged: true, plan: p };
67
+ const s = await load(repo, { ...toolOptions, ...(budget && { budget }) });
68
+ const session = `s${nextId++}`;
69
+ sessions.set(session, { repo, backend: s.backend, s });
70
+ return { loaded: true, session, backend: s.backend };
71
+ },
72
+ },
73
+ {
74
+ name: 'anyformer_generate', consequence: 'read',
75
+ description: 'Reply to a chat with a loaded model. Greedy decoding; returns the whole reply.',
76
+ inputSchema: {
77
+ type: 'object', required: ['session', 'messages'],
78
+ properties: {
79
+ session: { type: 'string' },
80
+ messages: { type: 'array', items: { type: 'object', required: ['role', 'content'], properties: { role: { type: 'string' }, content: { type: 'string' } } } },
81
+ maxNewTokens: { type: 'number', description: 'default 256' },
82
+ },
83
+ },
84
+ handler: async ({ session, messages, maxNewTokens = 256 }) => {
85
+ const e = sessions.get(session);
86
+ if (!e) throw new Error(`no session ${session}; anyformer_sessions lists the open ones, anyformer_load opens one`);
87
+ let last = { text: '', tokens: 0 };
88
+ for await (const c of e.s.generate(messages, { maxNewTokens })) last = c;
89
+ return { text: last.text, tokens: last.tokens };
90
+ },
91
+ },
92
+ {
93
+ name: 'anyformer_sessions', consequence: 'read',
94
+ description: 'The loaded models, and the last 50 tool calls with the door each came through.',
95
+ inputSchema: { type: 'object', properties: {} },
96
+ handler: () => ({ sessions: [...sessions].map(([id, e]) => ({ session: id, repo: e.repo, backend: e.backend })), journal }),
97
+ },
98
+ {
99
+ name: 'anyformer_stored', consequence: 'read',
100
+ description: 'Models downloaded into this browser: back end, repo, bytes.',
101
+ inputSchema: { type: 'object', properties: {} },
102
+ handler: () => stored(),
103
+ },
104
+ {
105
+ name: 'anyformer_remove', consequence: 'stages',
106
+ description: 'Delete a downloaded model from this browser. Without confirm: true it returns what it would delete and deletes nothing. The next load downloads it again.',
107
+ inputSchema: { type: 'object', required: ['repo'], properties: { repo, confirm: { type: 'boolean' } } },
108
+ handler: async ({ repo, confirm }) => {
109
+ if (confirm !== true) return { removed: false, staged: true, would: (await stored()).filter((r) => r.repo === repo) };
110
+ return { removed: true, ...(await remove(repo)) };
111
+ },
112
+ },
113
+ {
114
+ name: 'anyformer_unload', consequence: 'read',
115
+ description: 'Free a loaded model\'s GPU memory. Its weights stay in browser storage for the next load.',
116
+ inputSchema: { type: 'object', required: ['session'], properties: { session: { type: 'string' } } },
117
+ handler: async ({ session }) => {
118
+ const e = sessions.get(session);
119
+ if (!e) return { unloaded: false };
120
+ sessions.delete(session);
121
+ await e.s.dispose();
122
+ return { unloaded: true };
123
+ },
124
+ },
125
+ ];
126
+
127
+ // Call a tool by name, as either door does. Errors come back as { error } so a caller branches on data, not a throw.
128
+ export async function call(name, input = {}, door = 'direct') {
129
+ const t = TOOLS.find((x) => x.name === name);
130
+ if (!t) return { error: `no tool ${name}; tools: ${TOOLS.map((x) => x.name).join(', ')}` };
131
+ try {
132
+ const out = await t.handler(input);
133
+ record({ tool: name, door, ok: true });
134
+ return out;
135
+ } catch (e) {
136
+ record({ tool: name, door, ok: false, error: e.message });
137
+ return { error: e.message, ...(e.code && { code: e.code }), ...(e.need && { need: e.need }), ...(e.plan && { plan: brief(e.plan) }) };
138
+ }
139
+ }
140
+
141
+ // Publish the manifest: WebMCP when present, window.anyformer.tools always (the same entries).
142
+ export function registerTools({ modelContext = globalThis.navigator?.modelContext, target = globalThis } = {}) {
143
+ if (modelContext?.registerTool)
144
+ for (const t of TOOLS)
145
+ modelContext.registerTool({ name: t.name, description: t.description, inputSchema: t.inputSchema, execute: (input) => call(t.name, input, 'modelContext') });
146
+ target.anyformer = { ...(target.anyformer || {}), tools: Object.fromEntries(TOOLS.map((t) => [t.name, (input) => call(t.name, input, 'window')])) };
147
+ return TOOLS.map((t) => t.name);
148
+ }