# Project export: Organic Clusters (OG)

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: TreeHacks 2026
- Tagline: The only way you'll go through all those bookmarks and images you've saved.
- Devpost: https://devpost.com/software/organize-stuff
- GitHub: https://github.com/Kalamojo/organize_stuff
- Demo: https://organize-stuff.vercel.app/
- Video: https://www.youtube.com/embed/crfvNrNsLls?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Kalamojo (85 commits)

## Devpost submission (written by the team)

### Inspiration

When I say I bookmark everything, I mean everything. If I ever find something shocking, awesome, hilarious, drippy, or what have you, I almost always have the primal urge to bottle that moment and try to save it for later. As a result of these tendencies, however, I have a bottomless store of bookmarks and screenshots that have sat collecting dust for nearly a decade. I always want to go through and relive those moments, but the task of sorting through everything has always been so daunting, which is why I bring you today's product.

### What it does

Organic Clustering (OG) brings an automatic bookmark and image organization tool, while having the adaptability of learning from user input simultaneously. It will categorize items as it sees fit, until the user makes a move. Each change the user makes is directly logged in the model's reward system, and this immediate learning allows it to instantly adapt and start clustering items in a way the user prefers.

### How we built it

At its core, it is a contextual bandit algorithm that has each existing cluster (and the option of a new one) as its arms. When a user changes some cluster/category assignment, the model is penalized and learns this change. It also comes with preexisting configurations to make intelligent similarity-based clustering right out of the box. Each image and webpage is embedded through the CLIP embedding model, and this is served to the user through an interactive react flow UI.

### Challenges we ran into

Many, namely deployment related issues. I'll certainly tackle these after the hackathon though!

## README (from the GitHub repository)

Howdy



## Detected evidence (automated analysis)

