Audience: AI agents (including cheaper models) executing Phases 2-4 without the principal-engineer agent in the loop.
Authority: This document is binding. Locked-in decisions here may NOT be relitigated without explicit maintainer approval. If a request seems to conflict, stop and ask.
How to use this doc: Read top-to-bottom once. Find your current phase. Execute step-by-step. Every step has acceptance criteria — don't move on until they're met.
- Read these first, in order:
AGENTS.md— operating manual, locked-in decisionsARCHITECTURE.md— system design source of truthROADMAP.md— phase status- This document — concrete implementation
- Never:
- Add a backend the maintainer operates ($0 ops constraint)
- Add
<all_urls>tohost_permissions(we useactiveTabonly) - Auto-scan as the user scrolls (click-to-scan only)
- Send any data anywhere except the user's chosen BYO provider in Boost mode
- Introduce new
.jsfiles insrc/(TypeScript only) - Delete anything in
archive/
- Always:
- Type every cross-context message via the discriminated union in
src/shared/types.ts - Update
CHANGELOG.mdunder[Unreleased]when you ship something - Run
npm run typecheck && npm run lint && npm run test && npm run buildbefore committing - Commit at "phase or sub-phase" granularity, push to
mainafter each
- Type every cross-context message via the discriminated union in
- Commit message style: Honest. "Phase 2.1: wire offscreen → transformers.js (no model loaded yet)" beats "Implement ML."
- When in doubt: Stop and ask the maintainer. Do not invent product decisions. Do not soften an architectural constraint to make a task easier.
These came out of explicit conversations with the maintainer. Treat as constraints.
| # | Decision | Locked value |
|---|---|---|
| 2.1 | Default local model | Organika/sdxl-detector — Swin-T, ~85 MB, well-supported in transformers.js |
| 2.2 | Quantization | q8 (int8) — ~22 MB after quantization, tiny accuracy loss, huge UX win |
| 2.3 | Inference backend | device: 'auto' — WebGPU when available, WASM fallback |
| 2.4 | Image fetch strategy | Hybrid: canvas first, fall back to fetch(src, {credentials:'include'}) from offscreen |
| 2.5 | Model distribution | HF primary, GitHub Releases fallback via scripts/mirror-model.sh (see §6) |
| 2.6 | First-scan UX | Progress bar in popup with "Loading detector (~22 MB)..." |
| 2.7 | IndexedDB cache key | SHA-256(bytes) + modelId + provider — 7-day TTL |
| 2.8 | Min image size for classification | 96×96 px (visibility filter remains 40×40) |
| 2.9 | Power-user override | modelId field in Options (advanced section) |
- When: After Phase 2 ships and is in real personal use, before Phase 4.
- What: Curate 200-image set with maintainer's help (~30 min). Test 3-4 candidate models. Pick winner.
- Where:
bench/folder. - Candidates to test:
Organika/sdxl-detector(current default)Ateeqq/ai-vs-human-image-detector(DINOv2-based)umm-maybe/AI-image-detector(BEiT-based, larger)- One more newer model TBD at benchmark time
| # | Decision | Locked value |
|---|---|---|
| 3.1 | Default local model | Hello-SimpleAI/chatgpt-detector-roberta (~32 MB at q8) |
| 3.2 | Min selection length | 100 characters. Below → "Selection too short for reliable detection." toast |
| 3.3 | Default confidence threshold | 0.70 for text (vs 0.55 for images) — bias toward "Uncertain" |
| 3.4 | Long-text aggregation | 512-token sliding window, 64-token overlap, mean of per-chunk probabilities |
| 3.5 | UX surfacing | Toast only for v1 (no in-page highlights) |
| 3.6 | Skip behavior | Skip <input> and <textarea> selections |
| # | Decision | Locked value |
|---|---|---|
| 4.1 | LLM image classification | Structured JSON output via Gemini's responseSchema and OpenAI's response_format: json_schema. HF uses real classifiers. |
| 4.2 | Output schema | { label: 'ai' | 'real' | 'uncertain', confidence: number, reasoning: string } |
| 4.3 | Boost-mode failure → fall back to local with visible toast | "Boost failed (rate limited) — using local model" — locked by maintainer |
| 4.4 | Client-side rate limiting | 10 RPM per provider, queue and trickle, settings override |
| 4.5 | API key storage | chrome.storage.sync (encrypted at rest by Chrome). Never logged, never sent anywhere except the chosen provider |
| 4.6 | Cache BYO results | Yes, with provider in cache key |
| 4.7 | Provider precedence | Try BYO first, fall back to local when boost is enabled |
| # | Decision | Locked value |
|---|---|---|
| X.1 | Test strategy | Mock the offscreen layer in unit tests. Real models live in bench/, run manually. CI never downloads ML weights. |
| X.2 | Debug surface | Hidden "Debug" view in popup, shows last 10 results with timing, source, errors. Reveal via 5x logo click. |
| X.3 | Error reporting / Sentry | Defer to Phase 7. Don't build telemetry pipes for users that don't exist. |
| X.4 | Model versioning | modelId in cache key + IndexedDB schema versioning. Old entries expire via TTL. |
| X.5 | Failed sends from background | Wrap chrome.tabs.sendMessage with chrome.runtime.lastError check; log silently. |
| X.6 | Restricted-page detection | Detect chrome://, chrome-extension://, about:, the Web Store, and PDF viewer. Show "This page can't be scanned" toast in the popup. |
Phase 1 left some real bugs. Fix these as the first commit of Phase 2 work before any new features.
Bug: src/content/badges.ts creates one MutationObserver per badge, all watching document.body with subtree:true. On a 50-image page that's 50 observers walking every DOM mutation. Major perf bug.
Fix:
- Move to ONE module-level
MutationObserverinsrc/content/index.ts(or a smallsrc/content/badge-manager.ts). - The shared observer iterates all entries in
activeBadgeson each mutation and callscleanup()for any whoseimgis no longer indocument.body. - Remove the per-badge mutation observer from
createBadge. - Keep per-badge
ResizeObserverandscroll/resizelisteners — they're cheap and per-badge specifically.
Bug: src/content/badges.ts:69 — the MutationObserver callback references cleanup which is declared after it.
Fix: This goes away when you do 2.A (no per-badge mutation observer). If you don't do 2.A first, declare cleanup before the observer that uses it.
Bug: src/content/index.ts:171 abuses BadgeState for text toasts.
Fix: Introduce a discriminated union:
// in src/content/badges.ts
export type ScanEntry =
| { kind: 'image'; img: HTMLImageElement; overlay: HTMLElement; badge: HTMLElement;
timeoutId?: number; cleanup: () => void }
| { kind: 'text'; toast: HTMLElement; timeoutId?: number; cleanup: () => void };Update activeBadges: Map<string, ScanEntry>. RESULT handler switches on entry.kind. Drop BadgeState.
Bug: imageToDataUrl silently returns null on SecurityError.
Fix: Have it return { ok: true; dataUrl } | { ok: false; reason: 'tainted' | 'too-small' | 'unsupported' }. The badge then shows a clearer message, e.g. "Couldn't read image (CORS)". Phase 2.E will then add a fetch-fallback.
Bug: Popup fires ANALYZE_ACTIVE_TAB on chrome:// pages and silently fails.
Fix: Wrap every chrome.tabs.sendMessage and chrome.runtime.sendMessage callsite in:
chrome.tabs.sendMessage(tabId, msg, (resp) => {
if (chrome.runtime.lastError) {
console.warn('[bg] sendMessage failed:', chrome.runtime.lastError.message);
// Optional: surface a one-time toast in popup if applicable.
}
});For the popup specifically, detect restricted URLs (chrome.tabs.query returns the URL) and show "This page can't be scanned" instead of attempting.
Cosmetic: Replace simultaneous iteration+delete with:
for (const entry of activeBadges.values()) entry.cleanup();
activeBadges.clear();npm run typecheck && npm run lint && npm run test && npm run buildall green- Manual: open the extension on a page with 30+ images, verify only ONE
MutationObserveris created (use Chrome DevTools Performance panel) - Manual: open the extension on
chrome://extensions, click the popup → see "This page can't be scanned" toast - Commit message:
Phase 2 prep: fix Phase 1 perf bugs and error surfacing
Replace the mock classifier with real ONNX inference via @huggingface/transformers running in the offscreen document. Default model: Organika/sdxl-detector at q8 quantization. First scan downloads the model (~22 MB), subsequent scans use the IndexedDB cache.
npm install @huggingface/transformersImportant: transformers.js is ~3 MB. CRXJS will bundle it into the offscreen chunk only (lazy-loaded). Don't import it from popup, options, content, or background — only
src/offscreen/.
This module owns all transformers.js state. Single source of truth for the ONNX session, used for both image (Phase 2) and text (Phase 3).
Skeleton:
import { pipeline, env, type ImageClassificationPipeline } from '@huggingface/transformers';
// Force remote-only (no local model resolution at runtime).
env.allowLocalModels = false;
env.allowRemoteModels = true;
env.useBrowserCache = true;
// HF primary, GitHub Releases fallback. See §6 for fallback wiring.
env.remoteHost = 'https://huggingface.co';
const DEFAULT_IMAGE_MODEL = 'Organika/sdxl-detector';
const MODEL_REVISION = 'main'; // TODO: pin to specific SHA before Phase 2 ships
let imageClassifier: ImageClassificationPipeline | null = null;
let imageLoading: Promise<ImageClassificationPipeline> | null = null;
export interface ProgressCallback {
(info: { phase: 'downloading' | 'ready' | 'error'; pct?: number; error?: string }): void;
}
export async function ensureImageClassifier(
modelId: string = DEFAULT_IMAGE_MODEL,
onProgress?: ProgressCallback,
): Promise<ImageClassificationPipeline> {
if (imageClassifier) return imageClassifier;
if (imageLoading) return imageLoading;
imageLoading = (async () => {
onProgress?.({ phase: 'downloading', pct: 0 });
try {
const pipe = await pipeline('image-classification', modelId, {
dtype: 'q8',
device: 'auto',
revision: MODEL_REVISION,
progress_callback: (p: any) => {
if (p.status === 'progress' && typeof p.progress === 'number') {
onProgress?.({ phase: 'downloading', pct: Math.round(p.progress) });
}
},
});
imageClassifier = pipe as ImageClassificationPipeline;
onProgress?.({ phase: 'ready', pct: 100 });
return imageClassifier;
} catch (err) {
onProgress?.({ phase: 'error', error: String(err) });
imageLoading = null;
throw err;
}
})();
return imageLoading;
}
export async function classifyImageDataUrl(
dataUrl: string,
onProgress?: ProgressCallback,
): Promise<{ rawScores: Record<string, number>; modelId: string; durationMs: number }> {
const t0 = performance.now();
const pipe = await ensureImageClassifier(undefined, onProgress);
// transformers.js accepts data URLs and HTML image elements.
const out = await pipe(dataUrl, { top_k: 5 });
const rawScores: Record<string, number> = {};
for (const item of out as Array<{ label: string; score: number }>) {
rawScores[item.label] = item.score;
}
return {
rawScores,
modelId: DEFAULT_IMAGE_MODEL,
durationMs: performance.now() - t0,
};
}Critical pattern:
imageLoadingPromise dedupe. Multiple simultaneous classify calls during the first download must share ONE pipeline-load promise. Don't kick off N parallel downloads.
The model returns label-score pairs whose meaning depends on the model. For Organika/sdxl-detector:
- Label
'real'→ real image - Label
'fake'→ AI-generated
Create src/detectors/image/local.ts (replace the stub):
import { classifyImageDataUrl } from '@/offscreen/ml-engine';
import type { ClassificationResult } from '@/shared/types';
const FAKE_LABEL_ALIASES = new Set(['fake', 'ai', 'ai-generated', 'generated']);
const REAL_LABEL_ALIASES = new Set(['real', 'human', 'photo', 'photograph']);
const UNCERTAIN_BAND = 0.10; // ±0.10 from threshold = uncertain
export async function classifyImageLocal(
dataUrl: string,
threshold: number,
): Promise<ClassificationResult> {
const { rawScores, modelId, durationMs } = await classifyImageDataUrl(dataUrl);
let aiScore = 0;
let realScore = 0;
for (const [label, score] of Object.entries(rawScores)) {
const norm = label.toLowerCase();
if (FAKE_LABEL_ALIASES.has(norm)) aiScore = Math.max(aiScore, score);
if (REAL_LABEL_ALIASES.has(norm)) realScore = Math.max(realScore, score);
}
// Decide label
let label: ClassificationResult['label'];
let confidence: number;
if (aiScore > realScore + UNCERTAIN_BAND && aiScore > threshold) {
label = 'ai';
confidence = aiScore;
} else if (realScore > aiScore + UNCERTAIN_BAND && realScore > threshold) {
label = 'real';
confidence = realScore;
} else {
label = 'uncertain';
confidence = Math.max(aiScore, realScore);
}
return {
label,
confidence,
source: 'local',
modelId,
rawScores,
durationMs,
};
}Update src/offscreen/index.ts to handle CLASSIFY_IMAGE directly:
import { classifyImageLocal } from '@/detectors/image/local';
import { ensureImageClassifier } from './ml-engine';
import type { Message } from '@/shared/types';
chrome.runtime.onMessage.addListener((message: Message, _sender, sendResponse) => {
if (message.type === 'CLASSIFY_IMAGE') {
(async () => {
try {
const result = await classifyImageLocal(message.dataUrl, message.threshold ?? 0.55);
sendResponse({ ok: true, requestId: message.requestId, result });
} catch (err) {
sendResponse({ ok: false, requestId: message.requestId, error: String(err) });
}
})();
return true; // async sendResponse
}
if (message.type === 'PRELOAD_IMAGE_MODEL') {
(async () => {
await ensureImageClassifier(undefined, (info) => {
chrome.runtime.sendMessage({ type: 'MODEL_STATUS', phase: info.phase, pct: info.pct });
});
sendResponse({ ok: true });
})();
return true;
}
});Add
thresholdtoClassifyImageMessageandPRELOAD_IMAGE_MODELto theMessagediscriminated union insrc/shared/types.ts.
In src/background/index.ts, replace mockClassifyImage invocation with offscreen routing:
async function handleClassifyImage(msg: ClassifyImageMessage, sender: chrome.runtime.MessageSender) {
await ensureOffscreen();
const settings = await getSettings();
// Phase 4 will branch here on settings.boostProvider.
const response = await chrome.runtime.sendMessage({
type: 'CLASSIFY_IMAGE',
requestId: msg.requestId,
dataUrl: msg.dataUrl,
threshold: settings.confidenceThreshold,
});
if (sender.tab?.id != null) {
chrome.tabs.sendMessage(sender.tab.id, {
type: 'RESULT',
requestId: msg.requestId,
result: response?.ok ? response.result : {
label: 'uncertain', confidence: 0, source: 'local',
modelId: 'error', durationMs: 0,
reasoning: response?.error ?? 'Unknown error',
},
}, (_) => { /* ignore lastError */ void chrome.runtime.lastError; });
}
}In src/content/index.ts, modify imageToDataUrl to try createImageBitmap(img) first. On SecurityError (canvas tainted), send a FETCH_IMAGE_BYTES message to the background. Background does the fetch(src, {credentials:'include'}), returns the bytes as a dataUrl. Content script then resizes and continues.
Skeleton:
// Updated imageToDataUrl
async function imageToDataUrl(img: HTMLImageElement): Promise<string | null> {
const src = img.currentSrc || img.src;
if (!src) return null;
// Skip data: URLs > 2 MB and SVGs (raster-only models can't handle SVG cleanly)
if (src.startsWith('data:image/svg')) return null;
if (src.startsWith('data:') && src.length > 2 * 1024 * 1024) return null;
// Path A: try canvas (fast)
try {
const bitmap = await createImageBitmap(img, {
resizeWidth: 224, resizeHeight: 224, resizeQuality: 'medium',
});
const canvas = new OffscreenCanvas(224, 224);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.85 });
return await blobToDataUrl(blob);
} catch (e) {
// Path B: fall back to background fetch
return new Promise((resolve) => {
chrome.runtime.sendMessage({ type: 'FETCH_IMAGE_BYTES', src }, (resp) => {
if (chrome.runtime.lastError || !resp?.ok) return resolve(null);
resolve(resp.dataUrl);
});
});
}
}Background handles FETCH_IMAGE_BYTES:
if (message.type === 'FETCH_IMAGE_BYTES') {
(async () => {
try {
const r = await fetch(message.src, { credentials: 'include' });
if (!r.ok) return sendResponse({ ok: false, error: `HTTP ${r.status}` });
const blob = await r.blob();
const reader = new FileReader();
reader.onload = () => sendResponse({ ok: true, dataUrl: reader.result });
reader.onerror = () => sendResponse({ ok: false, error: 'read-failed' });
reader.readAsDataURL(blob);
} catch (err) {
sendResponse({ ok: false, error: String(err) });
}
})();
return true;
}Note:
fetchfrom the service worker doesn't replay browser-page cookies for cross-origin requests. For most cases (CDN images, public URLs) this works. For login-gated images, neither path will work without an opaque-response approach. Phase 2 ships with this limitation; document it.
Create src/shared/cache.ts:
import type { ClassificationResult } from './types';
const DB_NAME = 'aicd-cache-v1';
const STORE = 'results';
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
interface CacheEntry {
key: string; // sha256(bytes) + ':' + modelId + ':' + provider
result: ClassificationResult;
expiresAt: number;
}
let dbPromise: Promise<IDBDatabase> | null = null;
function openDb(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => {
req.result.createObjectStore(STORE, { keyPath: 'key' });
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
export async function sha256Hex(input: string | ArrayBuffer): Promise<string> {
const buf = typeof input === 'string' ? new TextEncoder().encode(input) : input;
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export function cacheKey(contentHash: string, modelId: string, provider: string): string {
return `${contentHash}:${modelId}:${provider}`;
}
export async function cacheGet(key: string): Promise<ClassificationResult | null> {
const db = await openDb();
return new Promise((resolve) => {
const tx = db.transaction(STORE, 'readonly');
const req = tx.objectStore(STORE).get(key);
req.onsuccess = () => {
const entry = req.result as CacheEntry | undefined;
if (!entry) return resolve(null);
if (entry.expiresAt < Date.now()) return resolve(null);
resolve(entry.result);
};
req.onerror = () => resolve(null);
});
}
export async function cacheSet(key: string, result: ClassificationResult): Promise<void> {
const db = await openDb();
return new Promise((resolve) => {
const tx = db.transaction(STORE, 'readwrite');
tx.objectStore(STORE).put({ key, result, expiresAt: Date.now() + TTL_MS });
tx.oncomplete = () => resolve();
tx.onerror = () => resolve();
});
}Use this in the offscreen handler around
classifyImageLocal. Computesha256Hex(dataUrl)(data URL is fine as a content hash for our purposes), build cache key, hitcacheGet, fall through to inference,cacheSet.
Update src/popup/App.tsx:
- On mount:
chrome.runtime.sendMessage({ type: 'PRELOAD_IMAGE_MODEL' })(fire-and-forget; the model loads in the background while the user reads the popup). - Listen for
MODEL_STATUSmessages and show a progress bar. - Replace "Mock mode (Phase 1)" pill with
Local · ready/Local · downloading 47%/Boost · Gemini/Local · error.
In src/options/App.tsx, add an <details> "Advanced" section with a freeform modelId input. Default empty (uses Organika/sdxl-detector). Add localImageModel: string to ExtensionSettings in types.ts.
-
npm run typecheck && npm run lint && npm run test && npm run buildgreen - Manual: extension loads, popup shows "Local · downloading 0% → ... → ready" on first scan
- Manual: scan visible images on a public page (e.g., a news site) shows real "AI" vs "Real" badges with confidence
- Manual: second scan of the same images is instant (cache hit)
- Manual: scan a Wikipedia article — ~all images get a result (not "Couldn't read")
- Manual: scan an Instagram post (logged in) — at least 50% of images get a real result via fetch fallback
- CI never downloads a model (unit tests mock
ensureImageClassifier) - Sub-commits as you go, e.g.:
Phase 2.1: install transformers.js, scaffold ml-enginePhase 2.2: real image inference via offscreen docPhase 2.3: IndexedDB result cachePhase 2.4: image fetch fallback via backgroundPhase 2.5: popup model status indicator + modelId override
- Login-gated images on third-party sites (Discord channels, private LinkedIn posts) often won't classify. Acceptable for v1.
- First scan after install takes 5-15 sec to download model. Show progress.
- Newer image generators (Flux, SD3, Sora frames, GPT-Image) leak past
Organika/sdxl-detector. This is the explicit reason for Phase 2.5 benchmark.
Replace Organika/sdxl-detector with the best-performing model under 100 MB on a maintainer-curated test set. Run after Phase 2 ships and is in real use.
Create bench/ with:
bench/
README.md # how to run
curate.md # maintainer-facing instructions for collecting test images
fixtures/
ai/ # ~30 known-AI images (gitignored; use git-lfs or external storage if checked in)
real/ # ~30 known-real images
run.ts # node script: load each model, classify each fixture, output CSV
results/ # per-run output, gitignored
Before running:
- Maintainer collects ~30 known-AI images (mix of generators they actually encounter) and ~30 known-real photos.
- Drops them into
bench/fixtures/{ai,real}/. - Agent runs
npm run bench.
Organika/sdxl-detector(current default, baseline)Ateeqq/ai-vs-human-image-detectorumm-maybe/AI-image-detector- One newer model TBD (search HF for "ai-detector" updated within 6 months)
Write bench/results/YYYY-MM-DD.md with:
- Table per model: accuracy, precision, recall, F1, mean confidence on correct preds, mean confidence on wrong preds, model size, mean inference time
- Recommendation: keep current default, OR switch to model X with rationale
- Switch only happens after maintainer reviews and approves
- Maintainer reviews benchmark report
- If switch approved: bump default in
src/offscreen/ml-engine.ts, pin new revision SHA, update CHANGELOG, runscripts/mirror-model.shfor the new model - If switch rejected: leave default as is, document rationale in
bench/results/
"Detect AI in selection" (right-click context menu + popup button) returns a real classification using Hello-SimpleAI/chatgpt-detector-roberta running locally.
Add to src/offscreen/ml-engine.ts:
import { pipeline, type TextClassificationPipeline } from '@huggingface/transformers';
const DEFAULT_TEXT_MODEL = 'Hello-SimpleAI/chatgpt-detector-roberta';
let textClassifier: TextClassificationPipeline | null = null;
let textLoading: Promise<TextClassificationPipeline> | null = null;
export async function ensureTextClassifier(
modelId = DEFAULT_TEXT_MODEL,
onProgress?: ProgressCallback,
): Promise<TextClassificationPipeline> {
if (textClassifier) return textClassifier;
if (textLoading) return textLoading;
textLoading = (async () => {
const pipe = await pipeline('text-classification', modelId, {
dtype: 'q8',
device: 'auto',
progress_callback: (p: any) => {
if (p.status === 'progress' && typeof p.progress === 'number') {
onProgress?.({ phase: 'downloading', pct: Math.round(p.progress) });
}
},
});
textClassifier = pipe as TextClassificationPipeline;
onProgress?.({ phase: 'ready', pct: 100 });
return textClassifier;
})();
return textLoading;
}Create src/detectors/text/local.ts:
import { ensureTextClassifier } from '@/offscreen/ml-engine';
import type { ClassificationResult } from '@/shared/types';
const MIN_LENGTH = 100;
// Approx token count via 4-char heuristic (good enough for chunking; not exact)
function approxTokens(s: string): number { return Math.ceil(s.length / 4); }
function chunkText(text: string, maxTokens = 512, overlap = 64): string[] {
if (approxTokens(text) <= maxTokens) return [text];
const chunkSize = maxTokens * 4;
const overlapSize = overlap * 4;
const chunks: string[] = [];
for (let start = 0; start < text.length; start += (chunkSize - overlapSize)) {
chunks.push(text.slice(start, start + chunkSize));
if (start + chunkSize >= text.length) break;
}
return chunks;
}
export async function classifyTextLocal(
text: string,
threshold: number,
): Promise<ClassificationResult | { tooShort: true }> {
if (text.trim().length < MIN_LENGTH) return { tooShort: true };
const t0 = performance.now();
const pipe = await ensureTextClassifier();
const chunks = chunkText(text);
let aiSum = 0;
let humanSum = 0;
for (const chunk of chunks) {
const result = (await pipe(chunk)) as Array<{ label: string; score: number }>;
for (const r of result) {
const label = r.label.toLowerCase();
if (label.includes('ai') || label.includes('chatgpt') || label === 'fake') aiSum += r.score;
if (label.includes('human') || label === 'real') humanSum += r.score;
}
}
const aiAvg = aiSum / chunks.length;
const humanAvg = humanSum / chunks.length;
let label: ClassificationResult['label'];
let confidence: number;
if (aiAvg > threshold && aiAvg > humanAvg + 0.10) {
label = 'ai'; confidence = aiAvg;
} else if (humanAvg > threshold && humanAvg > aiAvg + 0.10) {
label = 'real'; confidence = humanAvg;
} else {
label = 'uncertain'; confidence = Math.max(aiAvg, humanAvg);
}
return {
label, confidence,
source: 'local',
modelId: 'Hello-SimpleAI/chatgpt-detector-roberta',
rawScores: { ai: aiAvg, human: humanAvg },
durationMs: performance.now() - t0,
};
}- Offscreen: handle
CLASSIFY_TEXTsimilarly toCLASSIFY_IMAGE. IftooShort, return special response. - Background: route to offscreen.
- Content script: if response is
tooShort, show toast "Selection too short for reliable detection (min 100 chars)" instead of a result badge. - Content script
handleTextSelection: skip ifdocument.activeElementis<input>or<textarea>(selection from there is the user's own typed text).
In src/shared/types.ts, ExtensionSettings becomes:
export interface ExtensionSettings {
boostProvider: BoostProvider;
apiKey: string;
imageThreshold: number; // default 0.55
textThreshold: number; // default 0.70
showUncertain: boolean;
localImageModel: string;
localTextModel: string;
// Phase 4 additions:
fallbackOnBoostFailure: boolean; // default true
rateLimitRpm: number; // default 10
}Add a settings migration — when loading old settings without textThreshold, default it to 0.70.
-
npm run typecheck && npm run lint && npm run test && npm run buildgreen - Manual: select a paragraph from a news article → "Detect AI in selection" → real classification toast appears
- Manual: select 5 chars → "Selection too short" toast
- Manual: type in a textarea, select → no scan triggered
- Manual: pasting a known ChatGPT essay → labeled AI with high confidence
- Manual: pasting a Wikipedia paragraph → labeled "real" or "uncertain"
When a user pastes a Gemini / OpenAI / HF key in Options, image and text scans go to that provider with structured-JSON output. On failure, fall back to local with a visible toast.
Create one file per provider per modality, all implementing the same shape:
// src/detectors/image/gemini.ts
import type { ClassificationResult } from '@/shared/types';
export async function classifyImageGemini(
apiKey: string,
dataUrl: string,
): Promise<ClassificationResult> {
const t0 = performance.now();
// Strip "data:image/jpeg;base64," prefix
const [, mimeAndEncoding, base64] = dataUrl.match(/^data:(.+);base64,(.+)$/) || [];
if (!base64) throw new Error('Invalid data URL');
const mimeType = mimeAndEncoding?.split(';')[0] ?? 'image/jpeg';
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`;
const body = {
contents: [{
parts: [
{ text: 'Classify whether this image was generated by AI or is a real photograph. Respond with structured JSON.' },
{ inlineData: { mimeType, data: base64 } },
],
}],
generationConfig: {
responseMimeType: 'application/json',
responseSchema: {
type: 'object',
required: ['label', 'confidence', 'reasoning'],
properties: {
label: { type: 'string', enum: ['ai', 'real', 'uncertain'] },
confidence: { type: 'number' },
reasoning: { type: 'string' },
},
},
},
};
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`Gemini API ${r.status}: ${await r.text()}`);
const data = await r.json();
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '{}';
const parsed = JSON.parse(text);
return {
label: parsed.label,
confidence: Math.max(0, Math.min(1, Number(parsed.confidence) || 0)),
source: 'gemini',
modelId: 'gemini-2.5-flash',
reasoning: parsed.reasoning,
durationMs: performance.now() - t0,
};
}Mirror the pattern for:
src/detectors/image/openai.ts— POSThttps://api.openai.com/v1/chat/completionswithmodel: 'gpt-4o-mini',response_format: { type: 'json_schema', json_schema: { ... } }, image asdata:image/...;base64,...src/detectors/image/hf.ts— POST tohttps://api-inference.huggingface.co/models/{modelId}with raw bytes, parse the classifier output. Default modelId for HF: same aslocalImageModel.src/detectors/text/{gemini,openai,hf}.ts— same pattern minus image bits.
Update src/detectors/index.ts:
import { classifyImageLocal } from './image/local';
import { classifyImageGemini } from './image/gemini';
import { classifyImageOpenAI } from './image/openai';
import { classifyImageHF } from './image/hf';
import type { ClassificationResult, ExtensionSettings } from '@/shared/types';
export async function classifyImage(
dataUrl: string,
settings: ExtensionSettings,
emitToast: (msg: string) => void,
): Promise<ClassificationResult> {
const useBoost = settings.boostProvider !== 'none' && settings.apiKey;
if (useBoost) {
try {
switch (settings.boostProvider) {
case 'gemini': return await classifyImageGemini(settings.apiKey, dataUrl);
case 'openai': return await classifyImageOpenAI(settings.apiKey, dataUrl);
case 'hf': return await classifyImageHF(settings.apiKey, dataUrl, settings.localImageModel);
}
} catch (err) {
if (settings.fallbackOnBoostFailure) {
emitToast(`Boost failed (${humanizeError(err)}) — using local model`);
return classifyImageLocal(dataUrl, settings.imageThreshold);
}
throw err;
}
}
return classifyImageLocal(dataUrl, settings.imageThreshold);
}Where router runs: in the offscreen document, NOT background. Offscreen is the only context that can call
classifyImageLocal(transformers.js is loaded there). For BYO providers,fetch()works in offscreen too.
Create src/shared/rate-limit.ts:
class RateLimiter {
private timestamps: number[] = [];
constructor(private rpm: number) {}
async acquire(): Promise<void> {
const now = Date.now();
this.timestamps = this.timestamps.filter((t) => now - t < 60_000);
if (this.timestamps.length >= this.rpm) {
const waitMs = 60_000 - (now - this.timestamps[0]) + 50;
await new Promise((r) => setTimeout(r, waitMs));
return this.acquire();
}
this.timestamps.push(now);
}
}
const limiters: Record<string, RateLimiter> = {};
export async function acquireRateLimit(provider: string, rpm: number): Promise<void> {
if (!limiters[provider]) limiters[provider] = new RateLimiter(rpm);
await limiters[provider].acquire();
}Call acquireRateLimit(settings.boostProvider, settings.rateLimitRpm) at the top of each provider adapter.
The router calls emitToast(). The offscreen sends a BOOST_TOAST message to the background, which forwards it to the active tab's content script. Content script renders it as a 3-sec floating toast styled the same as the text-result toast.
Add to src/options/App.tsx:
- "Test API key" button — calls a tiny diagnostic ("respond with
{label: 'real', confidence: 1, reasoning: 'test'}") to verify the key works - Toggle: "Fall back to local model on boost failure" (default on)
- Number input: "Rate limit (requests/min)" (default 10, min 1, max 60)
-
npm run typecheck && npm run lint && npm run test && npm run buildgreen - Manual: paste a Gemini key, click Test → "Test successful · 234 ms"
- Manual: scan images with Gemini configured → badges show
Gemini · 94% - Manual: paste an invalid key, scan → toast "Boost failed (invalid key) — using local model", badge shows local result
- Manual: rate-limited burst (set rpm=2, scan 10 images) → trickles cleanly, no errors
- BYO provider keys never appear in
console.log, network requests, or stored anywhere exceptchrome.storage.sync - CI tests for provider adapters use mocked
fetch(no real API calls)
Unit tests live in tests/unit/. They MUST NOT:
- Download ML models
- Call real APIs
- Open real browser contexts
They SHOULD:
- Mock
@huggingface/transformersviavi.mockto return fixed pipeline outputs - Mock
fetchfor provider adapters - Test the label-mapping logic in
classifyImageLocal/classifyTextLocalthoroughly - Test the cache (use
fake-indexeddbif needed) - Test the rate limiter
Example mocking pattern:
import { vi } from 'vitest';
vi.mock('@huggingface/transformers', () => ({
pipeline: vi.fn().mockResolvedValue(
vi.fn().mockResolvedValue([
{ label: 'fake', score: 0.92 },
{ label: 'real', score: 0.08 },
]),
),
env: { allowLocalModels: false, allowRemoteModels: true, useBrowserCache: true },
}));Real-model tests live in bench/, run manually.
When you add a field to ExtensionSettings, update src/shared/storage.ts to fill defaults from DEFAULT_SETTINGS. Already does spread-with-defaults, so existing users don't lose data on upgrade.
| Pitfall | Avoid by |
|---|---|
Importing @huggingface/transformers from popup/options/content/background |
Only import from src/offscreen/. Will bloat other bundles otherwise. |
Calling chrome.offscreen.createDocument when one already exists |
Always check chrome.offscreen.hasDocument() first |
| Sending messages from offscreen → content script directly | Don't. Route through background SW. Offscreen ↔ background ↔ content. |
| Service worker dies, breaks ongoing classification | Each CLASSIFY_* request must be self-contained — no SW-level state assumed beyond the offscreen doc itself |
chrome.runtime.sendMessage with an undefined receiver |
Wrap with .catch(() => {}) or check chrome.runtime.lastError in callback |
| Holding the model pipeline as a top-level singleton | OK because offscreen is one document; just don't accidentally re-init on every call |
| Assuming canvas works on cross-origin images | Always have the fetch fallback ready |
| Logging API keys | NEVER console.log settings. Pick fields explicitly. |
// ❌ ANTI-PATTERN: re-implementing the message bus
chrome.runtime.onMessage.addListener((m: any) => { /* untyped */ });
// ✅ DO: use the typed Message union
chrome.runtime.onMessage.addListener((m: Message, sender, sendResponse) => {
if (m.type === 'CLASSIFY_IMAGE') { /* ... */ }
});// ❌ ANTI-PATTERN: per-instance MutationObserver on document.body
new MutationObserver(...).observe(document.body, { childList: true, subtree: true });
// ✅ DO: ONE shared observer in the content-script module// ❌ ANTI-PATTERN: fall-through types
type Entry = { img: HTMLImageElement | null; ... };
if (!entry.img) { /* it's a text toast */ }
// ✅ DO: discriminated unions
type Entry = { kind: 'image'; img: HTMLImageElement } | { kind: 'text'; toast: HTMLElement };
if (entry.kind === 'text') { /* known text */ }// ❌ ANTI-PATTERN: pessimistic invalidation
const result = await classify(image); // never cached
// ✅ DO: cache hit → skip inference
const cached = await cacheGet(key);
if (cached) return cached;
const result = await classify(image);
await cacheSet(key, result);
return result;- transformers.js v3 docs: https://huggingface.co/docs/transformers.js
- MV3 offscreen documents: https://developer.chrome.com/docs/extensions/reference/api/offscreen
- MV3 service workers: https://developer.chrome.com/docs/extensions/develop/concepts/service-workers
- CRXJS plugin: https://crxjs.dev/
- Gemini API: https://ai.google.dev/api/generate-content
- OpenAI structured outputs: https://platform.openai.com/docs/guides/structured-outputs
- HF Inference API: https://huggingface.co/docs/api-inference
Create this in Phase 2.5 or earlier when wiring the GH Releases fallback.
#!/usr/bin/env bash
# scripts/mirror-model.sh
# Usage: ./scripts/mirror-model.sh <hf-model-id> <revision> <release-tag>
# Example: ./scripts/mirror-model.sh Organika/sdxl-detector main models-v1
set -euo pipefail
MODEL_ID="${1:?HF model id required}"
REVISION="${2:-main}"
RELEASE_TAG="${3:-models-v1}"
WORKDIR="$(mktemp -d)"
cd "$WORKDIR"
echo "Downloading $MODEL_ID @ $REVISION..."
# Use huggingface-cli or curl. ONNX file is what we ship.
curl -L -o model.onnx "https://huggingface.co/$MODEL_ID/resolve/$REVISION/onnx/model_quantized.onnx"
curl -L -o config.json "https://huggingface.co/$MODEL_ID/resolve/$REVISION/config.json"
# Other files: tokenizer.json (for text), preprocessor_config.json (for image), etc.
echo "Attaching to GitHub Release $RELEASE_TAG..."
gh release upload "$RELEASE_TAG" model.onnx config.json --repo rithwikgokhale/image-ai-detector --clobber
echo "Done. Files mirrored to: https://github.com/rithwikgokhale/image-ai-detector/releases/tag/$RELEASE_TAG"
echo "Update src/offscreen/ml-engine.ts to point env.remoteHost fallback at this release."Then in ml-engine.ts, on a network error during model load, retry with env.remoteHost = 'https://github.com/rithwikgokhale/image-ai-detector/releases/download/models-v1'.
Stop and ask the maintainer if:
- A locked-in decision in §1 seems wrong for the current task
- A model returns wildly different label names than expected (
Organika/sdxl-detectorreturningLABEL_0/LABEL_1instead offake/realwould mean a different model variant was downloaded — confirm before mapping) - Phase 2 acceptance criteria are not all met after honest effort
- A new permission would be needed in
manifest.config.ts - An existing CI check would need to be relaxed
- Model download size exceeds 100 MB
- Boost provider response shape doesn't match the expected schema reliably
Don't ask:
- Naming of internal symbols (just be sensible)
- Choice of styling for new UI (match existing Tailwind patterns)
- Order of implementing the steps within a phase (follow this doc)
Roughly one push per sub-phase:
Phase 2 prep: fix Phase 1 perf bugs and error surfacing [commit + push]
Phase 2.1: install transformers.js, scaffold ml-engine [commit + push]
Phase 2.2: real image inference via offscreen doc [commit + push]
Phase 2.3: IndexedDB result cache [commit + push]
Phase 2.4: image fetch fallback via background [commit + push]
Phase 2.5: popup model status + modelId override [commit + push]
Phase 2.5 (benchmark): scaffold bench/ + run.ts [commit + push]
[after maintainer collects test images]
Phase 2.5 (benchmark): results + decision [commit + push]
Phase 3.1: text classifier in ml-engine [commit + push]
Phase 3.2: text local detector + sliding window [commit + push]
Phase 3.3: text UX wiring [commit + push]
Phase 4.1: provider adapter interface + Gemini image [commit + push]
Phase 4.2: OpenAI + HF image [commit + push]
Phase 4.3: text providers [commit + push]
Phase 4.4: detector router + rate limiting [commit + push]
Phase 4.5: Options UI for boost mode + Test button [commit + push]
Update CHANGELOG.md [Unreleased] → keep adding bullets.
Update ROADMAP.md ✅ checkmarks as phases finish.
End of handbook. Ping the principal-engineer agent (or the maintainer directly) for anything not covered here.