# Project export: BrowserDelta

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: BrowserDelta cuts browser-agent context by ~78% while matching the vision-full-state baseline on 12/12 next-action predictions! Compression layer for browser use.
- Devpost: https://devpost.com/software/browserdelta
- GitHub: https://github.com/jaykbpark/browser-use-compaction-co
- Video: https://www.youtube.com/embed/rEbSwp6_rc8?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (Best Use of Cognition)
- Team: 2 GitHub contributor(s) — Kangbin (Jay) Park (16 commits), devin-ai-integration[bot] (1 commits)

## Devpost submission (written by the team)

### Inspiration

I love making my agents do things on the web -- what limits me, however, is just how expensive it is! Browser agents waste context by repeatedly sending full screenshots and page state, even when only one textbox or button changed. BrowserDelta asks whether an agent can keep the next-action signal while seeing only the browser state delta.

### What it does

BrowserDelta is an intermediate layer between BrowserBase / Playwright and your agent. It is built with a FastAPI backend and can fit any browser use tool with a run-folder contract. Playwright / Browserbase records screenshots and page state, the codec writes a compaction version of the observations, and the replay eval compares the compact context vs full-state baselines. BrowserDelta behind the scenes uses a few techniques to compact images. DOM diffs noise-filtered pixel diffs region segmentation SSIM / phash metrics OCR Results On the core visual benchmark suite, BrowserDelta matched the vision-full-state baseline on 12/12 next-action predictions while cutting estimated context by about 76%. On imported MiniWoB++ demos, the compact representation reached about 96% token reduction with only a small parity gap against a full-state baseline.

## README (from the GitHub repository)

# BrowserDelta

BrowserDelta is a semantic compaction layer for Browserbase-style browser agents.
Instead of sending an LLM a full screenshot after every browser action, it records
the browser state before and after each step, diffs the states, and emits a small
observation that says only what changed.

```text
Browserbase session
  -> browser action
  -> raw before/after state
  -> BrowserDelta codec
  -> compact observation for the LLM
  -> replay eval for next-action parity
```

## What We Are Building

The MVP has three independent workstreams:

1. Browserbase recorder: runs browser actions and saves raw step evidence.
2. Compaction codec: converts raw step evidence into compact LLM observations.
3. Replay evaluator: checks whether compact observations preserve the next action.

The contract between the two teams is the run folder:

```text
runs/<run_id>/
  run.json
  steps.jsonl
  steps/
    step_001_before.json
    step_001_after.json
    step_001_before.png
    step_001_after.png
```

Each `steps.jsonl` row points to the raw before/after files. The compaction team
can work from those files without needing Browserbase credentials. Pointer paths
and generated crop paths are run-relative, so a copied run folder should still
compact correctly.

## Tech Stack

- Backend: Python, FastAPI
- Browser runtime: Browserbase, with local Playwright fallback
- Browser control: Playwright Python
- Screenshot diff: Pillow, NumPy, optional OpenCV
- Visual delta: connected components, DOM-box alignment, SSIM, perceptual hash,
  optional OCR
- Data format: JSON / JSONL run logs
- Viewer: Vite, React, TypeScript

