Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ surfaces: the MCP server's tool names, arguments and answers, `opendecider.tools
- **From 1.0.0 on:** backward-compatible additions ship in minor releases, and breaking changes only in major releases,
again with a deprecation release first.

## [Unreleased]

### Fixed

- `@opendecider/web`: where the browser's Cache API refuses a model file (a user reported it in Chrome), the file was
downloaded again on every load, without a word. It is now kept in the origin private file system instead,
and checked against its pinned SHA-256 like a cached copy. `onProgress` says where a downloaded file was kept, in a
new optional field on its last event, `stored`: `"cache"`, `"opfs"`, or `false` when neither would keep it. Once a
model has loaded, the files of builds the installed version no longer pins are deleted from both, so an update does
not leave an older build's 450–755 MiB behind; every build it pins is kept. `cache: false` still keeps nothing.
- OpenDecider Focus: when Chrome did not let it keep the model, the popup says so under the ready line, since the model
then downloads again next time.

## [0.8.1] - 2026-10-05

OpenDecider Focus only: the Python and npm packages are unchanged apart from their version number.
Expand Down
4 changes: 3 additions & 1 deletion docs/chrome-extension-privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ and its code is public: [github.com/manjunathshiva/opendecider](https://github.c
- **Answers already computed:** for each video judged, its id and the model's probabilities (never the title), so a
video is not judged twice. They are kept in the extension's local storage: the newest 5,000, with older ones deleted
when Chrome starts and every six hours (so for a few hours there can be more).
- **The model's files:** opendecider-nano (569–755 MiB), kept in the browser's cache after the first download.
- **The model's files:** opendecider-nano (569–755 MiB), kept in the browser's cache after the first download (or,
where Chrome refuses that, in the extension's own file storage). Files of model builds a newer version no longer uses
are deleted.
- **The text you check:** kept in Chrome's session storage (memory, never written to disk) until the guard window
reads it, then deleted from there. The guard window and the model's page hold it in memory while they check and show
it, and it is gone when the window closes. If the window closes before reading it, it stays in session storage until
Expand Down
13 changes: 11 additions & 2 deletions docs/guides/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ const guard = new Guard({ model }); // the prompt guard, on the same model
The first `loadNano()` downloads the build once (about 450 MiB) and keeps it in the browser's Cache API; later loads
read it from there in a second or two. Load it once per page or worker and reuse it.

Where the Cache API refuses a file, it is kept in the
[origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system)
instead. The last `onProgress` event of a downloaded file says where, in `stored`: `"cache"`, `"opfs"`, or `false` when
the browser kept it in neither (storage full or blocked), so the next load downloads it again; tell the user then.
Either copy is checked against the pinned SHA-256 each time it is read. Once a model has loaded, files of builds the
installed version does not pin (an older version's) are deleted; `cache: false` keeps nothing and deletes nothing.

## Builds and devices

| device | build (default) | download | one question (Chrome, Apple M4 Max) |
Expand Down Expand Up @@ -95,8 +102,10 @@ so the page stays responsive. WebGPU does its work off the main thread.
## Node, Bun and Deno

The same package runs WebAssembly there. Download the files once, at the revision your installed version pins, and read
them yourself; they are still checked against the pinned SHA-256. (There is no Cache API there: without `files`, every
start downloads the files again.)
them yourself; they are still checked against the pinned SHA-256. (Node and Bun have no Cache API or private file
system: without `files`, every start downloads the files again. Deno has a
[Cache API](https://docs.deno.com/examples/web_cache_api/) that keeps its files between runs, and `loadNano` uses it
as in a browser.)

```bash
REVISION=$(node --input-type=module -e 'import { REVISION } from "@opendecider/web"; console.log(REVISION)')
Expand Down
3 changes: 2 additions & 1 deletion docs/guides/chrome-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ gameplay?", "Is this a news or current affairs video?".

Times on an Apple M4 Max in Chrome (8 threads without the GPU); a slower computer takes longer.

The model is kept in the browser after the first download, and later loads in one or two seconds. It is loaded when
The model is kept in the browser after the first download, and later loads in one or two seconds (if Chrome does not
let the extension keep it, the popup says so, and it downloads again on the next load). It is loaded when
you open YouTube and closed after 10 minutes without filtering, which gives the memory back. Without a GPU, the feed
fills in a few videos at a time.

Expand Down
1 change: 1 addition & 0 deletions extension/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ const dl = await send({ type: "download" });
check(dl?.ok, `the model loads (${((Date.now() - t0) / 1000).toFixed(1)} s): ${dl?.error ?? ""}`);
const { model } = await extPage.evaluate(() => chrome.storage.session.get("model"));
console.log(" model:", JSON.stringify(model));
check(["cache", "opfs"].includes(model?.stored), `the downloaded model is kept for the next load (${model?.stored})`);

// 3. Focus (the default): learning kept, entertainment hidden, Shorts hidden, an untitled tile shown
await yt.reload();
Expand Down
10 changes: 8 additions & 2 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,16 @@ async function load(retry = false): Promise<void> {
try {
// the build that loaded last time: q8 where WebGPU could not run the model, so it is not tried on every load
const { build } = await chrome.storage.local.get("build");
const s = await offscreen<{ device: string; dtype: string }>({ type: "load", build });
const s = await offscreen<{ device: string; dtype: string; stored: "cache" | "opfs" | false | null }>({
type: "load",
build,
});
ready = true;
await chrome.storage.local.set({ build: s.dtype });
await chrome.storage.session.set({ model: { state: "ready", device: s.device, dtype: s.dtype } });
// stored: where a download was kept (false: nowhere, so the next load downloads it again; null: no download)
await chrome.storage.session.set({
model: { state: "ready", device: s.device, dtype: s.dtype, stored: s.stored },
});
} catch (e) {
failedAt = Date.now();
await chrome.storage.session.set({
Expand Down
10 changes: 8 additions & 2 deletions extension/src/offscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ declare const __MODEL_URL__: string;

let model: Promise<OpenDecider> | null = null;
let lastProgress = 0;
/** Where the files this load downloaded were kept: false when one was kept nowhere (it downloads again next time). */
let stored: "cache" | "opfs" | false | undefined;

function progress(p: { file: string; loaded: number; total: number; cached: boolean }) {
const now = performance.now();
Expand All @@ -27,12 +29,16 @@ const open = (device: "webgpu" | "wasm", dtype: Build["dtype"]) =>
wasmPaths: chrome.runtime.getURL("ort/"),
numThreads: Math.max(1, Math.min(8, navigator.hardwareConcurrency - 2)),
},
onProgress: ({ file, loaded, total, cached }) => progress({ file, loaded, total, cached }),
onProgress: ({ file, loaded, total, cached, stored: kept }) => {
if (kept !== undefined && stored !== false) stored = kept;
progress({ file, loaded, total, cached });
},
});

/** `last`: the build that loaded last time (the service worker keeps it). q8 with a GPU adapter means WebGPU could
* not run the model then, so it goes straight to the CPU build. */
function load(last?: unknown): Promise<OpenDecider> {
if (!model) stored = undefined;
model ??= pickBuild()
.then((build) => (last === "q8" ? { ...build, device: "wasm" as const, dtype: "q8" as const } : build))
.then((build) => openBuild(build, open, (e) => e instanceof ModelFileError))
Expand Down Expand Up @@ -67,7 +73,7 @@ async function guardCheck(text: string): Promise<GuardResult> {

async function status() {
const m = model && (await model.catch(() => null));
return { loaded: !!m, device: m?.meta["device"] ?? null, dtype: m?.meta["dtype"] ?? null };
return { loaded: !!m, device: m?.meta["device"] ?? null, dtype: m?.meta["dtype"] ?? null, stored: stored ?? null };
}

chrome.runtime.onMessage.addListener((m, sender, reply) => {
Expand Down
17 changes: 16 additions & 1 deletion extension/src/popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,15 @@ async function renderModel() {
const box = $("model");
const { consent } = await chrome.storage.local.get("consent");
const { model } = (await chrome.storage.session.get("model")) as {
model?: { state: string; loaded?: number; total?: number; cached?: boolean; device?: string; error?: string };
model?: {
state: string;
loaded?: number;
total?: number;
cached?: boolean;
device?: string;
stored?: string | false | null;
error?: string;
};
};
box.replaceChildren();
const p = document.createElement("p");
Expand Down Expand Up @@ -106,6 +114,13 @@ async function renderModel() {
model?.state === "ready"
? `Model ready, on this computer's ${model.device === "webgpu" ? "GPU (WebGPU)" : "CPU (WebAssembly)"}.`
: "Model downloaded: it loads when you open YouTube.";
if (model?.state === "ready" && model.stored === false) {
// the download worked, but neither the Cache API nor the extension's file storage would keep it
const note = document.createElement("p");
note.className = "muted";
note.textContent = "Chrome didn't let the extension keep the model, so it will download again next time.";
box.append(note);
}
}

async function renderCounts() {
Expand Down
5 changes: 4 additions & 1 deletion web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ const guard = new Guard({ model });

- `loadNano(options?)`: the model, as an `OpenDecider` (`systemOne`, `systemOneBatch`). Options: `device` (`"auto"`,
`"webgpu"`, `"wasm"`), `dtype` (`"q8f16"`, `"q8"`, `"fp16"`), `baseUrl`, `files` (read the files yourself, e.g. in Node),
`cache`, `onProgress`, `wasm` (`wasmPaths`, `numThreads`, `proxy`), `maxBatchTokens`, `signal`, `fetch`.
`cache`, `onProgress`, `wasm` (`wasmPaths`, `numThreads`, `proxy`), `maxBatchTokens`, `signal`, `fetch`. The files are
kept in the Cache API, or where it refuses them the origin private file system. `onProgress` gets
`{ file, loaded, total, cached }`, and on the last event of a downloaded file `stored`: `"cache"`, `"opfs"`, or
`false` when neither kept it (the next load downloads it again).
- `NanoBackend`: what `loadNano` wraps (`dispose()` frees the model's memory).
- `ModelFileError`: a model file that could not be loaded, or did not match its pinned size and SHA-256.
- `MODELS`, `TOKENIZER`, `REPO`, `REVISION`, `HUB_URL`, `MAX_TOKENS`: the pinned build (types `Dtype`, `FileSpec`,
Expand Down
143 changes: 120 additions & 23 deletions web/src/hub.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/** The model files: downloaded once, checked against the SHA-256 pinned in this package, kept in the Cache API. */
/** The model files: downloaded once, checked against the SHA-256 pinned in this package, kept in the Cache API (or,
* where it refuses them, the origin private file system). */

/** One file of a model build: where it is, and its exact size and SHA-256. */
export interface FileSpec {
Expand All @@ -12,8 +13,12 @@ export interface LoadProgress {
file: string;
loaded: number;
total: number;
/** True when the file came from the cache. */
/** True when the file came from the cache (the Cache API or the origin private file system). */
cached: boolean;
/** On the last event of a file that was downloaded: where it was kept for the next load, "cache" (the Cache API),
* "opfs" (the origin private file system, where the Cache API refused it) or false (neither kept it: the next load
* downloads it again). Absent otherwise, and with `files` or `cache: false`. */
stored?: "cache" | "opfs" | false;
}

/** A model file that could not be loaded, or did not match its pinned size or SHA-256 (it is never used then). */
Expand All @@ -26,20 +31,33 @@ export interface FetchOptions {
baseUrl: string;
/** Read a file yourself instead of downloading it (Node, Bun, Deno: a local folder). It is still checked. */
files?: ((path: string) => Promise<ArrayBuffer | Uint8Array>) | undefined;
/** Keep the files in the Cache API (browsers, workers, extensions). Default true where it exists. */
/** Keep the files in the Cache API, or where it refuses them the origin private file system (browsers, workers,
* extensions). Default true where either exists; false reads and writes neither. */
cache?: boolean | undefined;
fetch?: typeof globalThis.fetch | undefined;
onProgress?: ((p: LoadProgress) => void) | undefined;
signal?: AbortSignal | undefined;
}

const CACHE = "opendecider-web-v1";
const CACHE = "opendecider-web-v1"; // the Cache API's cache, and the origin private file system's folder
const KEY = "https://opendecider.invalid/sha256/"; // + the SHA-256: the content, wherever it was downloaded from
const SHA256 = /^[0-9a-f]{64}$/;

/** Where downloaded files are kept, by SHA-256. Any call may reject (storage full, blocked, or cleared meanwhile). */
interface Store {
name: "cache" | "opfs";
get(sha256: string): Promise<Uint8Array | undefined>;
put(sha256: string, bytes: Uint8Array): Promise<void>;
delete(sha256: string): Promise<unknown>;
/** The SHA-256 of every file kept. */
list(): Promise<string[]>;
}

/** The file's bytes, after its size and SHA-256 matched the spec. */
export async function loadFile(spec: FileSpec, o: FetchOptions): Promise<Uint8Array> {
o.signal?.throwIfAborted();
const progress = (loaded: number, cached: boolean) =>
o.onProgress?.({ file: spec.path, loaded, total: spec.bytes, cached });
const progress = (loaded: number, cached: boolean, stored?: LoadProgress["stored"]) =>
o.onProgress?.({ file: spec.path, loaded, total: spec.bytes, cached, ...(stored === undefined ? {} : { stored }) });
if (o.files) {
const got = await o.files(spec.path);
o.signal?.throwIfAborted();
Expand All @@ -48,34 +66,113 @@ export async function loadFile(spec: FileSpec, o: FetchOptions): Promise<Uint8Ar
progress(bytes.length, false);
return bytes;
}
const cache = o.cache !== false ? await openCache() : null;
const key = `https://opendecider.invalid/sha256/${spec.sha256}`; // the content, wherever it was downloaded from
if (cache) {
const hit = await cache.match(key).catch(() => undefined);
if (hit) {
const bytes = new Uint8Array(await hit.arrayBuffer());
o.signal?.throwIfAborted();
try {
await check(spec, bytes);
progress(bytes.length, true);
return bytes;
} catch {
await cache.delete(key).catch(() => false); // damaged or replaced: download it again
}
const stores = o.cache !== false ? await openStores() : null;
for (const store of stores ?? []) {
const hit = await store.get(spec.sha256).catch(() => undefined);
if (!hit) continue;
o.signal?.throwIfAborted();
try {
await check(spec, hit);
progress(hit.length, true);
return hit;
} catch {
await store.delete(spec.sha256).catch(() => false); // damaged or replaced: download it again
}
}
const bytes = await download(spec, o, (n) => progress(n, false));
await check(spec, bytes);
if (cache) await cache.put(key, new Response(bytes as Uint8Array<ArrayBuffer>)).catch(() => undefined); // a full or blocked cache is no error
if (stores) progress(bytes.length, false, await save(stores, spec.sha256, bytes));
return bytes;
}

async function openCache(): Promise<Cache | null> {
/** Deletes every kept file but `keep` (SHA-256s), such as the builds an older version of the package pinned. Never
* fails: a file left behind only takes space. */
export async function prune(keep: readonly string[]): Promise<void> {
for (const store of await openStores()) {
for (const sha256 of await store.list().catch(() => [])) {
if (!keep.includes(sha256)) await store.delete(sha256).catch(() => false);
}
}
}

/** The first store that keeps the bytes, or false: a full or blocked store is no error (the next load downloads). */
async function save(stores: Store[], sha256: string, bytes: Uint8Array): Promise<Store["name"] | false> {
for (const store of stores) {
try {
await store.put(sha256, bytes);
return store.name;
} catch {
// refused, full or blocked: the next store
}
}
return false;
}

/** The Cache API first, then the origin private file system: whichever of them exist here. */
async function openStores(): Promise<Store[]> {
return (await Promise.all([openCache(), openFolder()])).filter((s) => s !== null);
}

async function openCache(): Promise<Store | null> {
let cache: Cache;
try {
return typeof caches === "undefined" ? null : await caches.open(CACHE);
if (typeof caches === "undefined") return null;
cache = await caches.open(CACHE);
} catch {
return null; // e.g. an opaque origin, or storage blocked
}
return {
name: "cache",
get: async (sha256) => {
const hit = await cache.match(KEY + sha256);
return hit && new Uint8Array(await hit.arrayBuffer());
},
put: (sha256, bytes) => cache.put(KEY + sha256, new Response(bytes as Uint8Array<ArrayBuffer>)),
delete: (sha256) => cache.delete(KEY + sha256),
list: async () =>
(await cache.keys())
.filter((r) => r.url.startsWith(KEY))
.map((r) => r.url.slice(KEY.length))
.filter((k) => SHA256.test(k)),
};
}

/** The origin private file system's folder (navigator.storage.getDirectory), where there is one. */
async function openFolder(): Promise<Store | null> {
let dir: FileSystemDirectoryHandle;
try {
const storage = (globalThis.navigator as { storage?: Partial<StorageManager> } | undefined)?.storage;
if (typeof storage?.getDirectory !== "function") return null;
dir = await (await storage.getDirectory()).getDirectoryHandle(CACHE, { create: true });
} catch {
return null; // e.g. a private window without one
}
return {
name: "opfs",
get: async (sha256) => {
const file = await (await dir.getFileHandle(sha256)).getFile();
return new Uint8Array(await file.arrayBuffer());
},
put: async (sha256, bytes) => {
// where a page cannot write a file this way (older Safari's main thread): skip it, before an empty one exists
if (typeof FileSystemFileHandle === "undefined" || !("createWritable" in FileSystemFileHandle.prototype))
throw new Error("no createWritable");
const w = await (await dir.getFileHandle(sha256, { create: true })).createWritable();
try {
await w.write(bytes as Uint8Array<ArrayBuffer>);
await w.close(); // the file changes here, at once: a failed or concurrent write never leaves part of one
} catch (e) {
await w.abort().catch(() => undefined);
throw e;
}
},
delete: (sha256) => dir.removeEntry(sha256),
list: async () => {
const names: string[] = [];
for await (const n of (dir as unknown as { keys(): AsyncIterable<string> }).keys()) names.push(n);
return names.filter((n) => SHA256.test(n));
},
};
}

async function download(spec: FileSpec, o: FetchOptions, onBytes: (n: number) => void): Promise<Uint8Array> {
Expand Down
Loading
Loading