anyformer 0.0.1 → 0.3.1
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 +132 -0
- package/README.md +107 -17
- package/SPEC.md +68 -0
- package/index.js +17 -0
- package/package.json +41 -6
- package/src/backends/diskformer-llama.js +161 -0
- package/src/backends/diskformer.js +124 -0
- package/src/backends/kohra.js +59 -0
- package/src/backends/localmind.js +116 -0
- package/src/backends/transformers.js +191 -0
- package/src/backends/wllama.js +122 -0
- package/src/describe.js +47 -0
- package/src/errors.js +36 -0
- package/src/gguf-split.js +160 -0
- package/src/load.js +127 -0
- package/src/models.js +50 -0
- package/src/pipeline.js +27 -0
- package/src/probe.js +37 -0
- package/src/resolve.js +49 -0
- package/src/sources.js +23 -0
- package/src/store.js +145 -0
- package/src/tools.js +148 -0
package/src/store.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
// '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).
|
|
16
|
+
|
|
17
|
+
import { DISKFORMER_MODELS } from './backends/diskformer.js';
|
|
18
|
+
import { SPLIT_ROOT } from './gguf-split.js';
|
|
19
|
+
import { readLlamaIndex, LLAMA_INDEX } from './backends/diskformer-llama.js';
|
|
20
|
+
|
|
21
|
+
const CACHE_BACKEND = { 'transformers-cache': 'transformers', 'gguf-v1': 'localmind', 'gguf-v1-headers': 'localmind', 'bonsai-pipeline-v1': 'localmind' };
|
|
22
|
+
const IDB_STORES = ['gguf-cache-v1', 'safetensors-cache-v1'];
|
|
23
|
+
const BONSAI2 = 'prism-ml/Ternary-Bonsai-2-27B-gguf';
|
|
24
|
+
|
|
25
|
+
const repoOf = (url) => /^https:\/\/huggingface\.co\/([^/]+\/[^/]+)\/resolve\//.exec(url || '')?.[1] ?? null;
|
|
26
|
+
const req = (r) => new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); });
|
|
27
|
+
const openDb = (name) => new Promise((res, rej) => {
|
|
28
|
+
const r = indexedDB.open(name);
|
|
29
|
+
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
|
|
30
|
+
r.onupgradeneeded = () => { r.transaction.abort(); rej(new Error(`${name} does not exist`)); };
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function opfsDir(path) {
|
|
34
|
+
if (!globalThis.navigator?.storage?.getDirectory) return null;
|
|
35
|
+
let dir = await navigator.storage.getDirectory();
|
|
36
|
+
for (const name of path) {
|
|
37
|
+
try { dir = await dir.getDirectoryHandle(name); } catch { return null; }
|
|
38
|
+
}
|
|
39
|
+
return dir;
|
|
40
|
+
}
|
|
41
|
+
async function dirBytes(dir) {
|
|
42
|
+
let n = 0;
|
|
43
|
+
for await (const [, h] of dir.entries()) n += h.kind === 'file' ? (await h.getFile()).size : await dirBytes(h);
|
|
44
|
+
return n;
|
|
45
|
+
}
|
|
46
|
+
const readJson = async (dir, name) => { try { return JSON.parse(await (await (await dir.getFileHandle(name)).getFile()).text()); } catch { return null; } };
|
|
47
|
+
|
|
48
|
+
// Every stored piece: { backend, repo, bytes, remove }.
|
|
49
|
+
async function inventory() {
|
|
50
|
+
const out = [];
|
|
51
|
+
const add = (backend, repo, bytes, remove) => { if (repo) out.push({ backend, repo, bytes: bytes || 0, remove }); };
|
|
52
|
+
|
|
53
|
+
if (typeof caches !== 'undefined') for (const name of await caches.keys()) {
|
|
54
|
+
const cache = await caches.open(name);
|
|
55
|
+
for (const r of await cache.keys()) {
|
|
56
|
+
// Content-Length is in the stored headers; reading the body to size it would load every file into memory.
|
|
57
|
+
const res = await cache.match(r);
|
|
58
|
+
add(CACHE_BACKEND[name] || name, repoOf(r.url), Number(res?.headers.get('content-length')) || 0, () => cache.delete(r));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let dbs = [];
|
|
63
|
+
try { dbs = (await indexedDB.databases()).map((d) => d.name); } catch { /* no IndexedDB */ }
|
|
64
|
+
for (const dbName of IDB_STORES.filter((n) => dbs.includes(n))) {
|
|
65
|
+
const db = await openDb(dbName);
|
|
66
|
+
let meta = null, chunks = null;
|
|
67
|
+
for (const s of db.objectStoreNames) {
|
|
68
|
+
const [k] = await req(db.transaction(s, 'readonly').objectStore(s).getAllKeys(null, 1));
|
|
69
|
+
if (Array.isArray(k)) chunks = s; else if (typeof k === 'string') meta = s;
|
|
70
|
+
}
|
|
71
|
+
if (meta) for (const url of await req(db.transaction(meta, 'readonly').objectStore(meta).getAllKeys())) {
|
|
72
|
+
const m = await req(db.transaction(meta, 'readonly').objectStore(meta).get(url));
|
|
73
|
+
add('localmind', repoOf(url), m?.size, async () => {
|
|
74
|
+
if (chunks) await req(db.transaction(chunks, 'readwrite').objectStore(chunks).delete(IDBKeyRange.bound([url], [url, [], []])));
|
|
75
|
+
await req(db.transaction(meta, 'readwrite').objectStore(meta).delete(url));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (dbs.includes('webgpu-prefix-snapshots')) {
|
|
80
|
+
const db = await openDb('webgpu-prefix-snapshots');
|
|
81
|
+
const stores = [...db.objectStoreNames];
|
|
82
|
+
const slot = stores.includes('meta') ? await req(db.transaction('meta', 'readonly').objectStore('meta').get('prefix-snapshot-slot')) : null;
|
|
83
|
+
if (slot) add('localmind', BONSAI2, (slot.sizes || []).reduce((n, s) => n + s, 0), async () => {
|
|
84
|
+
for (const s of stores) await req(db.transaction(s, 'readwrite').objectStore(s).clear());
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ssd = await opfsDir(['localmind-ssd']);
|
|
89
|
+
if (ssd) for await (const [key, h] of ssd.entries()) {
|
|
90
|
+
if (h.kind !== 'directory') continue;
|
|
91
|
+
const src = (await readJson(h, 'manifest.json'))?.source;
|
|
92
|
+
const repo = src?.repo || repoOf(src?.url) || DISKFORMER_MODELS.find((m) => m.file?.toLowerCase().startsWith(key))?.repo;
|
|
93
|
+
add(DISKFORMER_MODELS.some((m) => m.repo === repo) ? 'diskformer' : 'localmind', repo, await dirBytes(h), () => ssd.removeEntry(key, { recursive: true }));
|
|
94
|
+
}
|
|
95
|
+
const wcache = await opfsDir(['cache']);
|
|
96
|
+
if (wcache) for await (const [name, h] of wcache.entries()) {
|
|
97
|
+
if (h.kind !== 'file' || name.startsWith('__metadata__')) continue;
|
|
98
|
+
add('wllama', repoOf((await readJson(wcache, `__metadata__${name}`))?.originalURL), (await h.getFile()).size, async () => {
|
|
99
|
+
await wcache.removeEntry(name);
|
|
100
|
+
await wcache.removeEntry(`__metadata__${name}`).catch(() => {});
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const split = await opfsDir([SPLIT_ROOT]);
|
|
104
|
+
if (split) for await (const [key, h] of split.entries()) {
|
|
105
|
+
if (h.kind !== 'directory') continue;
|
|
106
|
+
const repo = (await readJson(h, 'meta.json'))?.repo || (await readJson(h, 'done.json'))?.repo;
|
|
107
|
+
add('wllama', repo, await dirBytes(h), () => split.removeEntry(key, { recursive: true }));
|
|
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
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Every stored model, one row per back end and repo. A store of 0 bytes (an interrupted first write) is left out.
|
|
129
|
+
export async function stored() {
|
|
130
|
+
const rows = new Map();
|
|
131
|
+
for (const e of await inventory()) {
|
|
132
|
+
const k = `${e.backend} ${e.repo}`;
|
|
133
|
+
const r = rows.get(k) || { backend: e.backend, repo: e.repo, bytes: 0 };
|
|
134
|
+
r.bytes += e.bytes;
|
|
135
|
+
rows.set(k, r);
|
|
136
|
+
}
|
|
137
|
+
return [...rows.values()].filter((r) => r.bytes > 0);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Remove a repo from every store that holds it. Returns the bytes freed. The next load downloads it again.
|
|
141
|
+
export async function remove(repo) {
|
|
142
|
+
let bytes = 0;
|
|
143
|
+
for (const e of await inventory()) if (e.repo === repo) { bytes += e.bytes; await e.remove(); }
|
|
144
|
+
return { repo, bytes };
|
|
145
|
+
}
|
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
|
+
}
|