## Quick Start

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
python -m playwright install chromium
cp .env.example .env
```

Run the API:

```bash
uvicorn browserdelta.main:app --reload --app-dir backend
```

Record a local demo run:

```bash
python scripts/record_demo.py --url https://example.com --run-id smoke
```

Run the deterministic local BrowserDelta proof:

```bash
python scripts/record_demo.py --task tasks/local_checkout.json --run-id local_checkout --headless --compact --runtime local
```

This opens `demo_pages/local_checkout.html`, records four real browser actions,
and immediately compacts the run. Expected behavior:

- step 1: validation error -> `text_only`
- step 2: typed field value -> `text_only`
- step 3: canvas-only chart update -> `crop_with_context`
- step 4: checkout modal opens -> `text_only`

Compact a run:

```bash
python scripts/compact_run.py runs/smoke
```

Evaluate whether compact observations preserve the next action:

```bash
python scripts/eval_run.py runs/local_checkout
```

The replay evaluator writes `eval_report.json` and reports next-action parity
for every transition that has a following recorded action.

Run the same replay eval with a real LLM predictor:

```bash
python scripts/eval_run.py runs/local_checkout --predictor llm
```

This uses `OPENAI_API_KEY` and `OPENAI_MODEL` from `.env`. The default
`heuristic` predictor is still useful for free, deterministic smoke tests; the
`llm` predictor is the demo proof that a model can choose the same next actions
from compact context only.

Compare compact context against a real vision full-state baseline:

```bash
python scripts/eval_run.py runs/local_checkout --predictor llm --compare
```

This writes:

- `eval_report.json`: compact-only replay report.
- `eval_vision_full_state_report.json`: baseline replay report that sends the
  full captured page state plus the after-action screenshot as an `input_image`.
- `eval_comparison.json`: machine-readable compact-vs-baseline comparison.
- `eval_summary.md`: human-readable result for demos.

The comparison answers the core question directly: did compact context preserve
the next browser action, and how many estimated tokens did it save versus sending
the full captured state plus screenshot evidence?

For a cheaper text-only baseline that does not attach screenshot bytes, run:

```bash
python scripts/eval_run.py runs/local_checkout --predictor llm --compare --baseline-context full_state
```

Batch replay eval over multiple runs:

```bash
python scripts/eval_suite.py runs/local_checkout runs/browserbase_checkout
python scripts/eval_suite.py --json tasks/local_checkout.json
python scripts/eval_suite.py --predictor llm --compare runs/local_checkout runs/browserbase_checkout
python scripts/eval_suite.py --predictor llm --compare --baseline-context full_state runs/local_checkout runs/browserbase_checkout
```

Task JSON resolves its `id` to `runs/<id>`; suite JSON can also pass a `runs`
list of run folders.

Run the checked-in smoke fixture:

```bash
python scripts/compact_run.py examples/runs/login_error
```

That fixture should produce a text-only compact observation for an `Email is
required` validation error. Use it first when checking whether recorder output
still matches the compaction contract.

The CLI prints demo-facing metrics per step:

```text
step 1: text_only, 91.34% saved, confidence 0.95 - New text appeared: Email is required
total: 1 step(s), 60 compact tokens vs 693 baseline, 91.34% saved
```

Additional fixtures cover the two main router behaviors:

```bash
python scripts/compact_run.py examples/runs/modal_checkout
python scripts/compact_run.py examples/runs/visual_only_change
```

- `modal_checkout`: checkout dialog and form fields appear, expected
  `route=text_only`.
- `visual_only_change`: canvas-like chart changes without useful DOM evidence,
  expected `route=crop_with_context` with crops under `crops/step_001/`.

Visual benchmark tasks stress CV-heavy browser changes:

```bash
python scripts/record_demo.py --task tasks/visual_canvas_chart.json --run-id visual_canvas_chart --headless --compact --runtime local
python scripts/record_demo.py --task tasks/visual_progress_toast.json --run-id visual_progress_toast --headless --compact --runtime local
python scripts/record_demo.py --task tasks/visual_swatch_picker.json --run-id visual_swatch_picker --headless --compact --runtime local
python scripts/record_demo.py --task tasks/search_filter.json --run-id search_filter --headless --compact --runtime local
```

- `visual_canvas_chart`: repeated canvas redraws with no useful DOM text delta.
- `visual_progress_toast`: progress bar visual movement plus a completion toast.
- `visual_swatch_picker`: radio state plus selected swatch styling.
- `search_filter`: table filtering plus add-to-cart and reset state changes.

## External Evals

BrowserDelta can import BrowserGym/MiniWoB episodes into the same run-folder
contract, then score them with the existing replay evaluator:

```bash
PYTHONPATH=$PWD/backend python scripts/record_browsergym.py \
  --env browsergym/miniwob.click-button \
  --run-id bg_click_button \
  --action "click('a12')" \
  --headless \
  --compact