Indexed codebase: 25 recognized source files, 491 KB.
- CSS (language) — 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
- Vercel (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (55 of 55)

```
.gitattributes
.gitignore
.python-version
backend/.gitignore
backend/clip_model/tokenizer/tokenizer_config.json
backend/clip_model/tokenizer/tokenizer.json
backend/main.py
backend/requirements-build.txt
backend/requirements.txt
build_all.sh
dash_app.py
exploration/bandit_explore.ipynb
exploration/clustering_simulation_refined.ipynb
exploration/clustering_simulation.ipynb
frontend/.gitignore
frontend/index.html
frontend/package.json
frontend/src/animations.css
frontend/src/api.ts
frontend/src/App.tsx
frontend/src/ClusterGroup.tsx
frontend/src/index.css
frontend/src/ItemNode.tsx
frontend/src/main.tsx
frontend/src/types.ts
frontend/src/vite-env.d.ts
frontend/tsconfig.json
frontend/tsconfig.node.json
frontend/vite.config.ts
huggingface_upload.py
package.json
README.md
requirements_other_stuff.txt
scripts/prepare_model.py
streamlit_app.py
ts-worker/.editorconfig
ts-worker/.gitignore
ts-worker/.prettierrc
ts-worker/.vscode/settings.json
ts-worker/package.json
ts-worker/src/index.ts
ts-worker/test/env.d.ts
ts-worker/test/index.spec.ts
ts-worker/test/tsconfig.json
ts-worker/tsconfig.json
ts-worker/vitest.config.mts
ts-worker/worker-configuration.d.ts
ts-worker/wrangler.jsonc
vercel.json
worker/.gitignore
worker/pyproject.toml
worker/src/cluster_manager.py
worker/src/index.py
worker/src/utils.py
worker/wrangler.toml
```

### Dependencies

- backend/requirements.txt: beautifulsoup4, fastapi[standard-no-fastapi-cloud-cli], huggingface-hub, numpy, onnxruntime, pillow, requests, tokenizers
- frontend/package.json: @types/react@^18.2.0, @types/react-dom@^18.2.0, @vitejs/plugin-react@^4.2.0, axios@^1.6.0, react@^18.2.0, react-dom@^18.2.0, reactflow@^11.10.0, typescript@^5.3.0, vite@^5.0.0
- ts-worker/package.json: @cloudflare/vitest-pool-workers@^0.12.4, @types/node@^25.2.3, @vowpalwabbit/vowpalwabbit@^0.0.8, typescript@^5.5.2, vitest@~3.2.0, wrangler@^4.65.0
- worker/pyproject.toml: numpy, vowpal-wabbit-next

### Recent commits (newest first)

- Latest worker changes
- Just giving a backup
- How it was
- No public
- Correct backend dir
- Reverting
- Idk but aight
- Another try
- assets needed
- Testing vercel changes
- Redeploy ig
- CORS test
- disabling warning
- Try-all catch
- Setting home env
- Redeploy
- Disabling huggingface download stuff
- Not using clip folder
- Addressing env issue
- Upload all together

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

### package.json

```
{
  "scripts": {
    "build": "npm run build:frontend",
    "build:frontend": "echo '--- Building frontend ---' && cd frontend && npm install && npm run build && cd .. && echo '--- Moving frontend output to /public ---' && mv frontend/dist public"
  }
}

```

### backend/requirements.txt

```
fastapi[standard-no-fastapi-cloud-cli]
onnxruntime
numpy
pillow
requests
beautifulsoup4
tokenizers
huggingface-hub

```

### worker/pyproject.toml

```
[project]
name = "organize-stuff-clustering"
version = "0.1.0"
description = "Vowpal Wabbit Clustering Worker"
requires-python = ">=3.12"
dependencies = [
    "numpy",
    "vowpal-wabbit-next"
]

[dependency-groups]
dev = [
    "workers-py",
    "workers-runtime-sdk"
]

```

### ts-worker/package.json

```
{
	"name": "ts-worker",
	"version": "0.0.0",
	"private": true,
	"scripts": {
		"deploy": "wrangler deploy",
		"dev": "wrangler dev",
		"start": "wrangler dev",
		"test": "vitest",
		"cf-typegen": "wrangler types"
	},
	"devDependencies": {
		"@cloudflare/vitest-pool-workers": "^0.12.4",
		"@types/node": "^25.2.3",
		"typescript": "^5.5.2",
		"vitest": "~3.2.0",
		"wrangler": "^4.65.0"
	},
	"dependencies": {
		"@vowpalwabbit/vowpalwabbit": "^0.0.8"
	}
}

```

### frontend/package.json

```
{
  "name": "organic-clustering-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "reactflow": "^11.10.0",
    "axios": "^1.6.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.2.0",
    "typescript": "^5.3.0",
    "vite": "^5.0.0"
  }
}

```

### backend/main.py

```python
import os

# Put *all* HF caches in /tmp (writable on Vercel)
os.environ["HF_HOME"] = "/tmp/huggingface"
os.environ["HF_HUB_CACHE"] = "/tmp/huggingface/hub"
os.environ["HF_XET_CACHE"] = "/tmp/huggingface/xet"
os.environ["XDG_CACHE_HOME"] = "/tmp"

# Try to disable Xet (see note below about version quirks)
os.environ["HF_HUB_DISABLE_XET"] = "1"

# Make sure dirs exist (prevents some “can’t write logs” failures)
os.makedirs(os.environ["HF_HOME"], exist_ok=True)
os.makedirs(os.environ["HF_HUB_CACHE"], exist_ok=True)
os.makedirs(os.environ["HF_XET_CACHE"], exist_ok=True)


# put this at the very top, before any HF imports
from huggingface_hub.utils import logging as hf_logging
hf_logging.set_verbosity_error()  # or set_verbosity_warning()

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Optional
import numpy as np
import requests
from io import BytesIO
from PIL import Image
import onnxruntime as ort
from tokenizers import Tokenizer
from bs4 import BeautifulSoup
from huggingface_hub import hf_hub_download

# --- Model Paths ---
# MODEL_DIR = os.path.join("api", "clip_model")
# TOKENIZER_PATH = os.path.join(MODEL_DIR, "tokenizer", "tokenizer.json")
# VISION_MODEL_PATH = os.path.join(MODEL_DIR, "clip_vision_quantized.onnx")
# TEXT_MODEL_PATH = os.path.join(MODEL_DIR, "clip_text_quantized.onnx")

def load_model_from_hf(filename):
    # Path where Vercel allows writing
    target_path = f"/tmp/{filename}"
    
    if not os.path.exists(target_path):
        print(f"Downloading {filename} from Hugging Face...")
        hf_hub_download(
            repo_id="Kalamojo/cluster-bandits",
            filename=filename,
            local_dir="/tmp",
            token=os.environ.get("HF_TOKEN") # Use a Vercel Env Var for private repos
        )
    return target_path

# Usage in your initialization logic
TOKENIZER_PATH = load_model_from_hf("tokenizer.json")
VISION_MODEL_PATH = load_model_from_hf("clip_vision_quantized.onnx")
TEXT_MODEL_PATH = load_model_from_hf("clip_text_quantized.onnx")

# --- CLIP Image Normalization Constants ---
CLIP_IMAGE_MEAN = np.array([0.48145466, 0.4578275, 0.40821073], dtype=np.float32)
CLIP_IMAGE_STD = np.array([0.26862954, 0.26130258, 0.27577711], dtype=np.float32)

# --- FastAPI App ---
app = FastAPI(title="Embedding API")

# CORS
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# --- Model and Tokenizer Loading ---
print("🔄 Loading ONNX models and tokenizer...")
vision_session = ort.InferenceSession(VISION_MODEL_PATH)
text_session = ort.InferenceSession(TEXT_MODEL_PATH)
tokenizer = Tokenizer.from_file(TOKENIZER_PATH)
# Set the truncation and padding parameters that were in the original tokenizer config
tokenizer.enable_truncation(max_length=77)
tokenizer.enable_padding(pad_id=0, pad_token="<|endoftext|>", length=77)
print("✅ Models and tokenizer loaded.")

# --- Pydantic Models ---
class Item(BaseModel):
    id: int
    features: List[float]
    metadata: str
    image_url: Optional[str] = None
    full_embedding: Optional[List[float]] = None
    url: Optional[str] = None

class ImageEmbedRequest(BaseModel):
    image_url: str
    metadata: Optional[str] = None

class UrlEmbedRequest(BaseModel):
    url: str
    metadata: Optional[str] = None

# --- Helper Functions ---
def get_text_from_html(html_content: str) -> str:
    soup = BeautifulSoup(html_content, 'html.parser')
    # Remove script and style elements
    for script_or_style in soup(["script", "style"]):
        script_or_style.decompose()
    # Get text
    text = soup.get_text()
    # Break into lines and remove leading/trailing space on each
    lines = (line.strip() for line in text.splitlines())
    # Break multi-headlines into a line each
    chunks = (phrase.strip() for line in lines for phrase in line.split("  "))
    # Drop blank lines
    text = '\n'.join(chunk for chunk in chunks if chunk)
    return text

# --- API Endpoints ---
@app.get("/")
async def root():
    return {"message": "Embedding API", "docs": "/docs"}

@app.post("/api/embed_image", response_model=Item)
async def embed_image(request: ImageEmbedRequest):
    """Embed an image using the quantized ONNX CLIP vision model"""
    try:
        response = requests.get(request.image_url, timeout=10)
        response.raise_for_status()
        image = Image.open(BytesIO(response.content)).convert('RGB')
        
        image = image.resize((224, 224), Image.BICUBIC)
        image = np.array(image, dtype=np.float32) / np.float32(255.0)
        image = (image - CLIP_IMAGE_MEAN) / CLIP_IMAGE_STD
        image = image.transpose(2, 0, 1)
        image_tensor = np.expand_dims(image, axis=0).astype(np.float32)

        ort_inputs = {vision_session.get_inputs()[0].name: image_tensor}
        ort_outs = vision_session.run(None, ort_inputs)
        image_features = ort_outs[0]
        
        norm = np.linalg.norm(image_features, axis=1, keepdims=True)
        full_embedding = (image_features / norm).flatten()

        print(f"📸 Embedded image: {request.image_url[:60]}... → {full_embedding.shape}")

        return Item(
            id=-1,
            features=[0.0, 0.0],
            metadata=request.metadata or f"Image: {request.image_url[:30]}...",
            image_url=request.image_url,
            full_embedding=full_embedding.tolist()
        )
    except Exception as e:
        print(f"❌ Error embedding image: {str(e)}")
        raise HTTPException(status_code=500, detail=f"Failed to embed image: {str(e)}")

@app.post("/api/embed_url", response_model=Item)
async def embed_url(request: UrlEmbedRequest):
    """Embed a URL's text content using the quantized ONNX CLIP text model"""
    try:
        # 1. Fetch and parse HTML
        response = requests.get(request.url, timeout=10)
        response.raise_for_status()
        page_text = get_text_from_html(res
[truncated — 1062 more characters]
```

### frontend/src/main.tsx

```typescript
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

```

### worker/src/index.py

```python
import json
from typing import Dict, List, Any
import numpy as np
import vowpal_wabbit_next as vw
from cluster_manager import ClusterManager
from utils import predict_cluster, apply_human_correction, full_cluster_propagation_dash

async def handle(request):
    if request.method == 'OPTIONS':
        return response_with_cors(json.dumps({}))

    try:
        body = await request.json()
        action = body.get("action")
        state = body.get("state", {})
        
        # NOTE: The VW model state is NOT persisted between requests.
        # For a real application, you would serialize/deserialize the model 
        # from a KV store here.
        # Example:
        # vw_model_data = await env.KV_STORE.get("vw_model")
        # workspace = vw.Workspace(..., model_data=vw_model_data)
        workspace = vw.Workspace(["--cb_explore_adf", "--epsilon", "0.2", "--learning_rate", "0.5", "--power_t", "0"])
        parser = vw.TextFormatParser(workspace)
        cm = ClusterManager().from_dict(state.get("cm"))
        items = state.get("items", {})

        if action == "GET_ITEMS":
            # In this new setup, the worker is the source of truth for clustered items
            # So we just return the state
            pass

        elif action == "CLUSTER_ITEM":
            item = body["item"]
            item_id = item["id"]
            embedding = np.array(item["full_embedding"])
            
            # This will predict a cluster and update the cluster manager
            chosen_action, _, _, _, _, _ = predict_cluster(workspace, parser, cm, item_id, embedding, learn=True)
            
            items[str(item_id)] = {
                'id': item_id,
                'features': item.get('features', [0.0, 0.0]),
                'metadata': item['metadata'],
                'image_url': item.get('image_url'),
                'full_embedding': embedding.tolist(),
                'cluster': chosen_action['id']
            }


        elif action == "APPLY_CORRECTION":
            item_id = body["item_id"]
            target_cluster = body["target_cluster"]
            
            # This will learn the correction and update the cluster manager
            correct_cluster_id = apply_human_correction(workspace, parser, cm, item_id, target_cluster)
            items[str(item_id)]["cluster"] = correct_cluster_id

        elif action == "RESET":
            cm = ClusterManager()
            items = {}

        else:
            return response_with_cors(json.dumps({"error": "Invalid action"}), status=400)

        # NOTE: Persist the VW model state for true learning.
        # Example:
        # vw_model_data = workspace.get_model()
        # await env.KV_STORE.put("vw_model", vw_model_data)
        
        # The frontend will now be responsible for storing and sending the state
        response_data = {
            "cm": cm.to_dict(),
            "items": items
        }
        
        return response_with_cors(json.dumps(response_data))

    except Exception as e:
        import traceback
        return response_with_cors(json.dumps({"error": str(e), "trace": traceback.format_exc()}), status=500)

def response_with_cors(data, status=200):
    headers = {
        "Content-Type": "application/json",
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type",
    }
    return (data, status, headers)

async def on_fetch(request, env):
    return await handle(request)

```

### ts-worker/src/index.ts

```typescript
import VW from "@vowpalwabbit/vowpalwabbit";
console.log("VW:", VW);

const corsHeaders: Record<string, string> = {
  "Content-Type": "application/json",
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS, GET",
  "Access-Control-Allow-Headers": "Content-Type",
};

let loggedOnce = false;

function resolveWorkspaceCtorOrFactory(): any {
  // VW may be:
  // - the constructor/factory itself
  // - a CJS default wrapper (VW.default)
  // - an object with Workspace
  const candidate =
    (VW as any)?.Workspace ??
    (VW as any)?.default?.Workspace ??
    (VW as any)?.default ??
    VW;

  return candidate;
}

function createVW(args: string[]) {
  const WorkspaceOrFactory = resolveWorkspaceCtorOrFactory();

  if (!WorkspaceOrFactory) {
    throw new Error("VW export is empty/undefined");
  }

  // If it looks like a class (has prototype.predict), use `new`.
  if (WorkspaceOrFactory.prototype && typeof WorkspaceOrFactory.prototype.predict === "function") {
    return new WorkspaceOrFactory(args);
  }

  // Otherwise assume it's a factory function.
  if (typeof WorkspaceOrFactory === "function") {
    return WorkspaceOrFactory(args);
  }

  throw new Error(
    `VW export is not callable/constructible (type=${typeof WorkspaceOrFactory})`
  );
}

export default {
  async fetch(request: Request): Promise<Response> {
    // CORS preflight
    if (request.method === "OPTIONS") {
      return new Response(null, { headers: corsHeaders });
    }

    // Health check (also prevents Wrangler GET / from producing JSON parse errors)
    if (request.method === "GET") {
      if (!loggedOnce) {
        loggedOnce = true;
        console.log("VW typeof:", typeof VW);
        console.log("VW keys:", VW && typeof VW === "object" ? Object.keys(VW as any) : []);
        console.log("VW.default typeof:", typeof (VW as any)?.default);
      }
      return new Response(JSON.stringify({ ok: true }), { status: 200, headers: corsHeaders });
    }

    // Only POST is supported for actions
    if (request.method !== "POST") {
      return new Response(JSON.stringify({ error: "Method not allowed" }), {
        status: 405,
        headers: corsHeaders,
      });
    }

    const ct = request.headers.get("content-type") || "";
    if (!ct.includes("application/json")) {
      return new Response(JSON.stringify({ error: "Expected application/json" }), {
        status: 415,
        headers: corsHeaders,
      });
    }

    try {
      const body: any = await request.json();
      const { action, state = {} } = body;

      if (!action) {
        return new Response(JSON.stringify({ error: "Missing 'action'" }), {
          status: 400,
          headers: corsHeaders,
        });
      }

      // Initialize VW instance
      const vw = createVW([
        "--cb_explore_adf",
        "--epsilon",
        "0.2",
        "--learning_rate",
        "0.5",
        "--power_t",
        "0",
      ]);

      let items = state.items || {};
      let cm = state.cm || { clusters: {} };

      if (action === "CLUSTER_ITEM") {
        const item = body.item;
        if (!item?.full_embedding || !Array.isArray(item.full_embedding)) {
          throw new Error("Missing item.full_embedding (expected number[])");
        }

        const embedding: number[] = item.full_embedding;

        const adf_examples = [
          "shared | s_features " + embedding.join(" "),
          "| a_action_1",
          "| a_action_2",
        ];

        const prediction = vw.predict(adf_examples);
        vw.learn(adf_examples);

        items[item.id] = {
          ...item,
          cluster: prediction?.[0]?.action ?? 0,
        };
      }

      if (typeof vw.delete === "function") vw.delete();

      return new Response(JSON.stringify({ items, cm }), { status: 200, headers: corsHeaders });
    } catch (err: any) {
      console.error("Worker error:", err);
      return new Response(JSON.stringify({ error: err?.message || String(err) }), {
        status: 500,
        headers: corsHeaders,
      });
    }
  },
};

```

### frontend/src/App.tsx

```typescript
import { useCallback, useState } from 'react';
import ReactFlow, {
  Node,
  Controls,
  Background,
  useNodesState,
  useEdgesState,
  NodeTypes,
  NodeDragHandler,
  Panel,
} from 'reactflow';
import 'reactflow/dist/style.css';
import './animations.css';
import { api } from './api';
import type { Item } from './types';
import ItemNode from './ItemNode';
import ClusterGroup from './ClusterGroup';

const nodeTypes: NodeTypes = {
  item: ItemNode,
  cluster: ClusterGroup,
};

// Colors for clusters
const CLUSTER_COLORS = [
  '#8dd3c7', '#ffffb3', '#bebada', '#fb8072', '#80b1d3',
  '#fdb462', '#b3de69', '#fccde5', '#d9d9d9', '#bc80bd'
];

function App() {
  const [nodes, setNodes, onNodesChange] = useNodesState([]);
  const [edges, , onEdgesChange] = useEdgesState([]);
  const [items, setItems] = useState<Record<string, Item>>({});
  const [cmState, setCmState] = useState<any>({});
  const [draggedNode, setDraggedNode] = useState<Node | null>(null);
  const [dragStartPos, setDragStartPos] = useState<{ x: number; y: number } | null>(null);
  const [imageUrl, setImageUrl] = useState<string>('');
  const [isAddingImage, setIsAddingImage] = useState(false);
  const [bookmarkUrl, setBookmarkUrl] = useState<string>('');
  const [isAddingUrl, setIsAddingUrl] = useState(false);
  const [isImporting, setIsImporting] = useState(false);
  const [changedItems] = useState<Set<number>>(new Set());
  const [isPropagating, setIsPropagating] = useState(false);

  const updateUIFromState = (newItems: Record<string, Item>, newCmState: any) => {
    const itemArray = Object.values(newItems);
    const clusterSizes = Object.values(newCmState.clusters || {}).reduce((acc: Record<string, number>, c: any) => {
        acc[c.id] = c.size;
        return acc;
    }, {});
    setItems(newItems);
    setCmState(newCmState);
    updateNodesFromItems(itemArray, clusterSizes);
  }

  // Convert items to React Flow nodes
  const updateNodesFromItems = (items: Item[], clusterSizes: Record<string, number>) => {
    const clusterNodes: Node[] = [];
    const itemNodes: Node[] = [];
    const clusterPositions: Record<string, { x: number; y: number; count: number }> = {};

    // Create cluster group nodes with better spacing
    Object.keys(clusterSizes).forEach((clusterId, index) => {
      const x = (index % 3) * 450;
      const y = Math.floor(index / 3) * 450;
      const color = CLUSTER_COLORS[index % CLUSTER_COLORS.length];

      clusterPositions[clusterId] = { x, y, count: 0 };

      // Dynamic cluster size based on number of items
      const itemCount = clusterSizes[clusterId];
      const minSize = 350;
      const sizeIncrement = 40; // Add 40px for every 4 items
      const dynamicSize = Math.max(minSize, minSize + Math.floor(itemCount / 4) * sizeIncrement);

      clusterNodes.push({
        id: clusterId,
        type: 'cluster',
        position: { x, y },
        data: {
          label: clusterId.replace('_', ' ').toUpperCase(),
          size: clusterSizes[clusterId],
          color,
        },
        style: {
          width: dynamicSize,
          height: dynamicSize,
          backgroundColor: color + '20',
          border: `3px solid ${color}`,
          borderRadius: '15px',
          padding: '20px',
          transition: 'all 0.4s ease-in-out',
        },
        draggable: true,
        selectable: true,
      });
    });

    // Create item nodes inside clusters
    items.forEach((item) => {
      const clusterPos = clusterPositions[item.cluster];
      if (!clusterPos) return;

      // Calculate dynamic items per row based on cluster size
      const itemCount = clusterSizes[item.cluster];
      const minSize = 350;
      const sizeIncrement = 40;
      const clusterSize = Math.max(minSize, minSize + Math.floor(itemCount / 4) * sizeIncrement);
      const itemSpacing = 100; // Increased for 90px items
      const clusterPadding = 100; // Left/right padding
      const itemsPerRow = Math.max(3, Math.floor((clusterSize - clusterPadding) / itemSpacing));

      const row = Math.floor(clusterPos.count / itemsPerRow);
      const col = clusterPos.count % itemsPerRow;

      // Add deterministic offset based on item ID to prevent exact stacking
      const offsetX = ((item.id * 7) % 10) - 5;
      const offsetY = ((item.id * 13) % 10) - 5;

      itemNodes.push({
        id: String(item.id),
        type: 'item',
        position: {
          x: 50 + col * 100 + offsetX,
          y: 80 + row * 100 + offsetY,
        },
        data: {
          label: item.metadata || `#${item.id}`,
          cluster: item.cluster,
          image_url: item.image_url,
          url: item.url,
          isChanging: changedItems.has(item.id),
        },
        draggable: true,
        parentNode: item.cluster,
        style: {
          transition: 'all 0.5s ease-in-out',
        },
      });

      clusterPos.count++;
    });

    setNodes([...clusterNodes, ...itemNodes]);
  };

  // Handle adding image item
  const handleAddImage = async () => {
    if (!imageUrl.trim()) {
      alert('Please enter an image URL');
      return;
    }

    setIsAddingImage(true);
    try {
      const embeddedItem = await api.embedImage(imageUrl);
      embeddedItem.id = Date.now();
      const newState = await api.postToWorker('CLUSTER_ITEM', {
        state: { cm: cmState, items },
        item: embeddedItem,
      });
      updateUIFromState(newState.items, newState.cm);
      setImageUrl('');
    } catch (error) {
      console.error('Failed to add image:', error);
      alert('Failed to add image. Make sure the URL is valid.');
    } finally {
      setIsAddingImage(false);
    }
  };

  // Handle adding URL/bookmark
  const handleAddUrl = async () => {
    if (!bookmarkUrl.trim()) {
      alert('Please enter a URL');
      return;
    }

    setIsAddingUrl(true);
    try {
      const embeddedItem = await api.embedUrl(bookmarkUrl);
      embeddedItem.id = Date.now();
      const newState = await api.postToWorker('CLUSTER_IT
[truncated — 12085 more characters]
```

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