Add initial VibeGuard browser extension

This commit is contained in:
Jordan Wages 2026-08-24 15:27:46 -05:00
commit 9c65a61ebb
43 changed files with 125275 additions and 2 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
public/models/toxicity/onnx/*.onnx filter=lfs diff=lfs merge=lfs -text

6
.gitignore vendored
View file

@ -528,3 +528,9 @@ FodyWeavers.xsd
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# VibeGuard build artifacts
node_modules/
dist/
coverage/
.DS_Store

View file

@ -1,3 +1,41 @@
# vibeguard # VibeGuard
A browser add-on that hides vibe-killing toxic posts. VibeGuard is a local-first browser extension that hides toxic social-media posts. Content is classified on-device; post text is never sent to a classification service.
## Development
```sh
npm install
npm run typecheck
npm test
npm run build:firefox
npm run build:chromium
```
Each build writes a browser-specific package under `dist/<mode>/`. The packaged Apache 2.0 model is committed through Git LFS. Use the pinned-revision workflow below when updating it:
```sh
python3 -m venv .model-venv
. .model-venv/bin/activate
python3 -m pip install -r tools/requirements-model.txt
# Inspect architecture, labels, tokenizer, and resolved source revision.
python3 tools/convert_model.py inspect \
--model wagesj45/toxic-comment-classifier
# Use the immutable commit printed by inspection for a release artifact.
python3 tools/convert_model.py prepare \
--model wagesj45/toxic-comment-classifier \
--revision <resolved-huggingface-commit>
python3 tools/convert_model.py validate
```
Preparation writes the Transformers.js-compatible files and `model-manifest.json` under `public/models/toxicity/`. The manifest records the source revision, Apache 2.0 license, toxic/non-toxic label indices, maximum sequence length, and int8 quantization format. Source PyTorch/safetensors weights are never copied into the extension.
If the model uses generic labels such as `LABEL_0` and `LABEL_1`, pass `--toxic-index` and `--non-toxic-index` to `prepare`; the command refuses to guess an ambiguous mapping.
The first release targets Reddit, X/Twitter, and Facebook. Site selectors are isolated under `src/content/parsers/` because these sites frequently change their DOM structures.
## Runtime architecture
Firefox uses a persistent MV2 background page and inference worker. Chromium uses an MV3 service worker as a router, an offscreen document, and a worker-backed classifier. Both builds share one bounded, prioritized inference queue and in-memory text-result cache.

4452
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "vibeguard",
"version": "0.1.0",
"private": true,
"description": "A local-first browser extension that hides toxic social-media posts.",
"type": "module",
"scripts": {
"build": "npm run build:chromium",
"build:firefox": "vite build --mode firefox && VIBEGUARD_CLASSIC_ENTRY=content vite build --mode firefox && VIBEGUARD_CLASSIC_ENTRY=background vite build --mode firefox",
"build:chromium": "vite build --mode chromium && VIBEGUARD_CLASSIC_ENTRY=content vite build --mode chromium",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"model:inspect": "python3 tools/convert_model.py inspect",
"model:prepare": "python3 tools/convert_model.py prepare",
"model:validate": "python3 tools/convert_model.py validate"
},
"dependencies": {
"@huggingface/transformers": "^3.7.2"
},
"devDependencies": {
"@types/chrome": "^0.0.287",
"@types/firefox-webext-browser": "^120.0.4",
"jsdom": "^25.0.1",
"typescript": "^5.7.3",
"vite": "^6.0.7",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,12 @@
{
"manifest_version": 3,
"name": "VibeGuard",
"version": "0.1.0",
"description": "Hide toxic social-media posts locally.",
"permissions": ["storage", "tabs", "offscreen"],
"host_permissions": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
"background": { "service_worker": "background.js", "type": "module" },
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
"options_page": "options.html",
"web_accessible_resources": [{ "resources": ["inference.js", "models/*"], "matches": ["<all_urls>"] }]
}

View file

@ -0,0 +1,11 @@
{
"manifest_version": 2,
"name": "VibeGuard",
"version": "0.1.0",
"description": "Hide toxic social-media posts locally.",
"permissions": ["storage", "tabs", "https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"],
"background": { "scripts": ["background.js"], "persistent": true },
"content_scripts": [{ "matches": ["https://*.reddit.com/*", "https://*.x.com/*", "https://*.twitter.com/*", "https://*.facebook.com/*"], "js": ["content.js"], "run_at": "document_idle" }],
"options_ui": { "page": "options.html", "open_in_tab": true },
"web_accessible_resources": ["inference.js", "models/*"]
}

View file

@ -0,0 +1,37 @@
{
"activation": "gelu",
"architectures": [
"DistilBertForSequenceClassification"
],
"attention_dropout": 0.1,
"bos_token_id": null,
"dim": 768,
"dropout": 0.1,
"dtype": "float32",
"eos_token_id": null,
"hidden_dim": 3072,
"id2label": {
"0": "not_toxic",
"1": "toxic"
},
"initializer_range": 0.02,
"label2id": {
"not_toxic": 0,
"toxic": 1
},
"max_position_embeddings": 512,
"model_type": "distilbert",
"n_heads": 12,
"n_layers": 6,
"output_past": true,
"pad_token_id": 0,
"problem_type": "single_label_classification",
"qa_dropout": 0.1,
"seq_classif_dropout": 0.2,
"sinusoidal_pos_embds": false,
"tie_weights_": true,
"tie_word_embeddings": true,
"transformers_version": "5.15.1",
"use_cache": false,
"vocab_size": 119547
}

View file

@ -0,0 +1,17 @@
{
"source": "wagesj45/toxic-comment-classifier",
"revision": "a7d2df2ead42f0bce00b330939574a02266772f5",
"architecture": "DistilBertForSequenceClassification",
"labels": {
"toxic": 1,
"nonToxic": 0,
"names": {
"not_toxic": 0,
"toxic": 1
}
},
"maxLength": 512,
"quantization": "int8-dynamic",
"runtime": "onnxruntime-web-wasm",
"license": "Apache-2.0"
}

BIN
public/models/toxicity/onnx/model_quantized.onnx (Stored with Git LFS) Normal file

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
{
"backend": "tokenizers",
"cls_token": "[CLS]",
"do_lower_case": false,
"is_local": false,
"local_files_only": false,
"mask_token": "[MASK]",
"model_max_length": 512,
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"unk_token": "[UNK]"
}

2
public/offscreen.html Normal file
View file

@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>VibeGuard inference</title></head><body><script type="module" src="/offscreen.js"></script></body></html>

7
public/options.html Normal file
View file

@ -0,0 +1,7 @@
<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>VibeGuard settings</title></head><body><main><h1>VibeGuard settings</h1><form id="settings">
<label for="threshold">Toxicity threshold: <output id="threshold-value">80%</output></label><input id="threshold" type="range" min="0" max="1" step=".01">
<label for="filter-mode">Filtering mode</label><select id="filter-mode"><option value="collapse">Collapse with Show button</option><option value="hide">Hide completely</option></select>
<label><input id="show-score" type="checkbox"> Show toxicity score</label>
<fieldset><legend>Supported sites</legend><label><input id="site-reddit" type="checkbox"> Reddit</label><label><input id="site-twitter" type="checkbox"> X/Twitter</label><label><input id="site-facebook" type="checkbox"> Facebook</label></fieldset>
<button type="submit">Save settings</button> <span id="status" role="status"></span></form></main><script type="module" src="/options.js"></script></body></html>

View file

@ -0,0 +1,18 @@
import { createRuntime } from "./runtime";
import type { InferenceRequest, InferenceResult } from "../shared/types";
const OFFSCREEN_URL = "offscreen.html";
async function ensureOffscreen(): Promise<void> {
const contexts = await chrome.runtime.getContexts?.({ contextTypes: ["OFFSCREEN_DOCUMENT"] as never });
if (!contexts || contexts.length === 0) await chrome.offscreen.createDocument({ url: OFFSCREEN_URL, reasons: ["WORKERS"] as never, justification: "Run the local toxicity classifier in a durable worker." });
}
async function runInOffscreen(requests: InferenceRequest[]): Promise<InferenceResult[]> {
await ensureOffscreen();
const response = await chrome.runtime.sendMessage({ type: "OFFSCREEN_INFER", requests });
if (response?.error) throw new Error(response.error);
return response?.results ?? [];
}
createRuntime(runInOffscreen);

14
src/background/firefox.ts Normal file
View file

@ -0,0 +1,14 @@
import { createRuntime } from "./runtime";
import type { InferenceResult } from "../shared/types";
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
createRuntime((requests) => new Promise((resolve, reject) => {
const listener = (event: MessageEvent<{ results?: InferenceResult[]; error?: string }>) => {
worker.removeEventListener("message", listener);
if (event.data.error) reject(new Error(event.data.error));
else resolve(event.data.results ?? []);
};
worker.addEventListener("message", listener);
worker.postMessage({ requests });
}));

View file

@ -0,0 +1,15 @@
import type { RuntimeMessage } from "../shared/types";
const worker = new Worker(chrome.runtime.getURL("inference.js"), { type: "module" });
chrome.runtime.onMessage.addListener((message: RuntimeMessage, _sender, sendResponse) => {
if (message.type !== "OFFSCREEN_INFER") return false;
const listener = (event: MessageEvent<{ results?: unknown; error?: string }>) => {
worker.removeEventListener("message", listener);
if (event.data.error) sendResponse({ error: event.data.error });
else sendResponse({ results: event.data.results });
};
worker.addEventListener("message", listener);
worker.postMessage({ requests: message.requests });
return true;
});

25
src/background/runtime.ts Normal file
View file

@ -0,0 +1,25 @@
import { InferenceQueue } from "../inference/queue";
import { loadSettings, saveSettings } from "../shared/settings";
import type { InferenceRequest, InferenceResult, RuntimeMessage } from "../shared/types";
export function createRuntime(runBatch: (requests: InferenceRequest[]) => Promise<InferenceResult[]>): void {
let sequence = 0;
const queue = new InferenceQueue(async (requests) => {
const requestId = ++sequence;
return runBatch(requests);
});
chrome.runtime.onMessage.addListener((message: RuntimeMessage, sender, sendResponse) => {
if (message.type === "INFER") {
const request: InferenceRequest = { ...message.request, tabId: message.request.tabId ?? sender.tab?.id };
queue.enqueue(request).then((result) => sendResponse({ type: "INFERENCE_RESULT", result })).catch((error) => sendResponse({ error: String(error) }));
return true;
}
if (message.type === "GET_SETTINGS") { loadSettings().then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
if (message.type === "SET_SETTINGS") { saveSettings(message.settings).then((settings) => sendResponse({ type: "SETTINGS", settings })); return true; }
if (message.type === "PING") { sendResponse({ type: "PONG", pending: queue.pendingCount }); }
return false;
});
chrome.tabs?.onRemoved?.addListener((tabId) => queue.invalidate(tabId));
}

41
src/content/filter.ts Normal file
View file

@ -0,0 +1,41 @@
import type { InferenceResult, Settings } from "../shared/types";
const HIDDEN = "data-vibeguard-hidden";
const ORIGINAL_DISPLAY = "data-vibeguard-display";
export function applyResult(element: Element, result: InferenceResult, settings: Settings): void {
const shouldFilter = result.label === "toxic" && result.probability >= settings.threshold;
if (!shouldFilter) { restore(element); return; }
element.setAttribute(HIDDEN, "true");
if (settings.filterMode === "hide") {
element.setAttribute(ORIGINAL_DISPLAY, (element as HTMLElement).style.display);
(element as HTMLElement).style.display = "none";
return;
}
const html = element as HTMLElement;
if (!html.dataset.vibeguardOriginalDisplay) html.dataset.vibeguardOriginalDisplay = html.style.display;
html.style.display = "none";
let placeholder = element.nextElementSibling;
if (!placeholder?.matches("[data-vibeguard-placeholder]")) {
placeholder = document.createElement("div");
placeholder.setAttribute("data-vibeguard-placeholder", "true");
element.insertAdjacentElement("afterend", placeholder);
}
placeholder.className = "vibeguard-placeholder";
placeholder.textContent = `Content hidden as toxic${settings.showScore ? ` (${Math.round(result.probability * 100)}%)` : ""}`;
const button = document.createElement("button");
button.type = "button";
button.textContent = "Show";
button.addEventListener("click", () => { restore(element); placeholder?.remove(); });
placeholder.append(" ", button);
}
export function restore(element: Element): void {
const html = element as HTMLElement;
html.style.display = html.dataset.vibeguardOriginalDisplay ?? "";
delete html.dataset.vibeguardOriginalDisplay;
element.removeAttribute(HIDDEN);
element.nextElementSibling?.matches("[data-vibeguard-placeholder]") && element.nextElementSibling.remove();
}

48
src/content/main.ts Normal file
View file

@ -0,0 +1,48 @@
import { createParser, siteForLocation } from "./parsers";
import { applyResult } from "./filter";
import { loadSettings } from "../shared/settings";
import { hashText } from "../shared/hash";
import { priorityFor } from "../inference/queue";
import type { InferenceRequest, RuntimeMessage, Settings } from "../shared/types";
const site = siteForLocation();
if (site) void start(site);
async function start(activeSite: NonNullable<typeof site>): Promise<void> {
let settings = await requestSettings();
if (!settings.enabledSites[activeSite]) return;
const parser = createParser(activeSite);
const navigationId = crypto.randomUUID();
const seen = new WeakSet<Element>();
const process = (posts: ReturnType<typeof parser.discover>): void => {
posts.forEach((post) => {
if (seen.has(post.element)) return;
seen.add(post.element);
const request: InferenceRequest = {
id: post.id, text: post.text, site: activeSite, navigationId,
priority: priorityFor(isVisible(post.element), document.visibilityState === "visible"),
requestId: crypto.randomUUID()
};
void chrome.runtime.sendMessage<RuntimeMessage, { type: "INFERENCE_RESULT"; result: Parameters<typeof applyResult>[1] }>( { type: "INFER", request })
.then((response) => { if (response?.result) applyResult(post.element, response.result, settings); });
});
};
process(parser.discover(document));
const stop = parser.observe(process);
chrome.storage.onChanged.addListener(async (changes, area) => {
if (area === "local" && changes["vibeguard.settings"]?.newValue) settings = await loadSettings();
});
window.addEventListener("pagehide", () => { stop(); parser.dispose(); });
}
async function requestSettings(): Promise<Settings> {
const response = await chrome.runtime.sendMessage({ type: "GET_SETTINGS" });
return response?.settings ?? loadSettings();
}
function isVisible(element: Element): boolean {
const rect = element.getBoundingClientRect();
return rect.bottom >= 0 && rect.top <= window.innerHeight;
}

View file

@ -0,0 +1,40 @@
import type { IPostParser, NormalizedPost, SupportedSite } from "../../shared/types";
export abstract class SelectorParser implements IPostParser {
abstract readonly site: SupportedSite;
protected abstract readonly selectors: string[];
discover(root: Document | Element): NormalizedPost[] {
const nodes = root.querySelectorAll(this.selectors.join(","));
return Array.from(nodes).flatMap((element, index) => {
const text = this.extractText(element);
if (!text) return [];
const id = this.getId(element, index);
element.setAttribute("data-vibeguard-id", id);
return [{ id, text, element, site: this.site }];
});
}
observe(onPosts: (posts: NormalizedPost[]) => void): () => void {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) onPosts(this.discover(node as Element));
});
}
});
observer.observe(document.body, { childList: true, subtree: true });
return () => observer.disconnect();
}
dispose(): void {}
protected extractText(element: Element): string {
return (element.getAttribute("data-vibeguard-text") ?? element.textContent ?? "").replace(/\s+/g, " ").trim();
}
private getId(element: Element, index: number): string {
const nativeId = element.getAttribute("data-testid") ?? element.getAttribute("data-id") ?? element.id;
return `${this.site}:${nativeId || `${index}-${this.extractText(element).slice(0, 32)}`}`;
}
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class FacebookParser extends SelectorParser {
readonly site = "facebook" as const;
protected readonly selectors = ["div[role='article']"];
}

View file

@ -0,0 +1,17 @@
import type { IPostParser, SupportedSite } from "../../shared/types";
import { FacebookParser } from "./facebook";
import { RedditParser } from "./reddit";
import { TwitterParser } from "./twitter";
export function siteForLocation(hostname = location.hostname): SupportedSite | undefined {
if (hostname === "reddit.com" || hostname.endsWith(".reddit.com")) return "reddit";
if (hostname === "x.com" || hostname.endsWith(".x.com") || hostname === "twitter.com" || hostname.endsWith(".twitter.com")) return "twitter";
if (hostname === "facebook.com" || hostname.endsWith(".facebook.com")) return "facebook";
return undefined;
}
export function createParser(site: SupportedSite): IPostParser {
if (site === "reddit") return new RedditParser();
if (site === "twitter") return new TwitterParser();
return new FacebookParser();
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class RedditParser extends SelectorParser {
readonly site = "reddit" as const;
protected readonly selectors = ["shreddit-post", "shreddit-comment", "article[data-testid='post-container']", "div[data-testid='comment']"];
}

View file

@ -0,0 +1,6 @@
import { SelectorParser } from "./base";
export class TwitterParser extends SelectorParser {
readonly site = "twitter" as const;
protected readonly selectors = ["article[data-testid='tweet']"];
}

22
src/inference/cache.ts Normal file
View file

@ -0,0 +1,22 @@
import { hashText, normalizeText } from "../shared/hash";
import type { InferenceResult } from "../shared/types";
export class ResultCache {
private readonly values = new Map<string, InferenceResult>();
get(text: string): InferenceResult | undefined {
return this.values.get(hashText(text));
}
set(text: string, result: InferenceResult): void {
this.values.set(hashText(normalizeText(text)), result);
}
clear(): void {
this.values.clear();
}
get size(): number {
return this.values.size;
}
}

View file

@ -0,0 +1,46 @@
import { pipeline, type TextClassificationPipeline } from "@huggingface/transformers";
import { hashText } from "../shared/hash";
import type { InferenceRequest, InferenceResult } from "../shared/types";
import { loadModelManifest, modelBaseUrl, type ModelManifest } from "./model-metadata";
let classifier: TextClassificationPipeline | undefined;
let classifierPromise: Promise<TextClassificationPipeline> | undefined;
let manifest: ModelManifest | undefined;
export async function classify(requests: InferenceRequest[]): Promise<InferenceResult[]> {
const modelManifest = manifest ??= await loadModelManifest();
classifier ??= await loadClassifier();
const invoke = classifier as unknown as (texts: string[], options: Record<string, unknown>) => Promise<Array<Array<{ label: string; score: number }> | { label: string; score: number }>>;
const outputs = await invoke(requests.map((request) => request.text), { top_k: undefined, max_length: modelManifest.maxLength });
return requests.map((request, index) => {
const output = Array.isArray(outputs[index]) ? outputs[index] : [outputs[index]];
const toxic = output.find((item) => resolveOutputIndex(item?.label, modelManifest) === modelManifest.labels.toxic);
if (!toxic) throw new Error(`Classifier output did not contain toxic label index ${modelManifest.labels.toxic}`);
const probability = Math.max(0, Math.min(1, Number(toxic?.score ?? 0)));
return {
requestId: request.requestId,
id: request.id,
textHash: hashText(request.text),
label: probability >= 0.5 ? "toxic" : "not_toxic",
probability,
navigationId: request.navigationId,
modelRevision: modelManifest.revision
};
});
}
async function loadClassifier(): Promise<TextClassificationPipeline> {
classifierPromise ??= (pipeline as unknown as (task: string, model: string, options: { device: string }) => Promise<TextClassificationPipeline>)("text-classification", modelBaseUrl(), { device: "wasm" });
return classifierPromise;
}
export function resolveOutputIndex(label: string | undefined, metadata: ModelManifest): number | undefined {
if (!label) return undefined;
const normalized = label.toLowerCase().replace(/[\s-]+/g, "_");
if (normalized === "toxic") return metadata.labels.toxic;
if (normalized === "not_toxic" || normalized === "non_toxic" || normalized === "non-toxic") return metadata.labels.nonToxic;
const configured = metadata.labels.names[normalized] ?? metadata.labels.names[label];
if (configured !== undefined) return configured;
const match = normalized.match(/^label_(\d+)$/);
return match ? Number(match[1]) : undefined;
}

View file

@ -0,0 +1,36 @@
export interface ModelManifest {
source: string;
revision: string;
architecture: string;
labels: {
toxic: number;
nonToxic: number;
names: Record<string, number>;
};
maxLength: number;
quantization: string;
runtime: string;
}
let manifestPromise: Promise<ModelManifest> | undefined;
export function modelBaseUrl(): string {
return chrome.runtime.getURL("models/toxicity/");
}
export function loadModelManifest(): Promise<ModelManifest> {
manifestPromise ??= fetch(`${modelBaseUrl()}model-manifest.json`)
.then((response) => {
if (!response.ok) throw new Error(`VibeGuard model metadata unavailable (${response.status})`);
return response.json() as Promise<ModelManifest>;
})
.then(validateManifest);
return manifestPromise;
}
export function validateManifest(value: ModelManifest): ModelManifest {
if (!value || typeof value.source !== "string" || typeof value.revision !== "string") throw new Error("Invalid VibeGuard model manifest");
if (!Number.isInteger(value.labels?.toxic) || !Number.isInteger(value.labels?.nonToxic) || value.labels.toxic === value.labels.nonToxic || !value.labels?.names) throw new Error("Model manifest has no binary label mapping");
if (!Number.isInteger(value.maxLength) || value.maxLength < 8) throw new Error("Model manifest has an invalid maximum length");
return value;
}

100
src/inference/queue.ts Normal file
View file

@ -0,0 +1,100 @@
import type { InferenceRequest, InferenceResult, QueuePriority } from "../shared/types";
import { hashText } from "../shared/hash";
import { ResultCache } from "./cache";
type Runner = (requests: InferenceRequest[]) => Promise<InferenceResult[]>;
interface Pending {
request: InferenceRequest;
resolve: (result: InferenceResult) => void;
reject: (error: unknown) => void;
}
export class InferenceQueue {
private readonly pending: Pending[] = [];
private scheduled = false;
private running = false;
constructor(
private readonly run: Runner,
private readonly cache = new ResultCache(),
private readonly maxSize = 500,
private readonly batchSize = 8,
private readonly debounceMs = 20
) {}
enqueue(request: InferenceRequest): Promise<InferenceResult> {
const cached = this.cache.get(request.text);
if (cached) {
return Promise.resolve({ ...cached, requestId: request.requestId, id: request.id, navigationId: request.navigationId });
}
if (this.pending.length >= this.maxSize) this.dropLowestPriority();
return new Promise<InferenceResult>((resolve, reject) => {
this.pending.push({ request, resolve, reject });
this.pending.sort((a, b) => a.request.priority - b.request.priority);
this.schedule();
});
}
invalidate(tabId?: number, navigationId?: string): void {
for (let index = this.pending.length - 1; index >= 0; index -= 1) {
const request = this.pending[index]?.request;
if (!request) continue;
if ((tabId === undefined || request.tabId === tabId) && (navigationId === undefined || request.navigationId === navigationId)) {
this.pending[index]?.reject(new Error("Inference request invalidated"));
this.pending.splice(index, 1);
}
}
}
get pendingCount(): number { return this.pending.length; }
get cacheSize(): number { return this.cache.size; }
private schedule(): void {
if (this.scheduled || this.running) return;
this.scheduled = true;
setTimeout(() => { this.scheduled = false; void this.flush(); }, this.debounceMs);
}
private async flush(): Promise<void> {
if (this.running || this.pending.length === 0) return;
this.running = true;
const batch = this.pending.splice(0, this.batchSize);
try {
const results = await this.run(batch.map((item) => item.request));
const byRequest = new Map(results.map((result) => [result.requestId, result]));
for (const item of batch) {
const result = byRequest.get(item.request.requestId);
if (!result) item.reject(new Error("Classifier returned no result"));
else { this.cache.set(item.request.text, result); item.resolve(result); }
}
} catch (error) {
batch.forEach((item) => item.reject(error));
} finally {
this.running = false;
if (this.pending.length > 0) this.schedule();
}
}
private dropLowestPriority(): void {
let worstIndex = 0;
for (let index = 1; index < this.pending.length; index += 1) {
const current = this.pending[index]?.request.priority ?? 0;
const worst = this.pending[worstIndex]?.request.priority ?? 0;
if (current > worst) worstIndex = index;
}
this.pending[worstIndex]?.reject(new Error("Inference queue is full"));
this.pending.splice(worstIndex, 1);
}
}
export function requestKey(request: InferenceRequest): string {
return `${request.tabId ?? "global"}:${request.navigationId}:${request.id}:${hashText(request.text)}`;
}
export function priorityFor(visible: boolean, activeTab: boolean): QueuePriority {
if (visible && activeTab) return 0;
if (activeTab) return 1;
return visible ? 2 : 3;
}

11
src/inference/worker.ts Normal file
View file

@ -0,0 +1,11 @@
import { classify } from "./classifier";
import type { InferenceRequest } from "../shared/types";
self.onmessage = async (event: MessageEvent<{ requests: InferenceRequest[] }>) => {
try {
const results = await classify(event.data.requests);
self.postMessage({ results });
} catch (error) {
self.postMessage({ error: error instanceof Error ? error.message : String(error) });
}
};

37
src/options/main.ts Normal file
View file

@ -0,0 +1,37 @@
import { loadSettings, saveSettings } from "../shared/settings";
import type { FilterMode, Settings } from "../shared/types";
import "./style.css";
const form = document.querySelector<HTMLFormElement>("#settings");
const threshold = document.querySelector<HTMLInputElement>("#threshold");
const thresholdValue = document.querySelector<HTMLElement>("#threshold-value");
const mode = document.querySelector<HTMLSelectElement>("#filter-mode");
const showScore = document.querySelector<HTMLInputElement>("#show-score");
const status = document.querySelector<HTMLElement>("#status");
void loadSettings().then((settings) => {
threshold!.value = String(settings.threshold);
mode!.value = settings.filterMode;
showScore!.checked = settings.showScore;
for (const site of ["reddit", "twitter", "facebook"] as const) document.querySelector<HTMLInputElement>(`#site-${site}`)!.checked = settings.enabledSites[site];
updateThresholdLabel();
});
threshold?.addEventListener("input", updateThresholdLabel);
form?.addEventListener("submit", async (event) => {
event.preventDefault();
const settings: Partial<Settings> = {
threshold: Number(threshold?.value), filterMode: mode?.value as FilterMode, showScore: showScore?.checked,
enabledSites: {
reddit: document.querySelector<HTMLInputElement>("#site-reddit")!.checked,
twitter: document.querySelector<HTMLInputElement>("#site-twitter")!.checked,
facebook: document.querySelector<HTMLInputElement>("#site-facebook")!.checked
}
};
await saveSettings(settings);
if (status) { status.textContent = "Saved"; setTimeout(() => { status.textContent = ""; }, 1500); }
});
function updateThresholdLabel(): void {
if (thresholdValue && threshold) thresholdValue.textContent = `${Math.round(Number(threshold.value) * 100)}%`;
}

8
src/options/style.css Normal file
View file

@ -0,0 +1,8 @@
:root { font: 16px system-ui, sans-serif; color: #18212f; background: #f7f8fb; }
body { max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }
main { background: white; border-radius: .75rem; padding: 1.5rem; box-shadow: 0 2px 14px #18212f18; }
label, fieldset { display: block; margin: 1rem 0; }
fieldset { border: 0; padding: 0; }
button { padding: .5rem 1rem; border: 0; border-radius: .4rem; background: #4355db; color: white; cursor: pointer; }
.vibeguard-placeholder { padding: .75rem; margin: .25rem 0; border: 1px solid #d5d9e8; background: #f1f3f9; color: #5c6475; }
.vibeguard-placeholder button { padding: .25rem .5rem; margin-left: .5rem; }

13
src/shared/hash.ts Normal file
View file

@ -0,0 +1,13 @@
export function normalizeText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLocaleLowerCase();
}
export function hashText(text: string): string {
const normalized = normalizeText(text);
let hash = 2166136261;
for (let index = 0; index < normalized.length; index += 1) {
hash ^= normalized.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}

47
src/shared/settings.ts Normal file
View file

@ -0,0 +1,47 @@
import { DEFAULT_SETTINGS, type Settings } from "./types";
const KEY = "vibeguard.settings";
export async function loadSettings(): Promise<Settings> {
const stored = await getStorageItem(KEY);
return mergeSettings(stored[KEY] as Partial<Settings> | undefined);
}
export async function saveSettings(update: Partial<Settings>): Promise<Settings> {
const settings = mergeSettings({ ...(await loadSettings()), ...update });
await setStorageItem({ [KEY]: settings });
return settings;
}
function getStorageItem(key: string): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
chrome.storage.local.get(key, (items) => {
const error = chrome.runtime.lastError;
if (error) reject(new Error(error.message));
else resolve(items);
});
});
}
function setStorageItem(items: Record<string, unknown>): Promise<void> {
return new Promise((resolve, reject) => {
chrome.storage.local.set(items, () => {
const error = chrome.runtime.lastError;
if (error) reject(new Error(error.message));
else resolve();
});
});
}
export function mergeSettings(input?: Partial<Settings>): Settings {
return {
...DEFAULT_SETTINGS,
...input,
threshold: clamp(Number(input?.threshold ?? DEFAULT_SETTINGS.threshold), 0, 1),
enabledSites: { ...DEFAULT_SETTINGS.enabledSites, ...input?.enabledSites }
};
}
function clamp(value: number, min: number, max: number): number {
return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : min;
}

64
src/shared/types.ts Normal file
View file

@ -0,0 +1,64 @@
export type SupportedSite = "reddit" | "twitter" | "facebook";
export type QueuePriority = 0 | 1 | 2 | 3;
export type FilterMode = "collapse" | "hide";
export interface NormalizedPost {
id: string;
text: string;
element: Element;
site: SupportedSite;
tabId?: number;
}
export interface PostDescriptor {
id: string;
text: string;
site: SupportedSite;
tabId?: number;
navigationId: string;
}
export interface InferenceRequest extends PostDescriptor {
priority: QueuePriority;
requestId: string;
}
export interface InferenceResult {
requestId: string;
id: string;
textHash: string;
label: "toxic" | "not_toxic";
probability: number;
navigationId: string;
modelRevision?: string;
}
export interface Settings {
threshold: number;
filterMode: FilterMode;
showScore: boolean;
enabledSites: Record<SupportedSite, boolean>;
}
export const DEFAULT_SETTINGS: Settings = {
threshold: 0.8,
filterMode: "collapse",
showScore: true,
enabledSites: { reddit: true, twitter: true, facebook: true }
};
export interface IPostParser {
readonly site: SupportedSite;
discover(root: Document | Element): NormalizedPost[];
observe(onPosts: (posts: NormalizedPost[]) => void): () => void;
dispose(): void;
}
export type RuntimeMessage =
| { type: "INFER"; request: InferenceRequest }
| { type: "INFERENCE_RESULT"; result: InferenceResult }
| { type: "GET_SETTINGS" }
| { type: "SET_SETTINGS"; settings: Partial<Settings> }
| { type: "SETTINGS"; settings: Settings }
| { type: "OFFSCREEN_INFER"; requests: InferenceRequest[] }
| { type: "PING" };

16
tests/filter.test.ts Normal file
View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { applyResult, restore } from "../src/content/filter";
import { DEFAULT_SETTINGS } from "../src/shared/types";
describe("content filtering", () => {
it("collapses toxic content and restores it", () => {
const element = document.createElement("article");
document.body.append(element);
applyResult(element, { requestId: "1", id: "1", textHash: "x", label: "toxic", probability: .91, navigationId: "n" }, DEFAULT_SETTINGS);
expect(element.style.display).toBe("none");
expect(document.querySelector("[data-vibeguard-placeholder]")).not.toBeNull();
restore(element);
expect(element.style.display).toBe("");
expect(document.querySelector("[data-vibeguard-placeholder]")).toBeNull();
});
});

9
tests/hash.test.ts Normal file
View file

@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { hashText, normalizeText } from "../src/shared/hash";
describe("text hashing", () => {
it("normalizes whitespace and casing", () => {
expect(normalizeText(" Hello\n WORLD ")).toBe("hello world");
expect(hashText("hello world")).toBe(hashText("HELLO\nWORLD"));
});
});

View file

@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { validateManifest } from "../src/inference/model-metadata";
import { resolveOutputIndex } from "../src/inference/classifier";
const manifest = {
source: "wagesj45/toxic-comment-classifier",
revision: "abc123",
architecture: "DistilBertForSequenceClassification",
labels: { toxic: 1, nonToxic: 0, names: { toxic: 1, non_toxic: 0 } },
maxLength: 512,
quantization: "int8-dynamic",
runtime: "onnxruntime-web-wasm"
};
describe("model contract", () => {
it("accepts a binary manifest and resolves common output labels", () => {
expect(validateManifest(manifest)).toEqual(manifest);
expect(resolveOutputIndex("LABEL_1", manifest)).toBe(1);
expect(resolveOutputIndex("not-toxic", manifest)).toBe(0);
expect(resolveOutputIndex("toxic", manifest)).toBe(1);
});
it("rejects incomplete metadata", () => {
expect(() => validateManifest({ ...manifest, labels: { toxic: 1, nonToxic: 1, names: {} } })).toThrow();
expect(() => validateManifest({ ...manifest, maxLength: 0 })).toThrow();
expect(() => validateManifest({ ...manifest, labels: undefined as unknown as typeof manifest.labels })).toThrow();
});
});

23
tests/queue.test.ts Normal file
View file

@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { InferenceQueue } from "../src/inference/queue";
import type { InferenceRequest } from "../src/shared/types";
const request = (id: string, priority: 0 | 1 | 2 | 3): InferenceRequest => ({
id, text: `text ${id}`, site: "reddit", priority, requestId: id, navigationId: "nav"
});
describe("InferenceQueue", () => {
it("batches requests and caches equivalent text", async () => {
let calls = 0;
const queue = new InferenceQueue(async (requests) => {
calls += 1;
return requests.map((item) => ({ requestId: item.requestId, id: item.id, textHash: "hash", label: "toxic" as const, probability: .9, navigationId: item.navigationId }));
}, undefined, 10, 8, 0);
const first = await queue.enqueue(request("a", 0));
const second = await queue.enqueue({ ...request("b", 0), text: "text a" });
expect(first.probability).toBe(.9);
expect(second.requestId).toBe("b");
expect(calls).toBe(1);
expect(queue.cacheSize).toBe(1);
});
});

172
tools/convert_model.py Normal file
View file

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Prepare a pinned Hugging Face binary classifier for VibeGuard."""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
DEFAULT_MODEL = "wagesj45/toxic-comment-classifier"
DEFAULT_OUTPUT = Path("public/models/toxicity")
def make_parser() -> argparse.ArgumentParser:
command = argparse.ArgumentParser(description=__doc__)
command.add_argument("action", choices=("inspect", "prepare", "validate"))
command.add_argument("--model", default=DEFAULT_MODEL)
command.add_argument("--revision", help="Immutable HF commit; required by prepare")
command.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
command.add_argument("--toxic-index", type=int)
command.add_argument("--non-toxic-index", type=int)
command.add_argument("--max-length", type=int)
return command
def load_source(model: str, revision: str | None) -> tuple[Path, dict[str, Any]]:
try:
from huggingface_hub import model_info, snapshot_download
except ImportError as error:
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
kwargs: dict[str, Any] = {"repo_id": model}
if revision: kwargs["revision"] = revision
source = Path(snapshot_download(**kwargs))
info = None
try:
info = model_info(model, revision=revision)
except Exception as error: # A pinned, complete local snapshot is still usable offline.
print(f"Warning: Hugging Face metadata unavailable; using local snapshot metadata: {error}", file=sys.stderr)
card = getattr(info, "cardData", None) or {}
license_id = card.get("license")
if not license_id and (source / "LICENSE").exists():
license_text = (source / "LICENSE").read_text(errors="replace").lower()
if "apache license" in license_text and "version 2.0" in license_text:
license_id = "apache-2.0"
return source, {"source": model, "revision": revision or getattr(info, "sha", "unknown"), "license": license_id}
def inspect_source(source: Path) -> dict[str, Any]:
config = json.loads((source / "config.json").read_text())
labels = {str(key): str(value) for key, value in (config.get("id2label") or {}).items()}
return {
"architecture": (config.get("architectures") or [config.get("model_type", "unknown")])[0],
"modelType": config.get("model_type"),
"numLabels": config.get("num_labels") or len(labels),
"id2label": labels,
"maxLength": config.get("max_position_embeddings"),
"files": sorted(path.name for path in source.iterdir()),
}
def resolve_labels(details: dict[str, Any], toxic_override: int | None, non_toxic_override: int | None) -> tuple[int, int, dict[str, int]]:
names = {str(index): value.lower().replace("-", "_").replace(" ", "_") for index, value in details["id2label"].items()}
indexed = {name: int(index) for index, name in names.items()}
toxic = toxic_override if toxic_override is not None else next((index for name, index in indexed.items() if name in ("toxic", "toxicity")), None)
non_toxic = non_toxic_override if non_toxic_override is not None else next((index for name, index in indexed.items() if name in ("non_toxic", "nontoxic", "clean", "not_toxic")), None)
if toxic is None or non_toxic is None or toxic == non_toxic:
raise ValueError("Could not resolve distinct labels; pass --toxic-index and --non-toxic-index")
return toxic, non_toxic, indexed
def prepare(args: argparse.Namespace) -> None:
if not args.revision: raise SystemExit("prepare requires --revision for reproducible release artifacts")
source, source_metadata = load_source(args.model, args.revision)
license_id = str(source_metadata.get("license") or "").lower()
if license_id != "apache-2.0": raise SystemExit(f"Expected Apache-2.0 model metadata, found license={source_metadata.get('license')!r}")
details = inspect_source(source)
if details["numLabels"] != 2: raise SystemExit(f"Expected a binary classifier, found {details['numLabels']}")
toxic, non_toxic, names = resolve_labels(details, args.toxic_index, args.non_toxic_index)
max_length = args.max_length or details["maxLength"] or 512
with tempfile.TemporaryDirectory(prefix="vibeguard-model-") as temporary:
export_dir = Path(temporary) / "onnx"
float_model = export_dir / "model.onnx"
export_onnx(source, float_model, max_length)
quantized = export_dir / "model_quantized.onnx"
quantize(float_model, quantized)
args.output.mkdir(parents=True, exist_ok=True)
for name in ("tokenizer.json", "tokenizer_config.json", "special_tokens_map.json", "vocab.txt", "merges.txt"):
if (source / name).exists(): shutil.copy2(source / name, args.output / name)
shutil.copy2(source / "config.json", args.output / "config.json")
(args.output / "onnx").mkdir(exist_ok=True)
shutil.copy2(quantized, args.output / "onnx" / "model_quantized.onnx")
manifest = {"source": args.model, "revision": source_metadata["revision"], "architecture": details["architecture"], "labels": {"toxic": toxic, "nonToxic": non_toxic, "names": names}, "maxLength": max_length, "quantization": "int8-dynamic", "runtime": "onnxruntime-web-wasm", "license": "Apache-2.0"}
(args.output / "model-manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
print(json.dumps(manifest, indent=2))
def quantize(source: Path, destination: Path) -> None:
try:
from onnxruntime.quantization import QuantType, quantize_dynamic
except ImportError as error:
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
quantize_dynamic(str(source), str(destination), weight_type=QuantType.QInt8, per_channel=True, reduce_range=True)
def export_onnx(source: Path, destination: Path, max_length: int) -> None:
try:
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
except ImportError as error:
raise SystemExit("Install model tooling with: python3 -m pip install -r tools/requirements-model.txt") from error
tokenizer = AutoTokenizer.from_pretrained(str(source), local_files_only=True)
model = AutoModelForSequenceClassification.from_pretrained(str(source), local_files_only=True)
model.eval()
encoded = tokenizer("VibeGuard export calibration text.", return_tensors="pt", truncation=True, max_length=min(max_length, 32), padding="max_length")
class ClassifierWrapper(torch.nn.Module):
def __init__(self, wrapped: torch.nn.Module) -> None:
super().__init__()
self.wrapped = wrapped
def forward(self, input_ids: Any, attention_mask: Any) -> Any:
return self.wrapped(input_ids=input_ids, attention_mask=attention_mask).logits
destination.parent.mkdir(parents=True, exist_ok=True)
with torch.no_grad():
torch.onnx.export(
ClassifierWrapper(model),
(encoded["input_ids"], encoded["attention_mask"]),
str(destination),
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={"input_ids": {0: "batch", 1: "sequence"}, "attention_mask": {0: "batch", 1: "sequence"}, "logits": {0: "batch"}},
opset_version=17,
do_constant_folding=True,
dynamo=False,
)
def validate(args: argparse.Namespace) -> None:
manifest_path = args.output / "model-manifest.json"
model_path = args.output / "onnx" / "model_quantized.onnx"
if not manifest_path.is_file() or not model_path.is_file(): raise SystemExit(f"Missing generated model files under {args.output}")
manifest = json.loads(manifest_path.read_text())
if manifest.get("quantization") != "int8-dynamic" or str(manifest.get("license")).lower() != "apache-2.0": raise SystemExit("Unexpected model quantization or license")
try:
import onnx
onnx.checker.check_model(str(model_path))
except ImportError as error:
raise SystemExit("Install tools with: python3 -m pip install -r tools/requirements-model.txt") from error
print(f"Validated {model_path} ({model_path.stat().st_size / 1024 / 1024:.1f} MiB)")
def run(command: list[str]) -> None:
print("+", " ".join(command))
subprocess.run(command, check=True)
def main() -> None:
args = make_parser().parse_args()
if args.action == "inspect":
source, metadata = load_source(args.model, args.revision)
print(json.dumps({**metadata, **inspect_source(source)}, indent=2))
elif args.action == "prepare": prepare(args)
else: validate(args)
if __name__ == "__main__": main()

View file

@ -0,0 +1,5 @@
huggingface_hub>=0.27,<1
transformers>=4.48,<5
torch>=2.1,<3
onnx>=1.17,<2
onnxruntime>=1.20,<2

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "WebWorker"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["chrome"]
},
"include": ["src", "tests", "vite.config.ts"]
}

54
vite.config.ts Normal file
View file

@ -0,0 +1,54 @@
import { defineConfig, type Plugin } from "vite";
import { readFileSync } from "node:fs";
export default defineConfig(({ mode }) => ({
plugins: [manifestPlugin(mode)],
build: {
outDir: `dist/${mode}`,
// Background scripts and content scripts are classic scripts in Manifest V2.
// They are rebuilt individually below as self-contained IIFEs, after the
// regular ESM build has produced the worker and HTML-page bundles.
emptyOutDir: !process.env.VIBEGUARD_CLASSIC_ENTRY,
rollupOptions: {
input: inputsFor(mode),
output: {
entryFileNames: "[name].js",
chunkFileNames: "chunks/[name]-[hash].js",
assetFileNames: "assets/[name]-[hash][extname]",
format: process.env.VIBEGUARD_CLASSIC_ENTRY ? "iife" : "es",
inlineDynamicImports: Boolean(process.env.VIBEGUARD_CLASSIC_ENTRY)
}
}
},
test: {
environment: "jsdom",
globals: true,
include: ["tests/**/*.test.ts"]
}
}));
function inputsFor(mode: string): Record<string, string> {
const classicEntry = process.env.VIBEGUARD_CLASSIC_ENTRY;
if (classicEntry === "content") return { content: "src/content/main.ts" };
if (classicEntry === "background") {
return { background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts" };
}
return {
content: "src/content/main.ts",
background: mode === "firefox" ? "src/background/firefox.ts" : "src/background/chromium.ts",
inference: "src/inference/worker.ts",
options: "src/options/main.ts",
offscreen: "src/background/offscreen.ts"
};
}
function manifestPlugin(mode: string): Plugin {
return {
name: "vibeguard-manifest",
generateBundle() {
const filename = mode === "firefox" ? "public/manifest.firefox.json" : "public/manifest.chromium.json";
this.emitFile({ type: "asset", fileName: "manifest.json", source: readFileSync(filename, "utf8") });
}
};
}