python scripts/eval_run.py runs/bg_click_button --compare --baseline-context vision_full_state
```

Important: BrowserGym is intentionally not a default dependency because current
`browsergym-core` pins an older Playwright than the main recorder uses. Install
and run it in an isolated Python environment, then point `PYTHONPATH` at this
repo's `backend/` package. Also set `MINIWOB_URL` as required by MiniWoB++.

For multi-episode scripted suites, use:

```bash
python scripts/eval_external_suite.py docs/browsergym-miniwob-smoke.example.json --compare
```

See `docs/external-evals.md` for the benchmark strategy and caveats.

Run tests:

```bash
pytest
```

Run the shared recorder/codec contract test after changing schemas, recorder
output, or compaction path handling:

```bash
pytest tests/test_run_contract.py
```

## Browserbase Setup

For local development, BrowserDelta falls back to a local Playwright Chromium
browser when no Browserbase connection URL is configured.

To use Browserbase with the normal token flow, set:

```bash
BROWSERBASE_API_KEY="..."
```

`BROWSERBASE_PROJECT_ID` is optional; Browserbase can infer the pro

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 68 recognized source files, 346 KB.
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- HTML (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Codex — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (95 of 95)

```
.env.example
.gitignore
AGENTS.md
backend/browserdelta/__init__.py
backend/browserdelta/api/__init__.py
backend/browserdelta/api/routes_runs.py
backend/browserdelta/browserbase/__init__.py
backend/browserdelta/browserbase/actions.py
backend/browserdelta/browserbase/recorder.py
backend/browserdelta/browserbase/session.py
backend/browserdelta/browserbase/state.py
backend/browserdelta/compaction/__init__.py
backend/browserdelta/compaction/codec.py
backend/browserdelta/compaction/image_diff.py
backend/browserdelta/compaction/metrics.py
backend/browserdelta/compaction/renderer.py
backend/browserdelta/compaction/router.py
backend/browserdelta/compaction/structural_diff.py
backend/browserdelta/config.py
backend/browserdelta/eval/__init__.py
backend/browserdelta/eval/agent_judge.py
backend/browserdelta/eval/llm_agent.py
backend/browserdelta/eval/runner.py
backend/browserdelta/external/__init__.py
backend/browserdelta/external/browsergym_adapter.py
backend/browserdelta/main.py
backend/browserdelta/schemas.py
backend/browserdelta/storage.py
demo_pages/local_checkout.html
demo_pages/search_filter.html
demo_pages/visual_canvas_chart.html
demo_pages/visual_progress_toast.html
demo_pages/visual_swatch_picker.html
docs/architecture.md
docs/browsergym-miniwob-smoke.example.json
docs/external-evals.md
docs/schemas.md
docs/team-todos.md
examples/runs/login_error/README.md
examples/runs/login_error/run.json
examples/runs/login_error/steps.jsonl
examples/runs/login_error/steps/step_001_after.json
examples/runs/login_error/steps/step_001_before.json
examples/runs/modal_checkout/README.md
examples/runs/modal_checkout/run.json
examples/runs/modal_checkout/steps.jsonl
examples/runs/modal_checkout/steps/step_001_after.json
examples/runs/modal_checkout/steps/step_001_before.json
examples/runs/visual_only_change/README.md
examples/runs/visual_only_change/run.json
examples/runs/visual_only_change/steps.jsonl
examples/runs/visual_only_change/steps/step_001_after.json
examples/runs/visual_only_change/steps/step_001_before.json
pyproject.toml
README.md
reports/demo/miniwob-5seed-summary/summary.json
reports/demo/miniwob-5seed-summary/summary.md
runs/.gitkeep
scripts/compact_run.py
scripts/eval_external_suite.py
scripts/eval_run.py
scripts/eval_suite.py
scripts/record_browsergym.py
scripts/record_demo.py
scripts/run_api.py
scripts/summarize_miniwob_5seed.py
tasks/docs_search.json
tasks/local_checkout.json
tasks/search_filter.json
tasks/shopping.json
tasks/visual_canvas_chart.json
tasks/visual_progress_toast.json
tasks/visual_swatch_picker.json
tests/test_api_runs.py
tests/test_codec.py
tests/test_compact_run_cli.py
tests/test_eval_runner.py
tests/test_eval_suite.py
tests/test_example_fixture.py
tests/test_external_browsergym.py
tests/test_image_diff.py
tests/test_llm_agent.py
tests/test_metrics.py
tests/test_record_demo_e2e.py
tests/test_router.py
tests/test_run_contract.py
tests/test_search_filter_task.py
tests/test_structural_diff.py
tests/test_visual_benchmark_tasks.py
viewer/index.html
viewer/package.json
viewer/src/main.tsx
viewer/src/styles.css
viewer/tsconfig.json
viewer/vite.config.ts
```

### Dependencies

- pyproject.toml: browserbase@>=1.4.0, fastapi@>=0.115.0, numpy@>=1.26.0, opencv-python@>=4.10.0.84, pillow@>=10.4.0, playwright@>=1.48.0, pydantic@>=2.7.0, pytest@>=8.2.0, pytest-asyncio@>=0.24.0, python-dotenv@>=1.0.1, ruff@>=0.6.0, uvicorn[standard]@>=0.30.0
- viewer/package.json: @vitejs/plugin-react@^4.3.4, lucide-react@^0.468.0, react@^19.0.0, react-dom@^19.0.0, typescript@^5.7.0, vite@^6.0.0

### Recent commits (newest first)

- Add MiniWoB 5-seed compact-vs-full_state benchmark summary + aggregator (#4)
- Remove viewer setup explainer copy
- Collapse viewer replay controls
- Clarify viewer replay controls
- Remove ambiguous viewer hero metrics
- Remove viewer header explainer copy
- Tighten viewer header copy
- Simplify BrowserDelta viewer story
- Merge branch 'codex/browserdelta-external-evals' into codex/browserdelta-demo-viewer-agent
- Build BrowserDelta demo viewer
- Fix BrowserGym ref scoring in replay eval
- Document worktree checkpoint workflow
- Add safe external eval adapter
- Build browser compaction eval pipeline
- scaffold browserdelta project
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### AGENTS.md

```markdown
# AGENTS.md

## Project Goal

BrowserDelta is a semantic compaction layer for browser agents. It should reduce
the browser context sent to an LLM by replacing repeated full screenshots with
compact observations about what changed after each browser action.

## Current MVP

Build three pieces that meet at a file contract:

1. Browserbase recorder
   - Opens a Browserbase or local Playwright browser.
   - Executes actions.
   - Captures before/after screenshots and page state.
   - Writes raw run files under `runs/<run_id>/`.

2. Compaction codec
   - Reads raw run files.
   - Diffs screenshot and page state.
   - Writes `compact_observations.jsonl`.

3. Replay evaluator
   - Reads `steps.jsonl` and `compact_observations.jsonl`.
   - Predicts the next browser action from compact context only.
   - Writes `eval_report.json` with next-action parity and token savings.
   - Can compare compact context against a full captured-state baseline or a
     vision full-state baseline that attaches the after screenshot to the LLM,
     writing `eval_full_state_report.json`,
     `eval_vision_full_state_report.json`, `eval_comparison.json`, and
     `eval_summary.md`.

Do not make the project depend on a polished demo viewer before the recorder and
codec work from the command line.

## Team Split

Browserbase team owns:

- `backend/browserdelta/browserbase/session.py`
- `backend/browserdelta/browserbase/actions.py`
- `backend/browserdelta/browserbase/state.py`
- `backend/browserdelta/browserbase/recorder.py`
- `scripts/record_demo.py`

Compaction team owns:

- `backend/browserdelta/compaction/image_diff.py`
- `backend/browserdelta/compaction/structural_diff.py`
- `backend/browserdelta/compaction/codec.py`
- `backend/browserdelta/compaction/router.py`
- `backend/browserdelta/compaction/renderer.py`
- `backend/browserdelta/compaction/metrics.py`
- `scripts/compact_run.py`
- `tests/test_codec.py`
- `tests/test_image_diff.py`
- `tests/test_metrics.py`
- `tests/test_router.py`
- `tests/test_structural_diff.py`

Shared files:

- `backend/browserdelta/schemas.py`
- `backend/browserdelta/storage.py`
- `backend/browserdelta/eval/**`
- `docs/schemas.md`
- `examples/runs/**`
- `scripts/eval_run.py`
- `tests/test_eval_runner.py`
- `tests/test_example_fixture.py`
- `tests/test_run_contract.py`

Coordinate before changing shared schemas.

Parallel work rule: Browserbase can change recorder/session/action/state code
while compaction changes codec/diff/rendering code, as long as both sides keep
the run folder contract valid. Do not have two teams develop against the same
mutable `runs/<run_id>` folder; copy a fixture or use separate run IDs.

## File Contract

Every recorded step must provide:

```json
{
  "step": 1,
  "action": {"type": "click", "target": "Search textbox"},
  "result": {"ok": true},
  "before": {
    "screenshot": "steps/step_001_before.png",
    "state": "steps/step_001_before.json"
  },
  "after": {
    "screenshot": "steps/step_001_after.png",
    "state": 
[truncated — 6563 more characters]
```

### docs/external-evals.md

```markdown
# External Evals

BrowserDelta's credible external-eval path is:

```text
BrowserGym/MiniWoB episode
  -> BrowserDelta run folder
  -> compact_observations.jsonl
  -> existing replay eval
  -> compact vs vision_full_state comparison
```

## Why BrowserGym First

BrowserGym/MiniWoB++ is the lightest open browser-agent benchmark family for our
current stage. It gives us small browser tasks, screenshots, accessibility-tree
style observations, rewards, and task termination without requiring the full
WebArena deployment stack.

Heavier follow-ons:

- WebArena: credible web-agent benchmark, but heavier services and setup.
- VisualWebArena: better for visual grounding, also heavier.
- WebLINX / Mind2Web: useful for offline trace-style replay instead of live
  browser control.
- Browser Use benchmark / BU Bench: useful public comparison story, but should
  be wired after the local adapter path is stable.

## Dependency Rule

Do not add BrowserGym to BrowserDelta's default dependencies right now. Current
`browsergym-core` releases pin `playwright==1.44`, while BrowserDelta's recorder
uses a newer Playwright. Keep BrowserGym in a separate environment until that
conflict is resolved.

Example isolated setup:

```bash
python -m venv .venv-browsergym
.venv-browsergym/bin/pip install browsergym-miniwob==0.14.3
export MINIWOB_URL="file:///path/to/miniwob-plusplus/miniwob/html/miniwob/"
PYTHONPATH=$PWD/backend .venv-browsergym/bin/python scripts/record_browsergym.py \
  --env browsergym/miniwob.click-button \
  --run-id bg_click_button \
  --action "click('a12')" \
  --headless \
  --compact
```

Then evaluate from the normal BrowserDelta environment:

```bash
python scripts/eval_run.py runs/bg_click_button --compare --baseline-context vision_full_state
```

## No-Op Traces Are Not Success

`record_browsergym.py` requires scripted actions by default. You can pass
`--allow-noop-policy` for an adapter smoke trace, but that should not be used as
task-success evidence. A no-op trace only proves the import path writes a valid
run folder.

For a hackathon demo, use one of these:

- a tiny scripted MiniWoB trace with known element refs
- a BrowserGym policy that actually solves the task
- an offline imported trace with gold actions

The eval story should compare two observation tools while holding the agent and
task fixed:

- `vision_full_state`: full page state plus screenshot image every step
- `compact`: BrowserDelta changed DOM/text plus visual crops when needed

The metrics to show are task success or next-action parity, compact-vs-baseline
accuracy, estimated token savings, and failure examples.

```

### pyproject.toml

```
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "browserdelta"
version = "0.1.0"
description = "Semantic browser-state compaction for Browserbase agents"
requires-python = ">=3.11"
dependencies = [
  "fastapi>=0.115.0",
  "uvicorn[standard]>=0.30.0",
  "pydantic>=2.7.0",
  "python-dotenv>=1.0.1",
  "browserbase>=1.4.0",
  "playwright>=1.48.0",
  "pillow>=10.4.0",
  "numpy>=1.26.0",
  "opencv-python>=4.10.0.84",
]

[project.optional-dependencies]
dev = [
  "pytest>=8.2.0",
  "pytest-asyncio>=0.24.0",
  "ruff>=0.6.0",
]

[tool.setuptools.packages.find]
where = ["backend"]

[tool.pytest.ini_options]
pythonpath = ["backend"]
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py311"

```

### viewer/package.json

```
{
  "name": "browserdelta-viewer",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@vitejs/plugin-react": "^4.3.4",
    "vite": "^6.0.0",
    "typescript": "^5.7.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "lucide-react": "^0.468.0"
  },
  "devDependencies": {}
}

```

### backend/browserdelta/main.py

```python
from fastapi import FastAPI

from browserdelta.api.routes_runs import router as runs_router


app = FastAPI(title="BrowserDelta API", version="0.1.0")


@app.get("/health")
def health() -> dict[str, str]:
    return {"status": "ok"}


app.include_router(runs_router, prefix="/api")

```

### viewer/src/main.tsx

```typescript
import React from "react";
import { createRoot } from "react-dom/client";
import {
  AlertCircle,
  ArrowRight,
  CheckCircle2,
  FileJson,
  Gauge,
  Image,
  Loader2,
} from "lucide-react";
import "./styles.css";

type RunSummary = {
  runs: string[];
};

type BrowserAction = {
  type: string;
  target?: string | null;
  text?: string | null;
  key?: string | null;
  amount?: number | null;
  url?: string | null;
};

type StepPointer = {
  screenshot: string;
  state: string;
};

type StepRecord = {
  step: number;
  action: BrowserAction;
  result: {
    ok: boolean;
    message?: string;
    error?: string | null;
  };
  before: StepPointer;
  after: StepPointer;
};

type InteractiveElement = {
  ref: string;
  role: string;
  name?: string | null;
  value?: string | null;
  disabled?: boolean | null;
};

type PageState = {
  url?: string;
  title?: string | null;
  text?: string[];
  interactive?: InteractiveElement[];
  focused_ref?: string | null;
  console_errors?: string[];
  network_errors?: string[];
  screenshot?: string;
};

type StructuralChange = {
  type: string;
  detail: string;
};

type VisualRegion = {
  kind: string;
  area_pct: number;
  element_ref?: string | null;
  element_role?: string | null;
  element_name?: string | null;
  overlap_pct: number;
};

type CompactObservation = {
  step: number;
  action_result: string;
  summary: string;
  changed: StructuralChange[];
  visual_changed_pct: number;
  visual_raw_changed_pct: number;
  visual_ssim_score?: number | null;
  visual_phash_distance?: number | null;
  fallback: "none" | "crop" | "full_screenshot";
  route: "text_only" | "crop_with_context" | "full_screenshot";
  route_reason: string;
  confidence: number;
  llm_observation: string;
  crop_paths: string[];
  full_screenshot_path?: string | null;
  visual_regions: VisualRegion[];
  tokens_estimate: number;
  baseline_tokens_estimate: number;
  reduction_pct: number;
};

type ReplayStepResult = {
  step: number;
  context_mode: "compact" | "full_state" | "vision_full_state";
  observation_summary: string;
  expected_next_action: BrowserAction;
  predicted_next_action: BrowserAction;
  passed: boolean;
  match_reason: string;
  rationale: string;
  confidence: number;
  route: CompactObservation["route"];
  fallback: CompactObservation["fallback"];
  tokens_estimate: number;
  baseline_tokens_estimate: number;
  reduction_pct: number;
};

type ReplayReport = {
  context_mode: "compact" | "full_state" | "vision_full_state";
  predictor: string;
  evaluated_steps: number;
  passed_steps: number;
  next_action_accuracy: number;
  compact_tokens: number;
  baseline_tokens: number;
  avg_reduction_pct: number;
  steps: ReplayStepResult[];
};

type EvalComparisonSummary = {
  baseline_context_mode?: "compact" | "full_state" | "vision_full_state";
  evaluated_steps: number;
  compact_passed_steps: number;
  baseline_passed_steps: number;
  compact_accuracy: number;
  baseline_accuracy: number;
  accuracy_delta: number;
  compact_tokens: number;
  baseline_tokens: number;
  token_savings: number;
  token_reduction_pct: number;
};

type EvalComparisonReport = {
  run_id: string;
  predictor: string;
  compact: ReplayReport;
  baseline: ReplayReport;
  summary: EvalComparisonSummary;
  verdict: string;
  explanation: string[];
};

type RunDetail = {
  run_id: string;
  manifest: {
    start_url?: string;
    mode?: string;
  } | null;
  steps: StepRecord[];
  compact_observations: CompactObservation[];
  eval_report?: ReplayReport | null;
  eval_full_state_report?: ReplayReport | null;
  eval_vision_full_state_report?: ReplayReport | null;
  eval_comparison?: EvalComparisonReport | null;
};

type BusyAction = "compare" | null;

const numberFormatter = new Intl.NumberFormat("en-US");
const RUN_LABELS: Record<string, string> = {
  viewer_search_filter_smoke: "Fruit Finder replay",
};

function App() {
  const [runs, setRuns] = React.useState<string[]>([]);
  const [selectedRun, setSelectedRun] = React.useState("");
  const [detail, setDetail] = React.useState<RunDetail | null>(null);
  const [benchmarkDetails, setBenchmarkDetails] = React.useState<RunDetail[]>([]);
  const [selectedStep, setSelectedStep] = React.useState(1);
  const [predictor, setPredictor] = React.useState<"heuristic" | "llm">("heuristic");
  const [status, setStatus] = React.useState("loading");
  const [busy, setBusy] = React.useState<BusyAction>(null);
  const [error, setError] = React.useState<string | null>(null);
  const [afterState, setAfterState] = React.useState<PageState | null>(null);

  const refreshRunList = React.useCallback(async () => {
    setStatus("loading");
    setError(null);
    try {
      const response = await fetch("/api/runs");
      if (!response.ok) throw new Error(`Run index returned ${response.status}`);
      const data = (await response.json()) as RunSummary;
      const nextRuns = data.runs ?? [];
      setRuns(nextRuns);
      setSelectedRun((current) => (current && nextRuns.includes(current) ? current : nextRuns[0] ?? ""));
      const loaded = await Promise.all(nextRuns.map((runId) => loadRunQuietly(runId)));
      setBenchmarkDetails(loaded.filter((run): run is RunDetail => Boolean(run)));
      setStatus("ready");
    } catch (err) {
      setRuns([]);
      setBenchmarkDetails([]);
      setStatus("api unavailable");
      setError(errorMessage(err));
    }
  }, []);

  React.useEffect(() => {
    void refreshRunList();
  }, [refreshRunList]);

  React.useEffect(() => {
    if (!selectedRun) {
      setDetail(null);
      return;
    }
    const controller = new AbortController();
    setStatus("loading run");
    setError(null);
    loadRun(selectedRun, controller.signal)
      .then((run) => {
        setDetail(run);
        setSelectedStep((current) => clampStep(current, run));
        setStatus("ready");
      })
      .catch((err) => {
        if (controller.signal.aborted) return;
        setDetail(null);
        setStatus("run unavailable");
        setE
[truncated — 19658 more characters]
```

### viewer/vite.config.ts

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    proxy: {
      "/api": "http://127.0.0.1:8000",
      "/health": "http://127.0.0.1:8000",
    },
  },
});

```

### viewer/index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>BrowserDelta Viewer</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

```

### scripts/run_api.py

```python
#!/usr/bin/env python3
from __future__ import annotations

import argparse

import uvicorn


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Run the BrowserDelta API.")
    parser.add_argument("--host", default="127.0.0.1")
    parser.add_argument("--port", type=int, default=8000)
    parser.add_argument("--reload", action="store_true")
    args = parser.parse_args()

    uvicorn.run(
        "browserdelta.main:app",
        app_dir="backend",
        host=args.host,
        port=args.port,
        reload=args.reload,
        reload_dirs=["backend"] if args.reload else None,
    )

```

### tests/test_metrics.py

```python
from pathlib import Path

from PIL import Image

from browserdelta.compaction.metrics import (
    estimate_image_tokens,
    estimate_raw_baseline_tokens,
    estimate_text_tokens,
    reduction_pct,
)
from browserdelta.schemas import PageState


def test_token_estimates_and_reduction(tmp_path: Path):
    screenshot = tmp_path / "screen.png"
    Image.new("RGB", (640, 480), "white").save(screenshot)
    state = PageState(url="https://example.com", title="Example", text=["hello world"])

    text_tokens = estimate_text_tokens("short observation")
    image_tokens = estimate_image_tokens(screenshot)
    baseline = estimate_raw_baseline_tokens(state, screenshot)

    assert text_tokens > 0
    assert image_tokens > 0
    assert baseline > image_tokens
    assert reduction_pct(baseline, text_tokens) > 50

```

[56 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]