"""
Membot — Brain Cartridge Server for AI Agents
==============================================
MCP server that gives AI agents swappable, physics-enhanced memory.
Built on the Vector+ Lattice Engine.

Architecture:
  - SentenceTransformer (nomic-ai/nomic-embed-text-v1.5) for 768-dim embeddings
  - Sign-zero Hamming search: 70% embedding cosine + 30% Hamming similarity on binary codes
  - Binary corpus: sign_zero encoding (bit_i = 1 if embedding_i > 0), 768 bits = 96 bytes/pattern
  - Keyword reranking on top of blended scores
  - GPU/lattice available for recall but not used for search ranking
  - Compatible with Vector Plus Studio v8.3 cartridge format (.npz/.pkl)

Security:
  - New cartridges saved as NPZ (no code execution risk)
  - Legacy .pkl loading restricted to trusted directories only
  - SHA256 manifest for cartridge integrity verification
  - Cartridge name sanitization (no path traversal)
  - Resource caps (max entries, max text length)

Tools:
  list_cartridges  — Browse available brain cartridges
  mount_cartridge  — Load a cartridge into memory
  memory_search    — Semantic search across mounted cartridge
  memory_store     — Store new text into current cartridge
  save_cartridge   — Persist current cartridge to disk
  unmount          — Free memory and unload cartridge
  get_status       — Server diagnostics

Transport:
  stdio  — Local pipe (OpenClaw, Claude Desktop)
  http   — Streamable HTTP (remote agents, Claude Code)

Usage:
  python membot_server.py                              # stdio (default)
  python membot_server.py --transport http --port 8000  # HTTP server
  MEMBOT_API_KEY=secret python membot_server.py --transport http  # HTTP + auth

See README.md for agent configuration.

https://github.com/project-you-apps/membot
"""

from fastmcp import FastMCP
import os
import sys
import re
import struct
import hashlib
import pickle
import sqlite3
import zlib
import time
import base64
import json
import logging
import argparse
import collections
import math
import numpy as np

# --- Logging (stderr only — stdout is reserved for MCP JSON-RPC) ---
logging.basicConfig(
    level=logging.INFO,
    format="[%(asctime)s] %(message)s",
    datefmt="%H:%M:%S",
    stream=sys.stderr,
)
log = logging.getLogger("membot")

# Also log to file for post-mortem debugging
_file_handler = logging.FileHandler(
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "membot.log"),
    mode="w",
    encoding="utf-8",
)
_file_handler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", "%H:%M:%S"))
log.addHandler(_file_handler)

# --- Config ---
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
CARTRIDGE_DIRS = [
    os.path.join(BASE_DIR, "cartridges"),
    os.path.join(BASE_DIR, "data"),
    os.path.join(BASE_DIR, "..", "vector-benchmark-demo", "cuda", "webgpu", "cartridges"),
    os.path.join(BASE_DIR, "..", "vector-benchmark-demo", "cuda", "cartridges"),
    os.path.join(BASE_DIR, "..", "vector-benchmark-demo", "cuda", "self_contained_cart_test"),
]
# Mempack storage: per-user writable carts at cartridges/users/<owner_id>/<name>.cart.npz.
# Walked recursively by find_mempacks() (separate from find_cartridges which only does
# top-level CARTRIDGE_DIRS). Andy 2026-05-12 — Block F revival as Mempack storage.
MEMPACK_BASE_DIR = os.path.join(BASE_DIR, "cartridges", "users")
# UUID format pattern for owner_id directory names (defense in depth — Supabase UUIDs).
_UUID_RE = re.compile(r"^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$")
HAMMING_BLEND = 0.3           # 70% cosine + 30% sign_zero Hamming (replaces physics L2)
RECENCY_HALF_LIFE = 86400.0   # 24h — recency weight halves per day (0 = disabled)

# --- Security Limits ---
MAX_ENTRIES = 3_000_000      # Max memories per cartridge (supports 2.4M arXiv)
MAX_TEXT_LENGTH = 10_000     # Max characters per memory_store call
MAX_QUERY_LENGTH = 2_000    # Max characters per search query
SAFE_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_\- \.]*$')


# ============================================================
# SECURITY HELPERS
# ============================================================

def sanitize_name(name: str) -> str:
    """Sanitize cartridge name: strip path separators, validate characters."""
    name = os.path.basename(name)
    name = name.replace("..", "").replace("/", "").replace("\\", "")
    if not name or not SAFE_NAME_PATTERN.match(name):
        raise ValueError(f"Invalid cartridge name: '{name}'. Use alphanumeric, hyphens, underscores, spaces, dots only.")
    return name


def compute_fingerprint(embeddings: np.ndarray, n_texts: int) -> str:
    """Compute SHA256 fingerprint from embedding content + count."""
    h = hashlib.sha256()
    if embeddings is not None and len(embeddings) > 0:
        h.update(embeddings[0].tobytes())
        h.update(embeddings[-1].tobytes())
    h.update(str(n_texts).encode())
    return h.hexdigest()[:16]


# Pattern 0 v2 permission helpers (Andy 2026-05-12).
# Permission semantics from spec docs/RFC/pattern-0-v2-spec.md:
#   r = read (search the cart)        bit 0 = 1
#   w = write (add/edit patterns)     bit 1 = 2
#   d = delete (tombstone patterns)   bit 2 = 4
#   a = admin (manage perms/delete)   bit 3 = 8
# Stored in the manifest as a readable string ("rw", "rwda", "" for none).
_PERM_BITS = {"r": 1, "w": 2, "d": 4, "a": 8}


def parse_perms(perm_str: str | None) -> int:
    """Convert a permission string like 'rwd' into a bitmask. Empty/None = 0."""
    if not perm_str:
        return 0
    mask = 0
    for ch in perm_str.lower():
        mask |= _PERM_BITS.get(ch, 0)
    return mask


def format_perms(mask: int) -> str:
    """Convert a permission bitmask back into a string like 'rwd'."""
    return "".join(ch for ch, bit in _PERM_BITS.items() if mask & bit)


# Pattern 0 v2 cart-type marker (Andy 2026-05-12). Mempack-shaped carts have
# cart_type="agent-memory" and reserve pattern index 1 ("Pattern I") for the
# agent's behavioral instructions. Other types are treated as knowledge carts.
CART_TYPE_KNOWLEDGE = "knowledge"
CART_TYPE_AGENT_MEMORY = "agent-memory"
PATTERN_I_IDX = 1  # The reserved slot for Pattern I in agent-memory carts


def save_manifest(cart_path: str, embeddings: np.ndarray, n_texts: int,
                  briefing: str | None = None,
                  owner_id: str | None = None,
                  owner_perms: str | None = None,
                  group_perms: str | None = None,
                  world_perms: str | None = None,
                  group_id: str | None = None,
                  max_patterns: int | None = None,
                  cart_type: str | None = None):
    """Save integrity manifest alongside cartridge.

    Pattern 0 v2 fields (Andy 2026-05-12) — see docs/RFC/pattern-0-v2-spec.md:
      briefing      — UTF-8 text presented to agents on mount (Phase 1)
      owner_id      — agent or user ID that owns this cart (Phase 2)
      owner_perms   — string of {r,w,d,a} flags for the owner
      group_perms   — flags for group members (if group_id set)
      world_perms   — flags for everyone else
      group_id      — team/org identifier; pairs with group_perms
      max_patterns  — capacity limit (0 or omitted = unlimited)

    All Pattern 0 v2 fields are optional and backward-compatible. Carts without
    them mount with world_perms=r (treat legacy carts as publicly readable) so
    existing flows keep working unchanged.
    """
    manifest_path = cart_path.rsplit(".", 1)[0] + "_manifest.json"
    manifest = {
        "version": "mcp-v3",
        "count": n_texts,
        "fingerprint": compute_fingerprint(embeddings, n_texts),
        "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
    }
    # Optional Pattern 0 v2 fields — only emit when caller supplied them so legacy
    # manifests stay minimal.
    if briefing:
        manifest["briefing"] = briefing
    if owner_id is not None:
        manifest["owner_id"] = owner_id
    if owner_perms is not None:
        manifest["owner_perms"] = owner_perms
    if group_perms is not None:
        manifest["group_perms"] = group_perms
    if world_perms is not None:
        manifest["world_perms"] = world_perms
    if group_id is not None:
        manifest["group_id"] = group_id
    if max_patterns is not None:
        manifest["max_patterns"] = int(max_patterns)
    if cart_type is not None:
        manifest["cart_type"] = cart_type

    with open(manifest_path, "w") as f:
        json.dump(manifest, f, indent=2)
    extra = []
    if briefing:
        extra.append(f"briefing({len(briefing)})")
    if owner_id is not None:
        extra.append(f"owner={owner_id}")
    extra_str = (" +" + " +".join(extra)) if extra else ""
    log.info(f"Manifest saved: {manifest['fingerprint']} ({n_texts} entries){extra_str}")
    return manifest


def load_manifest(cart_path: str) -> dict | None:
    """Read the manifest sidecar for a cart and return as dict, or None if missing/malformed.

    Used to extract Pattern 0 fields (briefing, eventually ownership, capabilities)
    on cart mount. Does NOT verify integrity — that's verify_manifest's job. This
    one is for reading metadata once integrity is established.
    """
    manifest_path = cart_path.rsplit(".", 1)[0] + "_manifest.json"
    if not os.path.exists(manifest_path):
        return None
    try:
        with open(manifest_path, "r", encoding="utf-8") as f:
            return json.load(f)
    except Exception as e:
        log.warning(f"manifest read error for {cart_path}: {e}")
        return None


def verify_manifest(cart_path: str, embeddings: np.ndarray, n_texts: int) -> tuple[bool, str]:
    """Verify cartridge against its manifest. Returns (ok, message)."""
    manifest_path = cart_path.rsplit(".", 1)[0] + "_manifest.json"
    if not os.path.exists(manifest_path):
        return True, "no manifest (legacy cartridge)"

    try:
        with open(manifest_path, "r") as f:
            manifest = json.load(f)

        expected_fp = manifest.get("fingerprint", "")
        actual_fp = compute_fingerprint(embeddings, n_texts)

        if expected_fp != actual_fp:
            return False, f"FINGERPRINT MISMATCH: expected {expected_fp}, got {actual_fp}"

        expected_count = manifest.get("count", -1)
        if expected_count != n_texts:
            return False, f"COUNT MISMATCH: expected {expected_count}, got {n_texts}"

        return True, f"verified ({actual_fp})"
    except Exception as e:
        return False, f"manifest read error: {e}"


def is_trusted_directory(path: str) -> bool:
    """Check if a file is within one of the trusted cartridge directories."""
    real_path = os.path.realpath(path)
    for d in CARTRIDGE_DIRS:
        real_dir = os.path.realpath(d)
        if real_path.startswith(real_dir + os.sep) or real_path.startswith(real_dir):
            return True
    return False


# ============================================================
# STATE (per-session)
# ============================================================

SESSION_TIMEOUT_SEC = 1800   # 30 minutes idle → session expires
MAX_SESSIONS = 50            # Max concurrent sessions

def _new_session() -> dict:
    """Create a fresh session state dict."""
    return {
        "cartridge_name": None,
        "cartridge_path": None,
        "briefing": None,         # Pattern 0 v2 Phase 1 — cart's agent-facing introduction
        # Pattern 0 v2 Phase 2 — ownership/access control. Defaults preserve
        # legacy behavior: no owner, world readable, no caps.
        "owner_id": None,
        "owner_perms": "rwda",
        "group_perms": "",
        "world_perms": "r",       # legacy carts treated as world-readable
        "group_id": None,
        "max_patterns": 0,        # 0 = unlimited
        "embeddings": None,       # (N, 768) float32
        "texts": [],              # list[str]
        "binary_corpus": None,    # (N, 768) uint8 — sign_zero encoding for Hamming search
        "signatures": None,       # (N, 4096) float32 or None (legacy, not used in search)
        "hippocampus": None,      # list[dict] — per-passage metadata (prev/next/flags/etc.)
        "lattice": None,          # CUDA wrapper or None
        "gpu_available": False,
        "modified": False,        # True if memory_store was called since last save
        "last_access": time.time(),
        "created_at": time.time(),
        "query_count": 0,
        "mount_count": 0,
        "last_action": None,      # e.g. "search 'earthquake'" or "mount wiki-10k"
        "last_action_time": None,
        "sqlite_conn": None,      # sqlite3.Connection for split carts (text on disk)
        "is_split_cart": False,   # True if using index + SQLite sidecar
        "has_embeddings": False,
    }


def _sqlite_fetch_passages(conn: sqlite3.Connection, indices: list) -> dict:
    """Fetch full passages from SQLite sidecar by index. Returns {idx: passage}."""
    if not conn or not indices:
        return {}
    placeholders = ",".join("?" for _ in indices)
    rows = conn.execute(
        f"SELECT idx, passage, title, paper_id FROM passages WHERE idx IN ({placeholders})",
        indices
    ).fetchall()
    return {r[0]: {"passage": r[1], "title": r[2], "paper_id": r[3]} for r in rows}


def _soft_truncate(text: str, target_min: int = 250, hard_max: int = 550) -> str:
    """Truncate at first sentence-end past target_min, falling back to last
    word boundary, then hard cap. Preserves provenance-card aesthetic without
    mid-word cuts."""
    if len(text) <= hard_max:
        return text
    for marker in (". ", "? ", "! ", "\n"):
        idx = text.find(marker, target_min)
        if 0 < idx <= hard_max - len(marker):
            return text[: idx + len(marker)].rstrip() + " …"
    cut = text.rfind(" ", target_min, hard_max)
    if cut > target_min:
        return text[:cut].rstrip() + " …"
    return text[:hard_max].rstrip() + " …"


# --- Depot Activity Log (ring buffer) ---
DEPOT_ACTIVITY_MAX = 200
_depot_activity: collections.deque = collections.deque(maxlen=DEPOT_ACTIVITY_MAX)
_depot_start_time = time.time()


def _log_activity(session_id: str, action: str, detail: str = "", latency_ms: float = 0):
    """Append an entry to the depot activity log and update session state."""
    entry = {
        "time": time.time(),
        "session": session_id,
        "action": action,
        "detail": detail,
        "latency_ms": round(latency_ms, 1),
    }
    _depot_activity.append(entry)
    # Update session last_action
    if session_id in _sessions:
        _sessions[session_id]["last_action"] = f"{action} {detail}".strip()
        _sessions[session_id]["last_action_time"] = time.time()

# session_id → session state dict
_sessions: dict[str, dict] = {}

# Global config (not per-session)
_server_config = {
    "read_only": False,
}


def _get_session(session_id: str) -> dict:
    """Get or create a session by ID. Evicts expired sessions."""
    now = time.time()

    # Evict expired sessions
    expired = [sid for sid, s in _sessions.items()
               if now - s["last_access"] > SESSION_TIMEOUT_SEC]
    for sid in expired:
        log.info(f"Session expired: {sid} (idle {now - _sessions[sid]['last_access']:.0f}s)")
        del _sessions[sid]

    if session_id not in _sessions:
        if len(_sessions) >= MAX_SESSIONS:
            # Evict oldest session
            oldest = min(_sessions, key=lambda sid: _sessions[sid]["last_access"])
            log.info(f"Session evicted (max reached): {oldest}")
            del _sessions[oldest]
        _sessions[session_id] = _new_session()
        log.info(f"New session created: {session_id}")

    _sessions[session_id]["last_access"] = now
    return _sessions[session_id]


def _resolve_session_id(session_id: str) -> str:
    """Resolve empty session_id to 'default', return as-is otherwise."""
    return session_id.strip() if session_id.strip() else "default"


# ============================================================
# EMBEDDING — Ollama backend (resource-constrained) or SentenceTransformer
# ============================================================

_embed_model = None  # lazy-loaded SentenceTransformer (if not using Ollama)
_embed_backend = os.environ.get("MEMBOT_EMBED_BACKEND", "auto")  # "ollama", "st", or "auto"
_ollama_url = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
_ollama_embed_model = os.environ.get("MEMBOT_OLLAMA_EMBED_MODEL", "nomic-embed-text")

def _ollama_available() -> bool:
    """Check if Ollama is running and has the embedding model."""
    try:
        import urllib.request
        req = urllib.request.Request(f"{_ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=2) as resp:
            data = json.loads(resp.read())
            models = [m["name"].split(":")[0] for m in data.get("models", [])]
            return _ollama_embed_model.split(":")[0] in models
    except Exception:
        return False

def _embed_via_ollama(text: str) -> np.ndarray:
    """Get embedding from Ollama API (no extra RAM — reuses running Ollama process)."""
    import urllib.request
    payload = json.dumps({"model": _ollama_embed_model, "input": text}).encode()
    req = urllib.request.Request(
        f"{_ollama_url}/api/embed",
        data=payload,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=30) as resp:
        data = json.loads(resp.read())
        return np.array(data["embeddings"][0], dtype=np.float32)

def _get_embed_model():
    """Lazy-load SentenceTransformer (same model Studio uses to build cartridges)."""
    global _embed_model
    if _embed_model is None:
        log.info("Loading SentenceTransformer nomic-embed-text-v1.5 ...")
        from sentence_transformers import SentenceTransformer
        _embed_model = SentenceTransformer(
            "nomic-ai/nomic-embed-text-v1.5", trust_remote_code=True
        )
        log.info("SentenceTransformer ready")
    return _embed_model

def _resolve_embed_backend() -> str:
    """Resolve which embedding backend to use."""
    if _embed_backend == "ollama":
        return "ollama"
    if _embed_backend == "st":
        return "st"
    # auto: prefer Ollama if available (saves ~2GB RAM)
    if _ollama_available():
        log.info(f"Embedding backend: Ollama ({_ollama_embed_model}) — saves ~2GB RAM")
        return "ollama"
    log.info("Embedding backend: SentenceTransformer (Ollama not available)")
    return "st"

_resolved_backend = None

def embed_text(text: str, prefix: str = "search_query") -> np.ndarray:
    """Get 768-dim Nomic embedding via Ollama or SentenceTransformer."""
    global _resolved_backend
    if _resolved_backend is None:
        _resolved_backend = _resolve_embed_backend()

    if _resolved_backend == "ollama":
        return _embed_via_ollama(f"{prefix}: {text}")
    else:
        model = _get_embed_model()
        vec = model.encode(f"{prefix}: {text}", convert_to_numpy=True)
        return vec.astype(np.float32)


# ============================================================
# GPU INIT (OPTIONAL — NON-FATAL IF UNAVAILABLE)
# ============================================================

_gpu_state = {
    "lattice": None,
    "available": False,
}

def init_gpu():
    """Try to load CUDA LatticeRunner. Returns True if successful."""
    if _gpu_state["lattice"] is not None:
        return _gpu_state["available"]
    try:
        sys.path.insert(0, BASE_DIR)
        from multi_lattice_wrapper_v7 import MultiLatticeCUDAv7
        _gpu_state["lattice"] = MultiLatticeCUDAv7(lattice_size=4096, verbose=0)
        _gpu_state["available"] = True
        log.info("CUDA LatticeRunner V7 ready (4096x4096)")
        return True
    except Exception as e:
        log.warning(f"CUDA not available (CPU-only mode): {e}")
        _gpu_state["available"] = False
        return False


# ============================================================
# CARTRIDGE I/O
# ============================================================

# Local cache for Mempack blobs fetched from Supabase Storage.
# Files are downloaded on mount and overwritten on subsequent mounts.
MEMPACK_CACHE_DIR = os.path.join("/tmp", "mempack_cache")
os.makedirs(MEMPACK_CACHE_DIR, exist_ok=True)


def _supabase_available() -> bool:
    """Cheap check: is Supabase configured? Caches result to avoid repeated env hits."""
    return bool(os.environ.get("SUPABASE_URL")) and bool(os.environ.get("SUPABASE_SERVICE_ROLE_KEY"))


# ============================================================
# JWT verification — Supabase Auth integration
#
# Browser-originating REST calls carry a Supabase session cookie set by the
# central auth router on .project-you.app. Membot verifies the embedded JWT
# against SUPABASE_JWT_SECRET (HS256, aud='authenticated') to identify the
# caller for ownership/scoping checks. MCP-tool agent callers don't go
# through this path — they pass session_id explicitly.
#
# Pattern mirrors vector-plus-studio-repo/api/auth.py.
# ============================================================

_JWT_ALG = "HS256"
_JWT_AUD = "authenticated"


def _read_jwt_from_request(request) -> str | None:
    """Extract a Supabase access_token from the request, if present.

    Looks at:
    1. `Authorization: Bearer <token>` header (preferred — explicit)
    2. `sb-<projectref>-auth-token` cookie (browser cross-app SSO).
       Modern supabase-js chunks large cookie values across `.0`, `.1`, etc.
       cookies — we collect all chunks for the same base name and reassemble
       in index order before decoding.

    The (assembled) cookie value is URL-encoded base64 of a JSON object
    containing `access_token` (the JWT). Newer supabase-js prefixes the
    value with 'base64-' — we strip that. Returns the raw JWT string or None.
    """
    auth = request.headers.get("authorization") or request.headers.get("Authorization")
    if auth and auth.lower().startswith("bearer "):
        token = auth[7:].strip()
        if token:
            return token

    cookie = request.headers.get("cookie") or request.headers.get("Cookie") or ""
    if not cookie:
        return None

    # Match base name AND chunked variants: sb-<ref>-auth-token(.<digits>)?=<value>
    chunk_re = re.compile(r'(sb-[a-z0-9]+-auth-token)(?:\.(\d+))?=([^;]+)')
    matches = chunk_re.findall(cookie)
    if not matches:
        return None

    # Group by base name (in case multiple Supabase projects coexist)
    groups: dict[str, list[tuple[int, str]]] = {}
    for base, idx_str, val in matches:
        groups.setdefault(base, []).append((int(idx_str) if idx_str else -1, val))

    # Pick the base with the most chunks
    chosen_base = max(groups.keys(), key=lambda k: len(groups[k]))
    chunks = groups[chosen_base]

    # Prefer an unchunked entry (idx == -1) if present; else assemble chunks in order
    unchunked = next((v for i, v in chunks if i == -1), None)
    if unchunked is not None:
        raw_encoded = unchunked
    else:
        chunks.sort(key=lambda x: x[0])
        raw_encoded = "".join(v for _, v in chunks)

    import urllib.parse
    raw = urllib.parse.unquote(raw_encoded)
    has_b64_prefix = raw.startswith("base64-")
    stripped = raw[7:] if has_b64_prefix else raw

    # Try multiple decode paths. supabase-js cookie format has shifted across
    # versions: standard base64, URL-safe base64, base64- prefix, or plain
    # JSON. Try in order of likelihood.
    payload = None
    decode_path = None

    # Path A: base64 (standard) -> JSON
    if payload is None:
        try:
            payload = json.loads(base64.b64decode(stripped + '=' * (-len(stripped) % 4)))
            decode_path = 'b64-standard'
        except Exception:
            pass

    # Path B: base64 (URL-safe) -> JSON
    if payload is None:
        try:
            payload = json.loads(base64.urlsafe_b64decode(stripped + '=' * (-len(stripped) % 4)))
            decode_path = 'b64-urlsafe'
        except Exception:
            pass

    # Path C: plain JSON
    if payload is None:
        try:
            payload = json.loads(stripped)
            decode_path = 'plain-json'
        except Exception:
            pass

    if payload is None:
        log.warning(
            f"JWT cookie decode failed (base={chosen_base}, len={len(raw)}, "
            f"prefix={'base64-' if has_b64_prefix else 'none'}, "
            f"head={stripped[:60]!r})"
        )
        return None

    if isinstance(payload, dict):
        token = payload.get("access_token")
    elif isinstance(payload, list) and payload:
        token = payload[0]
    else:
        token = None
    if not isinstance(token, str):
        log.warning(
            f"JWT cookie payload had no access_token (decode={decode_path}, "
            f"keys={list(payload.keys()) if isinstance(payload, dict) else type(payload).__name__})"
        )
        return None
    return token


def _get_user_claims_from_request(request) -> dict | None:
    """Verify the Supabase JWT and return the full decoded claims dict, or None.

    Returns None for: anonymous request, missing/expired/invalid JWT, or when
    SUPABASE_JWT_SECRET isn't configured (fail-closed for safety).

    Useful claims typically present:
      - sub                    : user UUID
      - email                  : user email
      - user_metadata          : {avatar_url, full_name, name, picture, ...}
                                 populated from the OAuth provider for social
                                 sign-ins; mostly empty for password sign-ups.
      - app_metadata           : {provider, providers, ...}
      - aud / exp / iat / iss  : standard JWT
    """
    secret = os.environ.get("SUPABASE_JWT_SECRET")
    if not secret:
        return None
    token = _read_jwt_from_request(request)
    if not token:
        return None
    try:
        import jwt as _pyjwt
    except ImportError:
        log.error("PyJWT not installed; cannot verify Supabase tokens")
        return None
    try:
        decoded = _pyjwt.decode(token, secret, algorithms=[_JWT_ALG], audience=_JWT_AUD)
        return decoded if isinstance(decoded, dict) else None
    except Exception as e:
        log.warning(f"JWT decode failed: {type(e).__name__}: {e}")
        return None


def _get_user_id_from_request(request) -> str | None:
    """Verify the Supabase JWT and return the `sub` claim (user UUID), or None.

    Thin wrapper over _get_user_claims_from_request for the common case where
    only the user_id is needed. See _get_user_claims_from_request for the full
    claims dict (email, user_metadata, etc.).
    """
    claims = _get_user_claims_from_request(request)
    return claims.get("sub") if claims else None


def _require_authenticated(request):
    """Return (user_id, None) if authenticated, (None, JSONResponse 401) otherwise.

    Caller pattern:
        user_id, err = _require_authenticated(request)
        if err is not None: return err
    """
    from starlette.responses import JSONResponse
    user_id = _get_user_id_from_request(request)
    if not user_id:
        return None, JSONResponse(
            {"status": "error",
             "error": "Authentication required (sign in at project-you.app)"},
            status_code=401, headers=_cors_headers(),
        )
    return user_id, None


def _require_mempack_owner(request, mempack_id: str):
    """Verify the caller owns the named Mempack.

    Returns (user_id, mempack_row, None) on success.
    Returns (None, None, JSONResponse) on auth/ownership/lookup failure (401/403/404/503).
    """
    from starlette.responses import JSONResponse
    user_id, err = _require_authenticated(request)
    if err is not None:
        return None, None, err
    if not _UUID_RE.match(mempack_id):
        return None, None, JSONResponse(
            {"status": "error", "error": "mempack_id must be a UUID"},
            status_code=400, headers=_cors_headers(),
        )
    if not _supabase_available():
        return None, None, JSONResponse(
            {"status": "error", "error": "Mempack backend not configured."},
            status_code=503, headers=_cors_headers(),
        )
    import supabase_storage as sbs
    mp = sbs.get_mempack_by_id(mempack_id)
    if not mp:
        return None, None, JSONResponse(
            {"status": "error", "error": "Mempack not found"},
            status_code=404, headers=_cors_headers(),
        )
    if mp.get("user_id") != user_id:
        log.warning(
            f"OWNERSHIP DENIED: user={user_id[:8]} tried to access "
            f"mempack={mempack_id[:8]} owned by {str(mp.get('user_id'))[:8]}"
        )
        return None, None, JSONResponse(
            {"status": "error", "error": "Forbidden — you don't own this Mempack"},
            status_code=403, headers=_cors_headers(),
        )
    return user_id, mp, None


def _session_id_for_user(user_id: str | None, fallback: str = "") -> str:
    """Derive a per-user session_id so each signed-in user gets their own
    in-process RAM slot. Anonymous callers fall back to the legacy default.

    Eliminates the cross-user mount-state leak: user A's mounted Mempack
    lives in 'user_<uuid_a>' session, user B can't see it.
    """
    if user_id:
        return f"user_{user_id}"
    return fallback.strip() if fallback and fallback.strip() else "default"


def _owner_id_to_uuid_bytes(owner_id: str | None) -> np.ndarray:
    """Convert a Supabase user-UUID string to a 16-byte numpy array.

    Used to plant the Pattern 0 v2 'owner_id' field directly into the cart
    blob (npz key 'pattern_0_owner') so ownership travels WITH the cart,
    independently of any one server's database. v3 will extend this with a
    public key + signature; for now it's just the UUID claim. Loaders that
    don't recognize the field ignore it (forward/backward compatible).

    Returns a (16,) uint8 array; all-zeros for missing/invalid input.
    """
    import uuid as _uuid
    try:
        u = _uuid.UUID(owner_id) if owner_id else None
        return np.frombuffer(u.bytes if u else b'\x00' * 16, dtype=np.uint8)
    except (ValueError, AttributeError, TypeError):
        return np.zeros(16, dtype=np.uint8)


def find_mempacks(owner_id: str | None = None) -> list[dict]:
    """Return Mempacks for an owner from Supabase (cold-cut from filesystem 2026-05-13).

    Mempacks now live as rows in `public.mempacks` + blobs in Supabase Storage
    (bucket: `mempacks`). The filesystem path under MEMPACK_BASE_DIR is no
    longer authoritative; the function returns the local CACHE path where the
    blob will be downloaded on mount.

    Args:
        owner_id: Supabase user UUID. If None, returns all Mempacks (admin/debug).

    Returns: list of dicts shaped to match the previous filesystem walker:
      name, path (eventual local cache path under MEMPACK_CACHE_DIR), owner_id,
      format, size_mb, has_manifest. Plus new fields: mempack_id, storage_status,
      cart_type, pattern_count.
    """
    if owner_id is not None and not _UUID_RE.match(owner_id):
        return []  # bad input -> empty, not error

    if not _supabase_available():
        log.warning("find_mempacks: SUPABASE env vars missing; returning empty")
        return []

    try:
        import supabase_storage as sbs
    except ImportError as e:
        log.error(f"find_mempacks: supabase_storage import failed: {e}")
        return []

    try:
        if owner_id:
            rows = sbs.list_mempacks(owner_id)
        else:
            # Admin/debug: list everyone's Mempacks
            sb = sbs.get_client()
            res = sb.table("mempacks").select("*").execute()
            rows = res.data or []
    except Exception as e:
        log.error(f"find_mempacks: Supabase query failed: {e}")
        return []

    results = []
    for row in rows:
        mempack_id = row["id"]
        size_bytes = row.get("size_bytes") or 0
        manifest = row.get("manifest") or {}
        results.append({
            "name":           row["name"],
            "path":           os.path.join(MEMPACK_CACHE_DIR, f"{mempack_id}.cart.npz"),
            "owner_id":       row["user_id"],
            "format":         "npz",
            "size_mb":        round(size_bytes / (1024 * 1024), 2),
            "size_bytes":     size_bytes,
            "has_manifest":   bool(manifest),
            "id":             mempack_id,   # canonical name for the dashboard
            "mempack_id":     mempack_id,   # legacy alias for mount_cartridge etc.
            "storage_status": row.get("storage_status"),
            "cart_type":      row.get("cart_type") or CART_TYPE_AGENT_MEMORY,
            "pattern_count":  row.get("pattern_count", 0),
            "pattern_i_text": row.get("pattern_i_text") or "",
            "activity_logging_enabled": row.get("activity_logging_enabled", True),
            "created_at":     row.get("created_at"),
            "updated_at":     row.get("updated_at"),
            "last_mounted_at": row.get("last_mounted_at"),
            # Manifest fields surfaced for mount-time state population
            "briefing":       row.get("briefing") or manifest.get("briefing"),
            "owner_perms":    manifest.get("owner_perms", "rwda"),
            "group_perms":    manifest.get("group_perms", ""),
            "world_perms":    manifest.get("world_perms", ""),
            "group_id":       manifest.get("group_id"),
            "max_patterns":   int(manifest.get("max_patterns", 0) or 0),
        })
    return results


def _mempack_persist_new_pattern(state: dict, new_text: str, new_emb: np.ndarray) -> str:
    """Round-trip a newly-stored pattern back to Supabase for a Mempack.

    Called from memory_store when the mounted cart is a Supabase-backed Mempack
    (detected via state.get("mempack_id")). Repacks the full cart blob with the
    new pattern + uploads to Storage (overwrites) + inserts a new
    mempack_patterns row + updates the mempacks row's size_bytes + pattern_count.

    Returns: short status string for the memory_store result message.

    Last-writer-wins for v1: concurrent writes from two agents to the same
    Mempack will race on blob upload. Acceptable for v1; revisit with optimistic
    locking via a row version column when concurrent agent flows show up.
    """
    import io
    import struct as _struct
    import supabase_storage as sbs
    from cartridge_builder import (
        HIPPO_FORMAT, FORMAT_VERSION_CANONICAL, PERM_DEFAULT,
    )

    mempack_id = state["mempack_id"]
    owner_id = state["owner_id"]
    name = state["cartridge_name"]
    texts = state["texts"]
    embeddings = state["embeddings"]
    new_idx = len(texts) - 1  # 0-based absolute pattern_idx

    # 1. Re-pack the blob with the new pattern included.
    # Preserve pattern_0_owner so the v2 ownership flag survives every imprint.
    compressed_texts = [
        np.void(zlib.compress(t.encode("utf-8"), level=9)) for t in texts
    ]
    buf = io.BytesIO()
    np.savez_compressed(
        buf,
        embeddings=embeddings,
        passages=np.array(texts, dtype=object),
        compressed_texts=np.array(compressed_texts, dtype=object),
        version="mcp-v3",
        pattern_0_owner=_owner_id_to_uuid_bytes(owner_id),
    )
    blob_bytes = buf.getvalue()

    # 2. Upload to Supabase Storage (upsert overwrites previous blob)
    sbs.upload_blob(owner_id, name, blob_bytes)

    # 3. Synthesize canonical 12-field H-block for the new pattern.
    # Pattern N+ entries are volatile (decay-eligible) and writable by default.
    # No linked-list ptrs in v1 — single-pattern append doesn't carry that
    # info; future write path can layer in parent/child links.
    now_ts = int(time.time())
    h_block = _struct.pack(
        HIPPO_FORMAT,
        new_idx + 1,                     # pattern_id (1-based, 0 = header)
        FORMAT_VERSION_CANONICAL,
        1,                               # cartridge_type = agent-memory
        0, 0, 0,                         # parent_ptr, child_ptr, sibling_ptr
        0,                               # source_hash (synthesized, no source file)
        new_idx,                         # sequence_num
        now_ts,
        0,                               # flags: volatile, not pinned (agent content)
        PERM_DEFAULT,                    # perms_byte: R+W
        b'\x00' * 34,                    # reserved
    )

    # 4. Insert the new pattern_patterns row at absolute pattern_idx
    sbs.append_pattern_row(
        mempack_id=mempack_id,
        pattern_idx=new_idx,
        h_block_bytes=h_block,
        text=new_text,
    )

    # 5. Update the mempacks row's size_bytes + pattern_count
    sbs.update_mempack_metadata(
        mempack_id=mempack_id,
        size_bytes=len(blob_bytes),
        pattern_count=len(texts),
    )

    # 6. Update the local cache file so subsequent mounts in this same droplet
    # session pick up the new state without a Supabase fetch round-trip.
    cache_path = os.path.join(MEMPACK_CACHE_DIR, f"{mempack_id}.cart.npz")
    try:
        with open(cache_path, "wb") as f:
            f.write(blob_bytes)
    except OSError as e:
        log.warning(f"Mempack cache write failed (non-fatal): {e}")

    # 7. Activity log (best-effort — never block the imprint on log failure).
    try:
        if sbs.is_logging_enabled(mempack_id):
            sbs.append_activity(
                mempack_id=mempack_id,
                event_type="imprint",
                summary=f"imprinted pattern at idx {new_idx} ({len(new_text)} chars)",
                pattern_idx=new_idx,
                metadata={
                    "blob_bytes":  len(blob_bytes),
                    "text_length": len(new_text),
                    "preview":     (new_text or "")[:120],
                },
                agent_label=state.get("agent_label"),
            )
    except Exception as e:
        log.warning(f"Mempack activity-log append failed (non-fatal): {e}")

    return f"persisted to Supabase (mempack_id={mempack_id[:8]}, {len(blob_bytes)} bytes)"


def _mempack_persist_pattern_update(
    state: dict, idx: int, new_text: str, new_emb: np.ndarray,
    previous_text: str | None = None,
) -> str:
    """Round-trip an IN-PLACE pattern update back to Supabase.

    Mirror of _mempack_persist_new_pattern but for overwriting an existing
    pattern at a known idx (e.g. Pattern I rewrite via mempack_update_pattern_i).
    Repacks the full blob, uploads (upsert), updates the mempack_patterns row
    at (mempack_id, pattern_idx), and refreshes the mempacks row metadata
    (size_bytes, and pattern_i_text if idx==PATTERN_I_IDX).

    Last-writer-wins for v1, same as the append path.

    Returns: short status string for the calling MCP tool's result message.
    """
    import io
    import struct as _struct
    import supabase_storage as sbs
    from cartridge_builder import (
        HIPPO_FORMAT, FORMAT_VERSION_CANONICAL, PERM_DEFAULT,
    )

    mempack_id = state["mempack_id"]
    owner_id = state["owner_id"]
    name = state["cartridge_name"]
    texts = state["texts"]
    embeddings = state["embeddings"]

    # 1. Re-pack the full cart blob with the updated pattern included.
    # Preserve pattern_0_owner across in-place updates too.
    compressed_texts = [
        np.void(zlib.compress(t.encode("utf-8"), level=9)) for t in texts
    ]
    buf = io.BytesIO()
    np.savez_compressed(
        buf,
        embeddings=embeddings,
        passages=np.array(texts, dtype=object),
        compressed_texts=np.array(compressed_texts, dtype=object),
        version="mcp-v3",
        pattern_0_owner=_owner_id_to_uuid_bytes(owner_id),
    )
    blob_bytes = buf.getvalue()

    # 2. Upload to Supabase Storage (upsert overwrites previous blob)
    sbs.upload_blob(owner_id, name, blob_bytes)

    # 3. Synthesize an H-block for the updated slot. Pattern I gets PINNED
    # treatment (non-volatile); Pattern N+ updates inherit defaults.
    now_ts = int(time.time())
    h_block = _struct.pack(
        HIPPO_FORMAT,
        idx + 1,                         # pattern_id (1-based)
        FORMAT_VERSION_CANONICAL,
        1,                               # cartridge_type = agent-memory
        0, 0, 0,                         # parent/child/sibling ptrs
        0,                               # source_hash (synthesized)
        idx,                             # sequence_num
        now_ts,
        0,                               # flags
        PERM_DEFAULT,                    # perms_byte: R+W
        b'\x00' * 34,                    # reserved
    )

    # 4. Update (NOT append) the mempack_patterns row at the target idx
    updated = sbs.update_pattern_row(
        mempack_id=mempack_id,
        pattern_idx=idx,
        h_block_bytes=h_block,
        text=new_text,
    )
    if not updated:
        # The row didn't exist — fall back to append (e.g. Pattern I was
        # missing on a cart provisioned before reserved-slot semantics).
        sbs.append_pattern_row(
            mempack_id=mempack_id,
            pattern_idx=idx,
            h_block_bytes=h_block,
            text=new_text,
        )

    # 5. Update mempacks row: size_bytes always; pattern_i_text iff this is
    # the Pattern I slot (denormalized for fast list/preview reads).
    metadata_kwargs: dict = {
        "mempack_id": mempack_id,
        "size_bytes": len(blob_bytes),
    }
    if idx == PATTERN_I_IDX:
        metadata_kwargs["pattern_i_text"] = new_text
    sbs.update_mempack_metadata(**metadata_kwargs)

    # 6. Refresh local cache so subsequent same-droplet mounts skip the round-trip
    cache_path = os.path.join(MEMPACK_CACHE_DIR, f"{mempack_id}.cart.npz")
    try:
        with open(cache_path, "wb") as f:
            f.write(blob_bytes)
    except OSError as e:
        log.warning(f"Mempack cache write failed (non-fatal): {e}")

    # 7. Activity log (best-effort — never block the update on log failure).
    # Includes previous_text (capped) so the activity feed can offer recovery
    # for accidental or agent-driven overwrites. See Fix A in todo list.
    _PREV_CAP = 8000
    _prev = previous_text if isinstance(previous_text, str) else ""
    _prev_trunc = len(_prev) > _PREV_CAP
    _prev_capped = _prev[:_PREV_CAP] if _prev_trunc else _prev
    try:
        if sbs.is_logging_enabled(mempack_id):
            event_type = "pattern_i_update" if idx == PATTERN_I_IDX else "pattern_update"
            verb = "rewrote Pattern I" if idx == PATTERN_I_IDX else f"updated pattern at idx {idx}"
            sbs.append_activity(
                mempack_id=mempack_id,
                event_type=event_type,
                summary=f"{verb} ({len(new_text)} chars)",
                pattern_idx=idx,
                metadata={
                    "blob_bytes":              len(blob_bytes),
                    "text_length":             len(new_text),
                    "preview":                 (new_text or "")[:120],
                    "previous_text":           _prev_capped,
                    "previous_text_length":    len(_prev),
                    "previous_text_truncated": _prev_trunc,
                },
                agent_label=state.get("agent_label"),
            )
    except Exception as e:
        log.warning(f"Mempack activity-log append failed (non-fatal): {e}")

    return f"updated idx={idx} in Supabase (mempack_id={mempack_id[:8]}, {len(blob_bytes)} bytes)"


def _ensure_mempack_cached(mempack: dict) -> str:
    """Download Mempack blob from Supabase Storage to local cache if not present.

    Args:
        mempack: dict from find_mempacks() with owner_id, name, mempack_id, path

    Returns: local cache path (mempack["path"]).

    Raises: RuntimeError if Supabase fetch fails.
    """
    import supabase_storage as sbs
    cache_path = mempack["path"]

    if os.path.exists(cache_path) and os.path.getsize(cache_path) > 0:
        return cache_path

    owner_id = mempack["owner_id"]
    name = mempack["name"]
    log.info(f"Fetching Mempack blob from Supabase: {owner_id}/{name}.cart.npz")
    try:
        blob_bytes = sbs.download_blob(owner_id, name)
    except Exception as e:
        raise RuntimeError(f"failed to fetch Mempack blob {owner_id}/{name}: {e}") from e

    if not blob_bytes:
        raise RuntimeError(f"empty blob returned for {owner_id}/{name}")

    with open(cache_path, "wb") as f:
        f.write(blob_bytes)
    log.info(f"Mempack cached: {cache_path} ({len(blob_bytes)} bytes)")
    return cache_path


def find_cartridges() -> list[dict]:
    """Scan cartridge directories for available .pkl and .npz cartridge files."""
    carts = []
    seen_names = set()

    for d in CARTRIDGE_DIRS:
        if not os.path.isdir(d):
            continue
        for f in os.listdir(d):
            # Support both .pkl (legacy) and .npz cartridges
            # Skip signature/brain companion files
            if f.endswith("_signatures.npz") or f.endswith("_brain.npy") or f.endswith("_manifest.json"):
                continue

            is_pkl = f.endswith(".pkl")
            is_npz = f.endswith(".cart.npz") or (f.endswith(".npz") and not f.endswith("_signatures.npz"))

            if not is_pkl and not is_npz:
                continue

            name = f.replace(".cart.npz", "").replace(".pkl", "").replace(".npz", "")
            if name in seen_names:
                continue
            seen_names.add(name)

            path = os.path.join(d, f)
            size_mb = os.path.getsize(path) / (1024 * 1024)

            has_brain = os.path.exists(os.path.join(d, f"{name}_brain.npy"))
            has_sigs = os.path.exists(os.path.join(d, f"{name}_signatures.npz"))
            has_manifest = os.path.exists(os.path.join(d, f"{name}_manifest.json"))

            carts.append({
                "name": name,
                "path": path,
                "format": "npz" if is_npz else "pkl",
                "size_mb": round(size_mb, 1),
                "has_brain": has_brain,
                "has_signatures": has_sigs,
                "has_manifest": has_manifest,
                "dir": os.path.basename(d),
            })
    return carts


def load_cartridge_safe(path: str) -> dict:
    """Load a cartridge using the safest available method.

    NPZ files are always safe (no code execution).
    PKL files are only loaded from trusted directories.
    """
    if path.endswith(".npz"):
        return load_npz_cartridge(path)

    if path.endswith(".pkl"):
        if not is_trusted_directory(path):
            raise PermissionError(
                f"Refusing to load .pkl from untrusted path: {path}. "
                f"Only files in {CARTRIDGE_DIRS} are allowed. "
                f"Convert to .npz format for unrestricted loading."
            )
        log.warning(f"Loading legacy .pkl (trusted path): {path}")
        return load_pkl_cartridge(path)

    raise ValueError(f"Unknown cartridge format: {path}")


def load_pkl_cartridge(path: str) -> dict:
    """Load a legacy .pkl cartridge. ONLY call from trusted directories."""
    with open(path, "rb") as f:
        data = pickle.load(f)

    # Handle versioned cartridges (v7.0-8.3) where embeddings are nested in data["data"]
    version = data.get("version", "legacy")
    if version in ("7.0", "8.0", "8.1", "8.2", "8.3"):
        core = data.get("data", data)
        embeddings = core.get("embeddings", data.get("embeddings", np.array([])))
        passages = core.get("passages", data.get("passages", []))
    elif "data" in data and isinstance(data["data"], dict):
        core = data["data"]
        embeddings = core.get("embeddings", np.array([]))
        passages = core.get("passages", [])
    else:
        embeddings = data.get("embeddings", np.array([]))
        passages = data.get("passages", data.get("texts", []))

    if isinstance(embeddings, list):
        embeddings = np.array(embeddings, dtype=np.float32)
    elif isinstance(embeddings, np.ndarray) and embeddings.dtype != np.float32:
        embeddings = embeddings.astype(np.float32)

    result = {
        "embeddings": embeddings,
        "texts": list(passages),
        "version": version,
        "format": "pkl",
    }
    # Pre-computed sign bits (packed uint8, 96 bytes per pattern)
    if "sign_bits" in data:
        result["sign_bits"] = data["sign_bits"]
    # Extra metadata fields
    for key in ("paper_ids", "titles", "metadata"):
        if key in data:
            result[key] = data[key]
    return result


def load_npz_cartridge(path: str) -> dict:
    """Load an .npz cartridge (safe — no code execution).

    Supports split carts: if the index contains has_sqlite=True,
    texts are loaded as snippets from the index and full passages
    are fetched on demand from a SQLite sidecar file.
    """
    data = np.load(path, allow_pickle=True)

    result = {"format": "npz", "version": "unknown"}

    # Check for split cart (index + SQLite sidecar)
    has_sqlite = bool(data["has_sqlite"]) if "has_sqlite" in data else False
    if has_sqlite:
        db_name = str(data["text_db"]) if "text_db" in data else None
        if db_name:
            db_path = os.path.join(os.path.dirname(path), db_name)
            if os.path.exists(db_path):
                result["sqlite_db_path"] = db_path
                log.info(f"Split cart: SQLite sidecar at {db_path}")
            else:
                log.warning(f"Split cart: SQLite sidecar not found at {db_path}")

        # Snippets serve as texts for keyword reranking
        if "snippets" in data:
            snippets = data["snippets"]
            if isinstance(snippets, np.ndarray) and snippets.dtype == object:
                result["texts"] = list(snippets)
            else:
                result["texts"] = list(snippets)
            count = int(data["count"]) if "count" in data else len(result["texts"])
            log.info(f"Split cart: {count:,} snippets loaded, full text in SQLite")
        else:
            result["texts"] = []

        result["embeddings"] = np.array([])
        result["is_split_cart"] = True

        # Sign bits
        if "sign_bits" in data:
            result["sign_bits"] = data["sign_bits"]

        return result

    # Handle various NPZ layouts (standard carts)
    if "embeddings" in data:
        result["embeddings"] = data["embeddings"]
    elif "embs" in data:
        embs = data["embs"]
        if isinstance(embs, np.ndarray) and embs.dtype == object:
            result["embeddings"] = np.array(list(embs), dtype=np.float32)
        else:
            result["embeddings"] = embs
    else:
        result["embeddings"] = np.array([])

    if "passages" in data:
        result["texts"] = list(data["passages"])
    elif "memories" in data:
        mems = data["memories"]
        if isinstance(mems, np.ndarray) and mems.dtype == object:
            result["texts"] = [m.get("text", str(m)) if isinstance(m, dict) else str(m) for m in mems]
        else:
            result["texts"] = list(mems)
    elif "compressed_texts" in data:
        texts = []
        for ct in data["compressed_texts"]:
            try:
                texts.append(zlib.decompress(bytes(ct)).decode("utf-8"))
            except Exception:
                texts.append("[decompress error]")
        result["texts"] = texts
    else:
        result["texts"] = []

    # Load hippocampus metadata if present (mcp-v4+)
    if "hippocampus" in data:
        raw_hippo = data["hippocampus"]
        log.info(f"[HIPPO] Found hippocampus in NPZ: shape={raw_hippo.shape}, dtype={raw_hippo.dtype}")
        result["hippocampus"] = _unpack_hippocampus(raw_hippo)
        log.info(f"[HIPPO] Unpacked {len(result['hippocampus'])} entries. First: {result['hippocampus'][0] if result['hippocampus'] else 'EMPTY'}")
        # Log a sample entry that should have links
        for h in result["hippocampus"][:20]:
            if h["prev"] is not None or h["next"] is not None:
                log.info(f"[HIPPO] Sample linked entry: {h}")
                break
        else:
            log.warning("[HIPPO] No linked entries found in first 20 hippocampus records!")
    else:
        # Expected for Supabase-backed Mempacks (H-block array lives in the
        # mempack_patterns Postgres rows, not in the NPZ blob) and for legacy
        # carts that pre-date the hippocampus array. Not an error condition.
        log.debug(f"[HIPPO] No hippocampus array in NPZ (Mempack-style: H-blocks in Postgres). Keys: {list(data.keys())}")
        result["hippocampus"] = None

    # Load per-pattern structured metadata (federate-v1+ format; used by
    # fleet-learning carts where each pattern carries a JSON dict of
    # game/machine/event/confidence/support_count/beat_dummy_pp/etc.).
    # Parsed eagerly so the filter API can query fields without re-parsing.
    #
    # Storage variants seen in the wild:
    #   - 1-d object array of dicts (federate-v1 CBP/Nomad/etc.)
    #   - 0-d object array wrapping a Python list of dicts
    #   - 0-d object array wrapping a JSON-encoded STRING (heartbeat cart)
    #   - 0-d object array wrapping None (meta field reserved but empty)
    # Also tolerate each element being either a dict or a JSON string.
    if "per_pattern_meta" in data:
        raw_meta = data["per_pattern_meta"]
        # Unwrap 0-d scalar
        if hasattr(raw_meta, "ndim") and raw_meta.ndim == 0:
            raw_meta = raw_meta.item()
        # Decode JSON-string-encoded lists
        if isinstance(raw_meta, (str, bytes)):
            try:
                raw_meta = json.loads(raw_meta)
            except Exception as e:
                log.warning(f"[META] per_pattern_meta is a string but not valid JSON: {e}")
                raw_meta = None
        if raw_meta is None:
            result["per_pattern_meta"] = None
        elif isinstance(raw_meta, dict):
            # Single dict for the whole cart — unusual but tolerate by broadcasting
            log.info(f"[META] per_pattern_meta is a single dict; not broadcasting (treating as absent)")
            result["per_pattern_meta"] = None
        else:
            try:
                iterable_meta = list(raw_meta)
            except TypeError:
                log.warning(f"[META] per_pattern_meta not iterable after unwrap: type={type(raw_meta).__name__}")
                result["per_pattern_meta"] = None
                return result
            parsed = []
            for entry in iterable_meta:
                if isinstance(entry, dict):
                    parsed.append(entry)
                elif isinstance(entry, (str, bytes)):
                    try:
                        obj = json.loads(entry)
                        parsed.append(obj if isinstance(obj, dict) else {})
                    except Exception:
                        parsed.append({})
                else:
                    parsed.append({})
            result["per_pattern_meta"] = parsed
            # Find first non-empty dict for log sample
            sample_keys = []
            for p in parsed:
                if p:
                    sample_keys = list(p.keys())
                    break
            log.info(f"[META] Loaded per_pattern_meta: {len(parsed)} entries, sample keys={sample_keys}")
    else:
        result["per_pattern_meta"] = None

    return result


def load_signatures(path: str) -> dict:
    """Load .npz signature file with optional compressed texts."""
    data = np.load(path, allow_pickle=True)
    result = {
        "signatures": data["signatures"],
        "n_patterns": int(data.get("n_patterns", len(data["signatures"]))),
        "method": str(data.get("signature_method", "unknown")),
    }

    if "compressed_texts" in data:
        texts = []
        for ct in data["compressed_texts"]:
            try:
                texts.append(zlib.decompress(bytes(ct)).decode("utf-8"))
            except Exception:
                texts.append("[decompress error]")
        result["texts"] = texts

    if "titles" in data:
        result["titles"] = [str(t) for t in data["titles"]]

    return result


# --- Hippocampus (H-block) metadata struct (matches cartridge_builder.py) ---
# Canonical 12-field format with perms_byte at offset 29. Migrated from legacy
# 11-field on 2026-05-13. See docs/PATTERN-ANATOMY.md §3 for spec.
_HIPPO_FORMAT = '<I B B I I I I H I B B 34s'
_HIPPO_SIZE = 64

def _unpack_hippocampus(raw: np.ndarray) -> list[dict]:
    """Unpack hippocampus metadata array into list of dicts.

    Args:
        raw: (N, 64) uint8 array from NPZ 'hippocampus' key

    Returns:
        List of dicts with: pattern_id, format_version, cartridge_type,
        prev, next, sibling, source_hash, sequence_num, timestamp, flags,
        perms_byte (canonical format only)
    """
    result = []
    for row in raw:
        vals = struct.unpack(_HIPPO_FORMAT, row.tobytes())
        result.append({
            "pattern_id":     vals[0],
            "format_version": vals[1],
            "cartridge_type": vals[2],
            "prev":           vals[3] if vals[3] > 0 else None,
            "next":           vals[4] if vals[4] > 0 else None,
            "sibling":        vals[5] if vals[5] > 0 else None,
            "source_hash":    vals[6],
            "sequence_num":   vals[7],
            "timestamp":      vals[8],
            "flags":          vals[9],
            "perms_byte":     vals[10],
        })
    return result


def save_as_npz(path: str, embeddings: np.ndarray, texts: list[str]):
    """Save cartridge in safe NPZ format (no pickle, no code execution)."""
    compressed_texts = []
    for t in texts:
        compressed_texts.append(np.void(zlib.compress(t.encode("utf-8"), level=9)))

    np.savez_compressed(
        path,
        embeddings=embeddings,
        passages=np.array(texts, dtype=object),
        compressed_texts=np.array(compressed_texts, dtype=object),
        version="mcp-v3",
    )


# ============================================================
# MCP SERVER
# ============================================================

mcp = FastMCP("Membot")


# ============================================================
# REST API — Simple HTTP endpoints for browser extensions, etc.
# These bypass MCP protocol overhead for direct tool access.
# ============================================================

from starlette.requests import Request
from starlette.responses import JSONResponse


def _log_rest(request: Request, endpoint: str, extra: str = ""):
    """Log REST API access with client identification."""
    client = request.headers.get("x-client", "unknown")
    ua = request.headers.get("user-agent", "")
    ip = request.client.host if request.client else "?"
    sid = "(no body)"
    msg = f"[REST] {endpoint} | client={client} | ip={ip}"
    if extra:
        msg += f" | {extra}"
    if ua and client == "unknown":
        # Show UA only if no explicit client header (helps identify curl vs browser vs extension)
        msg += f" | ua={ua[:80]}"
    log.info(msg)


@mcp.custom_route("/api/store", methods=["POST", "OPTIONS"])
async def rest_store(request: Request) -> JSONResponse:
    """REST wrapper for memory_store — used by Heartbeat browser extension.

    Step 3: per-user session_id from JWT so writes target the caller's own
    mounted state (not whatever the global slot happens to hold).
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        content = data.get("content", "")
        tags = data.get("tags", "")
        _log_rest(request, "STORE", f"tags={tags} len={len(content)}")
        user_id = _get_user_id_from_request(request)
        session_id = _session_id_for_user(user_id, fallback=data.get("session_id", ""))
        _call = getattr(memory_store, 'fn', memory_store)  # FastMCP 2.x vs 3.x
        result = _call(content=content, tags=tags, session_id=session_id)
        return JSONResponse({"status": "ok", "result": result}, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/store error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/search", methods=["POST", "OPTIONS"])
async def rest_search(request: Request) -> JSONResponse:
    """REST search returning structured JSON for browser apps.

    Step 3: per-user session_id from JWT so each user searches THEIR mounted
    state. Cross-user Mempack contents are not readable from another user's
    session because their session points at a different state slot.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        query = data.get("query", "")
        top_k = data.get("top_k", 5)
        _log_rest(request, "SEARCH", f"q=\"{query[:60]}\" top_k={top_k}")
        user_id = _get_user_id_from_request(request)
        session_id = _session_id_for_user(user_id, fallback=data.get("session_id", ""))
        state = _get_session(session_id)
        # Defense-in-depth: if the slot somehow holds another user's Mempack,
        # refuse the read. Belt + suspenders.
        if state.get("mempack_id") and state.get("owner_id") and user_id and state["owner_id"] != user_id:
            log.warning(
                f"OWNERSHIP DENIED (search): session={session_id[:14]} holds "
                f"mempack owned by {state['owner_id'][:8]}, caller={user_id[:8]}"
            )
            return JSONResponse(
                {"status": "error", "error": "Forbidden — mounted Mempack belongs to another user"},
                status_code=403, headers=_cors_headers(),
            )

        if state["cartridge_name"] is None:
            return JSONResponse({"status": "ok", "results": [], "error": "No cartridge mounted"}, headers=_cors_headers())

        has_emb = state.get("has_embeddings", True) and state["embeddings"] is not None and len(state["embeddings"]) > 0
        has_ham = state["binary_corpus"] is not None
        if not has_emb and not has_ham:
            return JSONResponse({"status": "ok", "results": []}, headers=_cors_headers())

        t0 = time.time()

        # Embed query
        query_emb = embed_text(query, prefix="search_query")

        # Helper: Hamming scores (handles packed and unpacked).
        # Uses popcount lookup + chunked XOR. Avoids both the 1.7 GB unpackbits
        # 768-expansion AND the 220 MB full-matrix XOR allocation.
        _POPCOUNT_TABLE_LOCAL = np.array([bin(i).count('1') for i in range(256)], dtype=np.uint8)
        def _ham_scores(q_emb, corpus_bin):
            is_packed = corpus_bin.shape[1] <= 96
            n = len(corpus_bin)
            n_bits = 768 if is_packed else corpus_bin.shape[1]
            dist = np.empty(n, dtype=np.uint32)
            BATCH = 100_000
            if is_packed:
                q_packed = np.packbits((q_emb > 0).astype(np.uint8))
                for start in range(0, n, BATCH):
                    end = min(start + BATCH, n)
                    xor = np.bitwise_xor(q_packed, corpus_bin[start:end])
                    dist[start:end] = _POPCOUNT_TABLE_LOCAL[xor].sum(axis=1)
            else:
                q_bin = (q_emb > 0).astype(np.uint8)
                for start in range(0, n, BATCH):
                    end = min(start + BATCH, n)
                    xor = np.bitwise_xor(q_bin, corpus_bin[start:end])
                    dist[start:end] = xor.sum(axis=1)
            return 1.0 - dist.astype(np.float32) / n_bits

        if has_emb:
            # Cosine similarity
            stored = state["embeddings"]
            stored_norms = np.linalg.norm(stored, axis=1, keepdims=True) + 1e-9
            query_norm = np.linalg.norm(query_emb) + 1e-9
            emb_scores = np.dot(stored / stored_norms, query_emb / query_norm)

            # Hamming blend
            scores = emb_scores
            if HAMMING_BLEND > 0 and has_ham:
                try:
                    corpus_bin = state["binary_corpus"]
                    n_bin = min(len(corpus_bin), len(emb_scores))
                    ham = _ham_scores(query_emb, corpus_bin[:n_bin])
                    blended = np.copy(emb_scores)
                    blended[:n_bin] = (1.0 - HAMMING_BLEND) * emb_scores[:n_bin] + HAMMING_BLEND * ham
                    scores = blended
                except Exception:
                    pass
        else:
            # Hamming-only
            scores = _ham_scores(query_emb, state["binary_corpus"])

        # Optional pre-filter: if a filter block is provided in the request,
        # restrict candidates to matching indices BEFORE ranking. This is the
        # shape Dennis's mechanics encoder + Query Hierarchy Phase 1 both want:
        # narrow by structural constraints first, rank by relevance second.
        # Filter spec shape documented in forum/filter-api-spec.md.
        filter_spec = data.get("filter")
        filter_matched = None
        if filter_spec:
            allowed_indices = _apply_filter(state, filter_spec)
            filter_matched = len(allowed_indices)
            if not allowed_indices:
                elapsed_ms = (time.time() - t0) * 1000
                _log_activity(session_id, "search+filter", f"'{query[:40]}' -> 0 (filter eliminated all)", elapsed_ms)
                return JSONResponse(
                    {
                        "status": "ok",
                        "results": [],
                        "filter_matched": 0,
                        "elapsed_ms": round(elapsed_ms),
                    },
                    headers=_cors_headers(),
                )
            # Vectorized mask: excluded indices get -inf, included keep score
            allowed_mask = np.zeros(len(scores), dtype=bool)
            for idx in allowed_indices:
                if idx < len(allowed_mask):
                    allowed_mask[idx] = True
            scores = np.where(allowed_mask, scores, -np.inf)

        # Keyword reranking
        STOP_WORDS = {"the", "a", "an", "is", "are", "was", "were", "of", "in", "to",
                      "and", "or", "for", "on", "it", "be", "as", "at", "by", "this",
                      "that", "with", "from", "not", "but", "has", "had", "have", "do",
                      "does", "did", "will", "can", "its", "who", "what", "how", "why"}
        keywords = [w for w in query.lower().split() if len(w) >= 3 and w not in STOP_WORDS]

        candidate_k = min(max(top_k * 4, 20), len(scores))
        candidate_idx = np.argsort(scores)[-candidate_k:][::-1]

        # Recency weighting: recent memories rank higher when similarity is close.
        # Uses timestamps from hippocampus metadata if available.
        # Formula: score * (0.5 + 0.5 * exp(-ln2 * age / half_life))
        # At age=0: 1.0, at half_life: 0.75, at infinity: 0.5 floor.
        _ln2 = math.log(2)
        now_ts = time.time()
        hippo_meta = state.get("hippocampus_meta")  # list of parsed header dicts
        has_recency = RECENCY_HALF_LIFE > 0 and hippo_meta is not None
        hippo = state.get("hippocampus")  # per-pattern structured meta: pattern_id, prev, next, flags

        boosted = []
        for i in candidate_idx:
            base_score = float(scores[i])
            if base_score < 0.05:
                continue
            # Skip tombstoned patterns (hippocampus flags bit 0x01 = TOMBSTONE)
            if hippo and i < len(hippo):
                flags = hippo[i].get("flags", 0) or 0
                if flags & 0x01:
                    continue
            text_lower = state["texts"][i].lower()
            hits = sum(1 for kw in keywords if kw in text_lower)
            boost = min(hits * 0.03, 0.12)
            final = base_score + boost
            # Apply recency weight
            if has_recency and i < len(hippo_meta):
                entry_ts = hippo_meta[i].get("timestamp", 0)
                if entry_ts > 0:
                    age = max(0.0, now_ts - entry_ts)
                    recency = 0.5 + 0.5 * math.exp(-_ln2 * age / RECENCY_HALF_LIFE)
                    final *= recency
            boosted.append((i, final))

        boosted.sort(key=lambda x: x[1], reverse=True)

        # Dedupe chunks that share a parent exchange. Long exchanges are chunked into
        # multiple patterns at cart-build time; each chunk is indexed independently
        # and all can match a query. Without dedup, top-k fills up with sibling chunks
        # of a single source exchange and wastes slots. Strategy: parse the tag prefix
        # for (url, turn); keep the highest-scoring chunk per source. Patterns without
        # a parseable prefix fall through (no dedup key = can't collide).
        seen_chunks = set()
        deduped = []
        dup_count = 0
        for i, score in boosted:
            text = state["texts"][i] if i < len(state["texts"]) else ""
            key = _chunk_dedup_key(text)
            if key is not None:
                if key in seen_chunks:
                    dup_count += 1
                    continue
                seen_chunks.add(key)
            deduped.append((i, score))
        if dup_count:
            log.info(f"[search] dedup removed {dup_count} sibling chunks")
        boosted = deduped

        elapsed_ms = (time.time() - t0) * 1000

        # Build structured results.
        # IMPORTANT: search returns the in-cart preview ONLY. The split-cart source
        # database is consulted on user demand via /api/passage, not here — keeps the
        # cart/source-DB distinction visible in the UX (every DB hit = a labeled user
        # action) and keeps search fast (no per-result SQLite query). The client gets
        # source_db so it knows the cart is split and can render the "load source"
        # CTA on the modal.
        is_split = bool(state.get("is_split_cart") and state.get("sqlite_conn"))
        source_db_label = (
            os.path.basename(state["sqlite_db_path"])
            if is_split and state.get("sqlite_db_path") else None
        )

        results = []
        for i, final_score in boosted[:top_k]:
            if final_score < 0.1:
                continue
            ram_text = state["texts"][i]
            # Extract tags if text starts with [TAGS] prefix (limit 300 chars for
            # URL-bearing tag prefixes used by Heartbeat).
            tags = ""
            body = ram_text
            if body.startswith("[") and "]" in body[:300]:
                tag_end = body.index("]")
                tags = body[1:tag_end]
                body = body[tag_end + 1:].strip()

            text = _soft_truncate(body)
            entry = {"text": text, "full_text": body, "score": round(final_score, 4), "tags": tags, "index": int(i)}
            # Provenance hint for split carts: client uses presence of source_db to
            # decide whether to render the "load source" CTA on the modal.
            if source_db_label:
                entry["source_db"] = source_db_label
            # Include hippocampus nav if available (hippo was hoisted earlier for tombstone check)
            if hippo and i < len(hippo):
                meta = hippo[i]
                log.info(f"[HIPPO-SEARCH] idx={i} pattern_id={meta['pattern_id']} prev_raw={meta['prev']} next_raw={meta['next']}")
                if meta["prev"] is not None:
                    entry["prev_idx"] = meta["prev"] - 1  # pattern_id 1-based → text 0-based
                if meta["next"] is not None:
                    entry["next_idx"] = meta["next"] - 1
            elif not hippo:
                log.info(f"[HIPPO-SEARCH] No hippocampus in session state!")
            else:
                log.info(f"[HIPPO-SEARCH] idx={i} out of range, hippo len={len(hippo)}")
            results.append(entry)

        state["query_count"] = state.get("query_count", 0) + 1
        log_label = "search+filter" if filter_matched is not None else "search"
        _log_activity(session_id, log_label, f"'{query[:40]}' -> {len(results)} results", elapsed_ms)

        resp = {"status": "ok", "results": results, "elapsed_ms": round(elapsed_ms)}
        if filter_matched is not None:
            resp["filter_matched"] = filter_matched
        return JSONResponse(resp, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/search error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


# ---------------------------------------------------------------------------
# Filter API (shipped 2026-04-19 for Dennis's Phase 4 sprint + general use)
# ---------------------------------------------------------------------------
# Returns indices + text + tags + meta for patterns matching a filter spec.
# Filter spec supports:
#   tags_all: ["ns:val", ...]  AND semantics across tags
#   tags_any: ["ns:val", ...]  OR semantics within a namespace
#   meta:     {field: value}   exact-match predicates on per_pattern_meta fields
#   quality:  {predicate: N}   support_count_min / beat_dummy_pp_min /
#                              uplift_over_weights_only_min — read from
#                              per_pattern_meta fields of matching name
#   limit:    int              max results (default 500, cap 10000)
#
# See forum/canonical-tags.md for the tag namespace vocabulary.

def _extract_tags_from_text(text: str) -> list[str]:
    """Extract canonical + free-form tags from a passage's bracketed prefix.

    Supports both 'memory_store'-style `[tag1,tag2] content` and federate-v1
    `[machine/player game L0 event @timestamp]` headers (the latter becomes
    a single tag we then split on whitespace/slash for canonical matches).
    """
    if not text or not text.startswith("["):
        return []
    end = text.find("]")
    if end < 0 or end > 200:  # defensive: real tag blocks are short
        return []
    raw = text[1:end]
    pieces = [p.strip() for p in raw.replace("/", " ").replace(",", " ").split()]
    return [p for p in pieces if p]


def _match_tag(tag_entry: str, filter_tag: str) -> bool:
    """Match a single filter tag against a single entry tag.

    Handles namespace:value canonical form (colon-separated) plus plain
    free-form tags. Case-sensitive on both sides (canonical values are
    lowercase by convention).
    """
    # Exact match
    if tag_entry == filter_tag:
        return True
    # Namespace prefix match: filter "game:" matches entry "game:ft09"
    if filter_tag.endswith(":") and tag_entry.startswith(filter_tag):
        return True
    return False


def _chunk_dedup_key(text: str):
    """Extract (url, turn) from a tag-prefix for chunk-dedup.

    Long exchanges split into multiple patterns at cart-build, all sharing the
    same (url, turn) but differing by chunk index. Returning that composite key
    lets the search result path dedupe siblings.

    Handles two Heartbeat tag-prefix formats (mixed carts contain both):
      v24-comma: [HEARTBEAT,CLAUDE,turn-119,url=claude.ai/chat/abc]
      v25-pipe:  [Claude | Turn 119 | 2026-03-09T22:08:21.046Z | claude.ai/chat/abc]

    Returns None if no prefix or the required tokens are absent (falls through
    to no-dedup — safer than a false-positive collision).
    """
    if not text or not text.startswith("[") or "]" not in text[:300]:
        return None
    prefix = text[1:text.index("]")]
    turn = None
    url = None

    # v24 comma format — extract turn-N and url=X tokens
    if "," in prefix:
        for tok in (t.strip() for t in prefix.split(",")):
            if tok.startswith("turn-") and turn is None:
                turn = tok
            elif tok.startswith("url=") and url is None:
                url = tok[4:]

    # v25 pipe format — fallback if v24 parse didn't yield both
    if (turn is None or url is None) and "|" in prefix:
        tokens = [t.strip() for t in prefix.split("|")]
        for tok in tokens:
            # Match "Turn 119" (case-insensitive, whitespace-tolerant)
            if turn is None:
                low = tok.lower()
                if low.startswith("turn "):
                    parts = tok.split()
                    if len(parts) >= 2 and parts[1].isdigit():
                        turn = f"turn-{parts[1]}"
        # URL token: trailing token that looks like a domain path
        if url is None and tokens and "/" in tokens[-1] and "." in tokens[-1]:
            url = tokens[-1]

    if turn and url:
        return (url, turn)
    return None


# Known filter-spec keys; anything else at top level is logged as a warning
# so bad client shapes (e.g. `{"tags": {"any": [...]}}` — nested style) don't
# silently no-op. Doesn't reject — filter still runs on recognized keys only.
_FILTER_SPEC_KNOWN_KEYS = {"tags_all", "tags_any", "meta", "quality", "limit", "session_id"}


def _apply_filter(state: dict, spec: dict) -> list[int]:
    """Apply a filter spec to the mounted cart, return matching indices.

    Missing state or empty cart returns [].
    """
    if not state or not state.get("texts"):
        return []

    # Warn on unknown top-level keys (likely a client mis-shape, e.g. nested
    # `{"tags": {"any": [...]}}` when spec requires flat `{"tags_any": [...]}`).
    if isinstance(spec, dict):
        unknown = [k for k in spec.keys() if k not in _FILTER_SPEC_KNOWN_KEYS]
        if unknown:
            log.warning(
                f"[filter] unknown spec keys ignored: {unknown}. "
                f"Valid: {sorted(_FILTER_SPEC_KNOWN_KEYS)}. "
                f"See shared-context/forum/filter-api-spec.md for flat shape."
            )

    texts = state["texts"]
    meta_list = state.get("per_pattern_meta") or [{}] * len(texts)
    n = len(texts)

    tags_all = spec.get("tags_all") or []
    tags_any = spec.get("tags_any") or []
    meta_filter = spec.get("meta") or {}
    quality = spec.get("quality") or {}

    out = []
    for i in range(n):
        text = texts[i] if i < len(texts) else ""
        meta = meta_list[i] if i < len(meta_list) else {}

        # tags_all: every filter tag must match some entry tag
        if tags_all:
            entry_tags = _extract_tags_from_text(text)
            # Also expose meta fields as pseudo-tags (game:ft09 from meta.game=ft09)
            for k, v in meta.items():
                if isinstance(v, (str, int)) and v != "":
                    entry_tags.append(f"{k}:{v}")
            ok = all(any(_match_tag(et, ft) for et in entry_tags) for ft in tags_all)
            if not ok:
                continue

        # tags_any: at least one filter tag must match some entry tag
        if tags_any:
            entry_tags = _extract_tags_from_text(text)
            for k, v in meta.items():
                if isinstance(v, (str, int)) and v != "":
                    entry_tags.append(f"{k}:{v}")
            ok = any(any(_match_tag(et, ft) for et in entry_tags) for ft in tags_any)
            if not ok:
                continue

        # meta exact-match predicates
        if meta_filter:
            ok = True
            for field, expected in meta_filter.items():
                if meta.get(field) != expected:
                    ok = False
                    break
            if not ok:
                continue

        # quality predicates (router PRD names; absent field = pass, not fail,
        # so carts that don't yet track these don't get zero-filtered)
        if quality:
            ok = True
            sc_min = quality.get("support_count_min")
            if sc_min is not None and meta.get("support_count") is not None:
                if meta.get("support_count", 0) < sc_min:
                    ok = False
            bd_min = quality.get("beat_dummy_pp_min")
            if ok and bd_min is not None and meta.get("beat_dummy_pp") is not None:
                if meta.get("beat_dummy_pp", 0.0) < bd_min:
                    ok = False
            uw_min = quality.get("uplift_over_weights_only_min")
            if ok and uw_min is not None and meta.get("uplift_over_weights_only") is not None:
                if meta.get("uplift_over_weights_only", 0.0) < uw_min:
                    ok = False
            if not ok:
                continue

        out.append(i)

    return out


@mcp.custom_route("/api/filter", methods=["POST", "OPTIONS"])
async def rest_filter(request: Request) -> JSONResponse:
    """Filter cart entries by tags, metadata predicates, and/or quality gates.

    Returns matching entries (index + text + tags + meta) with no similarity
    ranking. Complement to /api/search: search ranks by relevance, filter
    enumerates by structural match.

    Request body:
        {
          "tags_all": ["game:ft09", "machine:cbp"],  // AND within tags_all
          "tags_any": ["game:ft09", "game:lf52"],     // OR within tags_any
          "meta":    {"event": "level_solved"},       // exact-match on meta fields
          "quality": {"support_count_min": 5, "beat_dummy_pp_min": 10.0},
          "limit":    500,
          "session_id": ""
        }

    Response:
        {"status": "ok", "count": N, "results": [{index, text, tags, meta}, ...]}
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        _log_rest(request, "FILTER", f"spec={list(data.keys())}")
        session_id = _resolve_session_id(data.get("session_id", ""))
        state = _get_session(session_id)

        if state["cartridge_name"] is None:
            return JSONResponse(
                {"status": "ok", "count": 0, "results": [], "error": "No cartridge mounted"},
                headers=_cors_headers(),
            )

        t0 = time.time()
        indices = _apply_filter(state, data)

        # Respect limit (default 500, cap 10000)
        limit = min(int(data.get("limit", 500)), 10000)
        indices = indices[:limit]

        # Build response entries
        texts = state["texts"]
        meta_list = state.get("per_pattern_meta") or [{}] * len(texts)
        out = []
        for i in indices:
            text = texts[i] if i < len(texts) else ""
            tags = _extract_tags_from_text(text)
            meta = meta_list[i] if i < len(meta_list) else {}
            out.append({
                "index": int(i),
                "text": text if len(text) <= 1200 else text[:1200] + "...",
                "full_text": text,
                "tags": tags,
                "meta": meta,
            })

        elapsed_ms = (time.time() - t0) * 1000
        _log_activity(session_id, "filter", f"spec -> {len(out)} results", elapsed_ms)

        return JSONResponse(
            {
                "status": "ok",
                "count": len(out),
                "total_scanned": len(texts),
                "results": out,
                "elapsed_ms": round(elapsed_ms),
            },
            headers=_cors_headers(),
        )
    except Exception as e:
        log.error(f"REST /api/filter error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/status", methods=["GET", "OPTIONS"])
async def rest_status(request: Request) -> JSONResponse:
    """REST health check — returns structured cartridge status."""
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    _log_rest(request, "STATUS")
    try:
        sid = request.query_params.get("session_id", "")
        sid = _resolve_session_id(sid)
        state = _get_session(sid)
        n = len(state["texts"]) if state["texts"] else 0
        import socket
        return JSONResponse({
            "status": "ok",
            "cartridge": state["cartridge_name"],
            "briefing": state.get("briefing"),  # Pattern 0 v2 Phase 1
            # Pattern 0 v2 Phase 2 — ownership block. owner_id None = legacy cart.
            "owner_id": state.get("owner_id"),
            "owner_perms": state.get("owner_perms"),
            "group_perms": state.get("group_perms"),
            "world_perms": state.get("world_perms"),
            "group_id": state.get("group_id"),
            "max_patterns": state.get("max_patterns"),
            # Mempack — cart_type and Pattern I presence flag
            "cart_type": state.get("cart_type"),
            "has_pattern_i": (
                state.get("cart_type") == CART_TYPE_AGENT_MEMORY
                and state.get("texts") is not None
                and len(state["texts"]) > PATTERN_I_IDX
            ),
            "memories": n,
            "gpu": _gpu_state["available"],
            "hamming": state["binary_corpus"] is not None,
            "search_mode": "hamming+embedding" if (state.get("has_embeddings") and state["binary_corpus"] is not None) else "hamming-only" if state["binary_corpus"] is not None else "embedding" if state.get("has_embeddings") else "none",
            "session_id": sid,
            "read_only": _server_config.get("read_only", False),
            "hostname": socket.gethostname(),
        }, headers=_cors_headers())
    except Exception as e:
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/sync", methods=["POST", "OPTIONS"])
async def rest_sync(request: Request) -> JSONResponse:
    """Heartbeat JSONL sync — append harvested exchanges to a local flat file.

    POST body: { "exchanges": [...], "machine": "hostname" }
    Each exchange: { turn, userMessage, assistantMessage, timestamp, url, platform, platformName }
    Appends to cartridges/heartbeat_<machine>.jsonl (one JSON object per line).
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        exchanges = data.get("exchanges", [])
        machine = re.sub(r'[^a-zA-Z0-9_\-]', '_', data.get("machine", "unknown"))

        if not exchanges:
            return JSONResponse({"status": "ok", "appended": 0}, headers=_cors_headers())

        # Ensure cartridges directory exists
        cart_dir = os.path.join(BASE_DIR, "cartridges")
        os.makedirs(cart_dir, exist_ok=True)

        sync_file = os.path.join(cart_dir, f"heartbeat_{machine}.jsonl")
        appended = 0

        with open(sync_file, "a", encoding="utf-8") as f:
            for ex in exchanges:
                line = json.dumps(ex, ensure_ascii=False)
                f.write(line + "\n")
                appended += 1

        file_size = os.path.getsize(sync_file)
        _log_rest(request, "SYNC", f"machine={machine} appended={appended} file_size={file_size}")

        return JSONResponse({
            "status": "ok",
            "appended": appended,
            "file": f"heartbeat_{machine}.jsonl",
            "file_size": file_size,
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/sync error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/cartridges", methods=["GET", "OPTIONS"])
async def rest_cartridges(request: Request) -> JSONResponse:
    """List available cartridges on disk."""
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        carts = find_cartridges()
        return JSONResponse({
            "status": "ok",
            "cartridges": [
                {"name": c["name"], "size_mb": c["size_mb"], "format": c["format"],
                 "has_brain": c["has_brain"]}
                for c in carts
            ],
        }, headers=_cors_headers())
    except Exception as e:
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mount", methods=["POST", "OPTIONS"])
async def rest_mount(request: Request) -> JSONResponse:
    """Mount a cartridge by name for the app session.

    Step 3 multi-tenancy:
    - When the request carries a JWT, derive session_id = "user_<sub>" so each
      user gets their own RAM mount slot (closes the cross-user mount leak).
    - Force owner_id = JWT.sub for Mempack scoping. mount_cartridge calls
      find_mempacks(owner_id=...) and only finds the caller's own Mempacks,
      so Mempacks owned by other users become unmountable by name lookup.
    - Anonymous callers fall through to the legacy behavior (default session,
      unscoped find_mempacks). Knowledge carts unaffected.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        name = data.get("name", "")
        if not name:
            return JSONResponse({"status": "error", "error": "name required"}, status_code=400, headers=_cors_headers())

        # Identify caller via JWT (cookie or Bearer). None for anonymous.
        user_id = _get_user_id_from_request(request)
        # Per-user session slot when authenticated; per-body or default otherwise.
        session_id = _session_id_for_user(user_id, fallback=data.get("session_id", ""))
        # Mempack scoping: a signed-in caller can only see THEIR Mempacks.
        # Anonymous callers can still mount knowledge carts (no owner_id scope).
        owner_id = user_id or data.get("owner_id", "")

        _call = getattr(mount_cartridge, 'fn', mount_cartridge)
        result = _call(name=name, session_id=session_id, owner_id=owner_id)
        return JSONResponse({"status": "ok", "result": result}, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mount error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/passage", methods=["GET", "OPTIONS"])
async def rest_passage(request: Request) -> JSONResponse:
    """REST endpoint to fetch any passage by index, with hippocampus nav links.

    Step 3: per-user session_id from JWT. Cross-user Mempack defense-in-depth.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        idx = int(request.query_params.get("idx", -1))
        user_id = _get_user_id_from_request(request)
        session_id = _session_id_for_user(user_id, fallback=request.query_params.get("session_id", ""))
        state = _get_session(session_id)

        if state["cartridge_name"] is None:
            return JSONResponse({"status": "error", "error": "No cartridge mounted"}, headers=_cors_headers())
        if state.get("mempack_id") and state.get("owner_id") and user_id and state["owner_id"] != user_id:
            log.warning(
                f"OWNERSHIP DENIED (passage): session={session_id[:14]} holds "
                f"mempack owned by {state['owner_id'][:8]}, caller={user_id[:8]}"
            )
            return JSONResponse(
                {"status": "error", "error": "Forbidden — mounted Mempack belongs to another user"},
                status_code=403, headers=_cors_headers(),
            )

        texts = state["texts"]
        if idx < 0 or idx >= len(texts):
            return JSONResponse({"status": "error", "error": f"Index {idx} out of range"}, headers=_cors_headers())

        # For split carts, fetch full passage from the SQLite source database.
        # This is the user-driven "load source" path: only hit on click, never as
        # a side effect of search. Falls back to in-cart preview when SQLite is
        # unavailable or the row isn't there.
        full_text = texts[idx]
        paper_id = None
        source_db = None
        if state.get("is_split_cart") and state.get("sqlite_conn"):
            sqlite_row = _sqlite_fetch_passages(state["sqlite_conn"], [int(idx)]).get(int(idx))
            if sqlite_row:
                full_text = sqlite_row.get("passage") or full_text
                paper_id = sqlite_row.get("paper_id")
            if state.get("sqlite_db_path"):
                source_db = os.path.basename(state["sqlite_db_path"])

        entry = {"index": idx, "full_text": full_text}
        if paper_id:
            entry["paper_id"] = paper_id
        if source_db:
            entry["source_db"] = source_db

        hippo = state.get("hippocampus")
        if hippo and idx < len(hippo):
            meta = hippo[idx]
            if meta["prev"] is not None:
                entry["prev_idx"] = meta["prev"] - 1
            if meta["next"] is not None:
                entry["next_idx"] = meta["next"] - 1

        return JSONResponse({"status": "ok", "passage": entry}, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/passage error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/embed", methods=["POST", "OPTIONS"])
async def rest_embed(request: Request) -> JSONResponse:
    """Local embedding via sentence-transformers — replaces Nomic API calls from browser extension."""
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        data = await request.json()
        texts = data.get("texts", [])
        task_type = data.get("task_type", "search_document")
        if not texts:
            return JSONResponse({"status": "error", "error": "No texts provided"}, status_code=400, headers=_cors_headers())

        # Map Nomic task_type to sentence-transformers prefix
        prefix = "search_query" if task_type == "search_query" else "search_document"

        embeddings = []
        for text in texts:
            vec = embed_text(text, prefix=prefix)
            embeddings.append(vec.tolist())

        _log_rest(request, "EMBED", f"n={len(texts)} task={task_type}")
        return JSONResponse({
            "status": "ok",
            "embeddings": embeddings,
            "model": "nomic-ai/nomic-embed-text-v1.5"
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/embed error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/save", methods=["POST", "OPTIONS"])
async def rest_save(request: Request) -> JSONResponse:
    """REST wrapper for save_cartridge — persist to disk."""
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        _call = getattr(save_cartridge, 'fn', save_cartridge)
        result = _call()
        return JSONResponse({"status": "ok", "result": result}, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/save error: {e}")
        return JSONResponse({"status": "error", "error": str(e)}, status_code=500, headers=_cors_headers())


def _cors_headers():
    """CORS headers for browser extension access."""
    return {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type, Authorization",
    }


# ============================================================
# DEPOT DASHBOARD — Operator status board
# ============================================================

from starlette.responses import HTMLResponse


@mcp.custom_route("/depot/status", methods=["GET", "OPTIONS"])
async def depot_status(request: Request) -> JSONResponse:
    """JSON snapshot of all sessions, cartridges, and recent activity."""
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())

    now = time.time()
    uptime_sec = now - _depot_start_time

    # Sessions summary
    sessions_out = []
    for sid, s in _sessions.items():
        idle_sec = now - s["last_access"]
        if idle_sec > SESSION_TIMEOUT_SEC:
            status = "expired"
        elif s.get("last_action_time") and (now - s["last_action_time"]) < 10:
            status = "active"
        elif s["cartridge_name"] is None:
            status = "idle"
        else:
            status = "idle" if idle_sec > 30 else "active"
        sessions_out.append({
            "session_id": sid,
            "cartridge": s["cartridge_name"],
            "status": status,
            "idle_sec": round(idle_sec, 1),
            "query_count": s.get("query_count", 0),
            "mount_count": s.get("mount_count", 0),
            "last_action": s.get("last_action"),
            "created_at": s.get("created_at", s["last_access"]),
        })

    # Cartridge refcounts (how many sessions have each cart mounted)
    cart_refs = {}
    for s in _sessions.values():
        cn = s["cartridge_name"]
        if cn:
            if cn not in cart_refs:
                cart_refs[cn] = {"name": cn, "agents": 0, "entries": len(s["texts"]) if s["texts"] else 0}
            cart_refs[cn]["agents"] += 1

    # Available cartridges on disk
    disk_carts = []
    try:
        for c in find_cartridges():
            disk_carts.append({
                "name": c["name"],
                "size_mb": c.get("size_mb", 0),
                "mounted": c["name"] in cart_refs,
                "agents": cart_refs.get(c["name"], {}).get("agents", 0),
            })
    except Exception:
        pass

    # Recent activity (last 50)
    activity_out = []
    for entry in list(_depot_activity)[-50:]:
        activity_out.append({
            "time": entry["time"],
            "ago_sec": round(now - entry["time"], 1),
            "session": entry["session"],
            "action": entry["action"],
            "detail": entry["detail"],
            "latency_ms": entry["latency_ms"],
        })
    activity_out.reverse()  # newest first

    return JSONResponse({
        "uptime_sec": round(uptime_sec, 0),
        "active_sessions": len([s for s in sessions_out if s["status"] == "active"]),
        "total_sessions": len(sessions_out),
        "sessions": sessions_out,
        "cartridges": disk_carts,
        "mounted_carts": list(cart_refs.values()),
        "activity": activity_out,
        "gpu": _gpu_state["available"],
    }, headers=_cors_headers())


@mcp.custom_route("/depot", methods=["GET"])
async def depot_dashboard(request: Request) -> HTMLResponse:
    """Membot Depot — server rack dashboard."""
    return HTMLResponse(_DEPOT_HTML)


_DEPOT_HTML = r"""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Membot Depot</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: #0a0e14; color: #c8d0da; font-family: 'Segoe UI', system-ui, sans-serif;
    font-size: 13px; overflow: hidden; height: 100vh;
  }
  .header {
    display: flex; justify-content: space-between; align-items: center;
    padding: 12px 20px; background: #111820; border-bottom: 1px solid #1e2a38;
  }
  .header h1 { font-size: 16px; font-weight: 600; letter-spacing: 1px; }
  .header h1 span { color: #3b82f6; }
  .header-stats { display: flex; gap: 20px; font-size: 12px; color: #6b7b8d; }
  .header-stats .val { color: #e2e8f0; font-weight: 600; }

  .panels { display: flex; flex-direction: column; height: calc(100vh - 49px); }

  /* Rack panel */
  .rack-panel {
    padding: 16px 20px; border-bottom: 1px solid #1e2a38;
    flex-shrink: 0; position: relative;
  }
  .rack-panel h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px; color: #4a5568; margin-bottom: 12px; }
  .rack-grid { display: flex; flex-wrap: wrap; gap: 12px; }

  .cart-block {
    width: 130px; background: #141c26; border: 1px solid #1e2a38;
    border-radius: 6px; padding: 10px; cursor: pointer; transition: all 0.2s;
    position: relative;
  }
  .cart-block:hover { border-color: #3b82f6; background: #182030; }
  .cart-block.hot { border-color: #22c55e33; box-shadow: 0 0 12px #22c55e11; }
  .cart-block.cold { opacity: 0.5; }
  .cart-name { font-size: 12px; font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .cart-meta { font-size: 10px; color: #4a5568; margin-top: 2px; }
  .cart-leds { display: flex; gap: 4px; margin-top: 8px; min-height: 10px; }
  .led {
    width: 10px; height: 10px; border-radius: 50%; position: relative;
    cursor: pointer; transition: all 0.3s;
  }
  .led.active { background: #22c55e; box-shadow: 0 0 6px #22c55e88; }
  .led.idle { background: #eab308; box-shadow: 0 0 4px #eab30844; }
  .led.expired { background: #4a5568; }
  .led-tooltip {
    display: none; position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
    background: #1e293b; border: 1px solid #334155; border-radius: 4px; padding: 4px 8px;
    font-size: 10px; white-space: nowrap; z-index: 10; color: #e2e8f0;
  }
  .led:hover .led-tooltip { display: block; }

  .empty-slot {
    width: 130px; height: 72px; border: 1px dashed #1e2a38; border-radius: 6px;
    display: flex; align-items: center; justify-content: center;
    font-size: 10px; color: #2a3544;
  }

  /* Expand button on panels */
  .expand-btn {
    position: absolute; top: 8px; right: 12px; background: none; border: none;
    color: #4a5568; cursor: pointer; font-size: 14px; padding: 4px;
  }
  .expand-btn:hover { color: #e2e8f0; }

  /* Activity log */
  .activity-panel { flex: 1; overflow: hidden; display: flex; flex-direction: column; padding: 0 20px 12px; }
  .activity-panel h2 {
    font-size: 11px; text-transform: uppercase; letter-spacing: 1.5px;
    color: #4a5568; padding: 12px 0 8px; flex-shrink: 0; position: relative;
  }
  .activity-list { flex: 1; overflow-y: auto; }
  .activity-list::-webkit-scrollbar { width: 4px; }
  .activity-list::-webkit-scrollbar-thumb { background: #1e2a38; border-radius: 2px; }

  .activity-row {
    display: grid; grid-template-columns: 70px 110px 60px 1fr 60px;
    gap: 8px; padding: 4px 0; font-size: 11px; font-family: 'Cascadia Code', 'Fira Code', monospace;
    border-bottom: 1px solid #0d1219;
  }
  .activity-row .time { color: #4a5568; }
  .activity-row .session { color: #3b82f6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .activity-row .action { color: #22c55e; }
  .activity-row .action.mount { color: #eab308; }
  .activity-row .action.unmount { color: #ef4444; }
  .activity-row .detail { color: #8892a0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .activity-row .latency { color: #4a5568; text-align: right; }

  /* Detail pane (click cart or LED) */
  .detail-pane {
    display: none; position: fixed; right: 0; top: 0; width: 340px; height: 100vh;
    background: #111820; border-left: 1px solid #1e2a38; padding: 16px; z-index: 100;
    overflow-y: auto;
  }
  .detail-pane.open { display: block; }
  .detail-pane h3 { font-size: 14px; margin-bottom: 12px; }
  .detail-pane .close-btn {
    position: absolute; top: 12px; right: 12px; background: none; border: none;
    color: #6b7b8d; cursor: pointer; font-size: 16px;
  }
  .detail-field { margin-bottom: 8px; }
  .detail-field .label { font-size: 10px; text-transform: uppercase; color: #4a5568; }
  .detail-field .value { font-size: 13px; color: #e2e8f0; }

  /* Expanded panel */
  .panels.rack-expanded .rack-panel { flex: 1; overflow-y: auto; }
  .panels.rack-expanded .activity-panel { display: none; }
  .panels.activity-expanded .rack-panel { display: none; }
  .panels.activity-expanded .activity-panel { flex: 1; }

  /* No data state */
  .no-data { color: #2a3544; font-style: italic; padding: 20px; text-align: center; }
</style>
</head>
<body>

<div class="header">
  <h1><span>MEMBOT</span> DEPOT</h1>
  <div class="header-stats">
    <div><a href="#" onclick="showCartList();return false" style="color:#4fc3f7;text-decoration:none" title="View cartridge list">Carts: <span class="val" id="hdr-carts">0</span></a></div>
    <div>Sessions: <span class="val" id="hdr-sessions">0</span></div>
    <div>Queries: <span class="val" id="hdr-queries">0</span></div>
    <div>GPU: <span class="val" id="hdr-gpu">—</span></div>
    <div>Uptime: <span class="val" id="hdr-uptime">—</span></div>
  </div>
</div>

<div class="panels" id="panels">
  <div class="rack-panel" id="rack-panel">
    <h2>Cartridge Rack</h2>
    <button class="expand-btn" onclick="toggleExpand('rack')" title="Expand">&#x2922;</button>
    <div class="rack-grid" id="rack-grid"></div>
  </div>

  <div class="activity-panel" id="activity-panel">
    <h2>
      Recent Activity
      <button class="expand-btn" onclick="toggleExpand('activity')" title="Expand">&#x2922;</button>
    </h2>
    <div class="activity-list" id="activity-list"></div>
  </div>
</div>

<div class="detail-pane" id="detail-pane">
  <button class="close-btn" onclick="closeDetail()">&#x2715;</button>
  <div id="detail-content"></div>
</div>

<script>
const POLL_MS = 2000;
let lastData = null;

function fmt_time(ts) {
  const d = new Date(ts * 1000);
  return d.toLocaleTimeString('en-US', {hour12: false, hour:'2-digit', minute:'2-digit', second:'2-digit'});
}

function fmt_uptime(sec) {
  const d = Math.floor(sec / 86400);
  const h = Math.floor((sec % 86400) / 3600);
  const m = Math.floor((sec % 3600) / 60);
  if (d > 0) return d + 'd ' + h + 'h';
  if (h > 0) return h + 'h ' + m + 'm';
  return m + 'm';
}

function fmt_ago(sec) {
  if (sec < 5) return '<5s';
  if (sec < 60) return Math.round(sec) + 's';
  if (sec < 3600) return Math.round(sec/60) + 'm';
  return Math.round(sec/3600) + 'h';
}

function toggleExpand(panel) {
  const el = document.getElementById('panels');
  if (el.classList.contains(panel + '-expanded')) {
    el.className = 'panels';
  } else {
    el.className = 'panels ' + panel + '-expanded';
  }
}

function closeDetail() {
  document.getElementById('detail-pane').classList.remove('open');
}

function showCartList() {
  const pane = document.getElementById('detail-pane');
  const content = document.getElementById('detail-content');
  const carts = lastData?.cartridges || [];
  const sessions = lastData?.sessions || [];
  const totalMB = carts.reduce((a, c) => a + (parseFloat(c.size_mb) || 0), 0).toFixed(1);
  content.innerHTML = '<h3>Available Cartridges (' + carts.length + ')</h3>' +
    '<div style="color:#8892a0;font-size:12px;margin-bottom:12px">Total: ' + totalMB + ' MB on disk</div>' +
    (carts.length === 0 ? '<div class="no-data">No cartridges found</div>' :
      carts.map(c => {
        const agents = sessions.filter(s => s.cartridge === c.name);
        const status = c.mounted ? '<span style="color:#4caf50">mounted (' + agents.length + ' agent' + (agents.length !== 1 ? 's' : '') + ')</span>' : '<span style="color:#4a5568">on disk</span>';
        return '<div style="margin:6px 0;padding:8px;background:#0d1219;border-radius:4px;cursor:pointer" onclick="showCartDetail(' + esc_attr(JSON.stringify(c)) + ')">' +
          '<div style="display:flex;justify-content:space-between;align-items:center">' +
            '<span style="color:#e2e8f0;font-weight:600">' + esc(c.name) + '</span>' +
            '<span style="color:#8892a0;font-size:11px">' + c.size_mb + ' MB</span>' +
          '</div>' +
          '<div style="font-size:11px;margin-top:4px">' + status + '</div>' +
        '</div>';
      }).join('')
    );
  pane.classList.add('open');
}

function showCartDetail(cart) {
  const pane = document.getElementById('detail-pane');
  const content = document.getElementById('detail-content');
  // Find sessions on this cart
  const sessions = (lastData?.sessions || []).filter(s => s.cartridge === cart.name);
  content.innerHTML = '<h3>' + esc(cart.name) + '</h3>' +
    field('Size', cart.size_mb + ' MB') +
    field('Agents', cart.agents) +
    field('Status', cart.mounted ? 'Mounted' : 'On disk') +
    '<h3 style="margin-top:16px">Connected Agents</h3>' +
    (sessions.length === 0 ? '<div class="no-data">None</div>' :
      sessions.map(s =>
        '<div style="margin:8px 0;padding:8px;background:#0d1219;border-radius:4px">' +
          field('Session', s.session_id) +
          field('Queries', s.query_count) +
          field('Idle', fmt_ago(s.idle_sec)) +
          field('Last', s.last_action || '—') +
        '</div>'
      ).join('')
    );
  pane.classList.add('open');
}

function showSessionDetail(session) {
  const pane = document.getElementById('detail-pane');
  const content = document.getElementById('detail-content');
  const activities = (lastData?.activity || []).filter(a => a.session === session.session_id).slice(0, 20);
  content.innerHTML = '<h3>' + esc(session.session_id) + '</h3>' +
    field('Cartridge', session.cartridge || 'None') +
    field('Status', session.status) +
    field('Queries', session.query_count) +
    field('Mounts', session.mount_count) +
    field('Idle', fmt_ago(session.idle_sec)) +
    field('Last Action', session.last_action || '—') +
    '<h3 style="margin-top:16px">Recent Activity</h3>' +
    (activities.length === 0 ? '<div class="no-data">None</div>' :
      activities.map(a =>
        '<div style="font-size:11px;font-family:monospace;padding:2px 0;color:#8892a0">' +
          fmt_time(a.time) + ' ' + a.action + ' ' + esc(a.detail) +
          (a.latency_ms > 0 ? ' <span style="color:#4a5568">' + a.latency_ms + 'ms</span>' : '') +
        '</div>'
      ).join('')
    );
  pane.classList.add('open');
}

function field(label, val) {
  return '<div class="detail-field"><div class="label">' + label + '</div><div class="value">' + esc(String(val)) + '</div></div>';
}

function esc(s) {
  const d = document.createElement('div');
  d.textContent = s;
  return d.innerHTML;
}

function render(data) {
  lastData = data;

  // Header
  const carts_all = data.cartridges || [];
  document.getElementById('hdr-carts').textContent = carts_all.length;
  document.getElementById('hdr-sessions').textContent = data.active_sessions + ' active';
  const totalQ = (data.sessions || []).reduce((a, s) => a + s.query_count, 0);
  document.getElementById('hdr-queries').textContent = totalQ;
  document.getElementById('hdr-gpu').textContent = data.gpu ? 'Ready' : 'CPU only';
  document.getElementById('hdr-uptime').textContent = fmt_uptime(data.uptime_sec);

  // Rack grid — one block per disk cartridge + empty slots
  const grid = document.getElementById('rack-grid');
  const carts = data.cartridges || [];
  const sessions = data.sessions || [];

  let html = '';
  for (const cart of carts) {
    const agents = sessions.filter(s => s.cartridge === cart.name);
    const isHot = agents.some(a => a.status === 'active');
    const cls = cart.mounted ? (isHot ? 'hot' : '') : 'cold';
    html += '<div class="cart-block ' + cls + '" onclick="showCartDetail(' + esc_attr(JSON.stringify(cart)) + ')">';
    html += '<div class="cart-name">' + esc(cart.name) + '</div>';
    html += '<div class="cart-meta">' + cart.size_mb + ' MB</div>';
    html += '<div class="cart-leds">';
    for (const agent of agents) {
      html += '<div class="led ' + agent.status + '" onclick="event.stopPropagation();showSessionDetail(' + esc_attr(JSON.stringify(agent)) + ')">';
      html += '<div class="led-tooltip">' + esc(agent.session_id) + '<br>' + (agent.last_action || 'idle') + '</div>';
      html += '</div>';
    }
    html += '</div></div>';
  }
  // Empty slots to fill the row
  const empties = Math.max(0, 6 - carts.length);
  for (let i = 0; i < empties; i++) {
    html += '<div class="empty-slot">empty</div>';
  }
  grid.innerHTML = html;

  // Activity log
  const actList = document.getElementById('activity-list');
  let actHtml = '';
  const acts = data.activity || [];
  if (acts.length === 0) {
    actHtml = '<div class="no-data">No activity yet — agents will appear here when they connect</div>';
  }
  for (const a of acts) {
    const actionCls = a.action === 'mount' ? 'mount' : a.action === 'unmount' ? 'unmount' : '';
    actHtml += '<div class="activity-row">';
    actHtml += '<div class="time">' + fmt_time(a.time) + '</div>';
    actHtml += '<div class="session">' + esc(a.session) + '</div>';
    actHtml += '<div class="action ' + actionCls + '">' + a.action + '</div>';
    actHtml += '<div class="detail">' + esc(a.detail) + '</div>';
    actHtml += '<div class="latency">' + (a.latency_ms > 0 ? a.latency_ms + 'ms' : '') + '</div>';
    actHtml += '</div>';
  }
  actList.innerHTML = actHtml;
}

function esc_attr(s) {
  return s.replace(/&/g,'&amp;').replace(/'/g,'&#39;').replace(/"/g,'&quot;').replace(/</g,'&lt;');
}

async function poll() {
  try {
    const base = location.pathname.replace(/\/depot\/?$/, '');
    const resp = await fetch(base + '/depot/status');
    if (resp.ok) {
      const data = await resp.json();
      render(data);
    }
  } catch (e) {
    // Network error — will retry next cycle
  }
}

// Initial load + polling
poll();
setInterval(poll, POLL_MS);
</script>
</body>
</html>
"""


# ---------------------------------------------------------------------------
# App frontend — user-facing search & store interface
# ---------------------------------------------------------------------------

# Load icon at import time (once)
_icon_path = os.path.join(BASE_DIR, "membot-icon.png")
if os.path.exists(_icon_path):
    _app_icon_b64 = base64.b64encode(open(_icon_path, "rb").read()).decode()
else:
    _app_icon_b64 = ""

@mcp.custom_route("/app", methods=["GET"])
async def app_frontend(request: Request) -> HTMLResponse:
    """Membot App — user-facing search & store interface."""
    html = _APP_HTML.replace("{{ICON_DATA_URI}}",
        f"data:image/png;base64,{_app_icon_b64}" if _app_icon_b64 else "")
    return HTMLResponse(html)


@mcp.custom_route("/app/", methods=["GET"])
async def app_frontend_trailing_slash(request: Request):
    """Trailing-slash variant — 301 to canonical /app.

    Starlette doesn't auto-redirect on slash mismatch when routes are
    registered via FastMCP's custom_route helper, so `/app/` was 404ing.

    Use a RELATIVE redirect ('../app') so the browser resolves it against
    whatever prefix it's behind: from `https://project-you.app/membot/app/`
    -> `https://project-you.app/membot/app`; from local
    `http://localhost:8050/app/` -> `http://localhost:8050/app`. An absolute
    '/app' would 404 in prod (nginx doesn't re-prepend `/membot/` to the
    Location header it forwards).
    """
    from starlette.responses import RedirectResponse
    return RedirectResponse(url="../app", status_code=301)


@mcp.custom_route("/downloads/mempack_local_agent.py", methods=["GET"])
async def download_mempack_local_agent(request: Request):
    """Serve the mempack_local_agent.py harness as a downloadable Python file.

    The Python harness ships alongside membot at /opt/membot/tools/
    mempack_local_agent.py. We serve it as a single-file download so any
    user (not just developers with git access) can grab it from the
    Connect-an-agent panel and run a fully local Ollama-backed agent
    against their Mempack — no Claude Desktop, no API key required.

    Adds Content-Disposition: attachment so browsers offer Save As.
    """
    from starlette.responses import FileResponse, JSONResponse
    # The file lives next to this module on the droplet (in tools/).
    here = os.path.dirname(os.path.abspath(__file__))
    candidates = [
        os.path.join(here, "tools", "mempack_local_agent.py"),
        os.path.join(here, "mempack_local_agent.py"),  # fallback if relocated
    ]
    for path in candidates:
        if os.path.isfile(path):
            return FileResponse(
                path=path,
                media_type="text/x-python",
                filename="mempack_local_agent.py",
                headers=_cors_headers(),
            )
    log.error(f"mempack_local_agent.py not found in any of: {candidates}")
    return JSONResponse(
        {"status": "error", "error": "harness not deployed; check /opt/membot/tools/"},
        status_code=404, headers=_cors_headers(),
    )


_APP_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Membot — Brain Cartridge Memory</title>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  :root {
    --bg: #0c1017; --surface: #131a24; --surface-2: #1a2332;
    --border: #1e2d3d; --border-hover: #2a3f54;
    --text: #d1dae6; --text-dim: #6b7f95; --text-bright: #f0f4f8;
    --accent: #3b82f6; --accent-dim: #2563eb; --accent-glow: #3b82f620;
    --green: #22c55e; --green-dim: #16a34a; --green-glow: #22c55e18;
    --amber: #eab308; --red: #ef4444;
    --mono: 'Cascadia Code','Fira Code','JetBrains Mono','Consolas',monospace;
    --sans: 'Inter','Segoe UI',system-ui,-apple-system,sans-serif;
  }
  [data-theme="light"] {
    --bg: #f5f7fa; --surface: #ffffff; --surface-2: #f0f2f5;
    --border: #e2e8f0; --border-hover: #cbd5e1;
    --text: #334155; --text-dim: #94a3b8; --text-bright: #0f172a;
    --accent: #3b82f6; --accent-dim: #2563eb; --accent-glow: #3b82f615;
    --green: #16a34a; --green-dim: #15803d; --green-glow: #22c55e12;
    --amber: #ca8a04; --red: #dc2626;
  }
  body { background:var(--bg); color:var(--text); font-family:var(--sans); font-size:14px; line-height:1.6; min-height:100vh; }
  .app { max-width:960px; margin:0 auto; padding:20px; }
  .brand { display:flex; align-items:center; gap:14px; padding:24px 0 20px; border-bottom:1px solid var(--border); margin-bottom:24px; }
  .brand-icon { width:48px; height:48px; border-radius:12px; overflow:hidden; flex-shrink:0; box-shadow:0 4px 20px #3b82f630; }
  .brand-icon img { width:100%; height:100%; object-fit:cover; }
  .brand-text h1 { font-size:22px; font-weight:700; color:var(--text-bright); letter-spacing:-0.5px; }
  .brand-text h1 span { color:var(--accent); }
  .brand-text p { font-size:12px; color:var(--text-dim); margin-top:-2px; }
  .intro { font-size:13px; color:var(--text-dim); line-height:1.5; margin:-8px 0 18px; }
  .intro strong { color:var(--text); font-weight:600; }
  .theme-toggle { background:var(--surface); border:1px solid var(--border); color:var(--text-dim); width:36px; height:36px; border-radius:8px; cursor:pointer; font-size:18px; display:flex; align-items:center; justify-content:center; transition:all 0.2s; flex-shrink:0; margin-left:auto; }
  .theme-toggle:hover { border-color:var(--border-hover); color:var(--text); }
  [data-theme="light"] .result-card, [data-theme="light"] .cart-chip { box-shadow:0 1px 3px rgba(0,0,0,0.08); }
  .status-bar { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--text-dim); }
  .status-dot { width:10px; height:10px; border-radius:50%; background:var(--text-dim); transition:all 0.3s; }
  .status-dot.connected { background:var(--green); box-shadow:0 0 8px var(--green-glow); }
  .status-dot.error { background:var(--red); }
  /* Auth chip — signed-out: "Sign in" button. Signed-in: avatar + dropdown. */
  .auth-chip { position:relative; flex-shrink:0; margin-left:12px; }
  .auth-signin-btn { display:flex; align-items:center; gap:6px; padding:6px 12px; border-radius:8px; background:var(--accent-glow); color:var(--accent); border:1px solid transparent; font-size:12px; font-weight:600; font-family:inherit; cursor:pointer; transition:all 0.2s; }
  .auth-signin-btn:hover { background:var(--accent); color:#fff; }
  .auth-avatar-btn { width:36px; height:36px; border-radius:50%; padding:0; border:1px solid var(--border); background:var(--surface); cursor:pointer; overflow:hidden; display:flex; align-items:center; justify-content:center; transition:border-color 0.2s; }
  .auth-avatar-btn:hover { border-color:var(--accent); }
  .auth-avatar-btn img { width:100%; height:100%; object-fit:cover; }
  .auth-avatar-btn .auth-initial { color:var(--text-bright); font-weight:700; font-size:14px; background:linear-gradient(135deg, var(--accent), #8b5cf6); width:100%; height:100%; display:flex; align-items:center; justify-content:center; }
  .auth-menu { position:absolute; right:0; top:calc(100% + 6px); min-width:220px; background:var(--surface); border:1px solid var(--border); border-radius:10px; box-shadow:0 8px 32px rgba(0,0,0,0.4); z-index:50; padding:4px 0; }
  .auth-menu-header { padding:10px 14px; border-bottom:1px solid var(--border); }
  .auth-menu-header .label { font-size:10px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-dim); margin-bottom:2px; }
  .auth-menu-header .email { font-size:13px; color:var(--text-bright); word-break:break-all; }
  .auth-menu-item { display:flex; align-items:center; gap:10px; width:100%; padding:9px 14px; background:transparent; border:none; color:var(--text-dim); font-size:12px; text-align:left; cursor:pointer; font-family:inherit; transition:background 0.15s; }
  .auth-menu-item:hover { background:var(--surface-2); color:var(--text); }
  .auth-menu-item.danger:hover { color:#ef4444; }
  .auth-menu-item .soon-badge { margin-left:auto; font-size:10px; color:var(--text-dim); opacity:0.6; }
  .auth-menu-item.disabled { cursor:not-allowed; opacity:0.5; }
  .auth-menu-item.disabled:hover { background:transparent; color:var(--text-dim); }
  .cart-bar { display:flex; gap:10px; margin-bottom:20px; overflow-x:auto; padding-bottom:4px; align-items:center; }
  .cart-chip { flex-shrink:0; background:var(--surface); border:1px solid var(--border); border-radius:8px; padding:10px 16px; min-width:140px; transition:all 0.2s; cursor:pointer; }
  .cart-chip:hover { border-color:var(--border-hover); transform:translateY(-1px); }
  .cart-chip.active { border-color:var(--accent); background:var(--accent-glow); cursor:default; }
  .cart-chip.mounting { opacity:0.6; pointer-events:none; }
  .cart-chip .name { font-size:13px; font-weight:600; color:var(--text-bright); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .cart-chip .meta { font-size:11px; color:var(--text-dim); margin-top:2px; }
  .cart-chip .meta .count { color:var(--accent); font-weight:600; }
  .search-box { position:relative; display:flex; gap:8px; margin-bottom:8px; }
  .search-box input { flex:1; background:var(--surface); border:1px solid var(--border); color:var(--text-bright); font-size:15px; padding:12px 16px 12px 42px; border-radius:10px; outline:none; transition:all 0.2s; font-family:var(--sans); }
  .search-box input:focus { border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-glow); }
  .search-box input::placeholder { color:var(--text-dim); }
  .search-icon { position:absolute; left:14px; top:50%; transform:translateY(-50%); color:var(--text-dim); font-size:16px; pointer-events:none; }
  .search-box button { background:var(--accent); color:white; border:none; padding:12px 24px; border-radius:10px; font-size:14px; font-weight:600; cursor:pointer; transition:all 0.2s; white-space:nowrap; }
  .search-box button:hover { background:var(--accent-dim); }
  .search-meta { display:flex; justify-content:space-between; font-size:12px; color:var(--text-dim); margin-bottom:16px; }
  .results { display:flex; flex-direction:column; gap:12px; }
  .result-card { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:16px 20px; transition:all 0.2s; }
  .result-card:hover { border-color:var(--border-hover); }
  .result-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
  .result-rank { font-size:11px; font-weight:700; color:var(--accent); background:var(--accent-glow); padding:2px 8px; border-radius:4px; font-family:var(--mono); }
  .result-score { font-size:11px; color:var(--text-dim); font-family:var(--mono); }
  .result-text { font-size:13px; line-height:1.7; color:var(--text); white-space:pre-wrap; word-break:break-word; }
  .result-text mark { background:#eab30830; color:var(--amber); border-radius:2px; padding:0 2px; }
  .result-footer { margin-top:10px; padding-top:8px; border-top:1px solid var(--border); display:flex; gap:12px; font-size:11px; color:var(--text-dim); font-family:var(--mono); }
  .result-footer .tag { background:var(--surface-2); padding:1px 6px; border-radius:3px; }
  .result-footer .prov { margin-left:auto; font-size:10px; opacity:0.7; font-style:italic; }
  .result-text a, .passage-text a { color:var(--accent); text-decoration:none; border-bottom:1px dotted var(--accent); }
  .result-text a:hover, .passage-text a:hover { border-bottom-style:solid; }
  .passage-modal-source { padding:8px 20px; border-top:1px solid var(--border); font-size:11px; color:var(--text-dim); font-family:var(--mono); flex-shrink:0; }
  .passage-modal-cta { padding:12px 20px; border-top:1px solid var(--border); text-align:center; flex-shrink:0; }
  .modal-cta-btn { background:var(--accent); color:white; border:none; padding:10px 18px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; font-family:var(--sans); transition:background 0.2s; }
  .modal-cta-btn:hover { background:var(--accent-dim); }
  .modal-cta-btn:disabled { opacity:0.6; cursor:wait; }
  .modal-cta-loading { font-size:12px; color:var(--text-dim); font-style:italic; }
  .result-card { cursor:pointer; }
  .passage-overlay { position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:900; display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity 0.2s; pointer-events:none; }
  .passage-overlay.open { opacity:1; pointer-events:auto; }
  .passage-modal { background:var(--bg); border:1px solid var(--border); border-radius:14px; width:min(800px,90vw); max-height:80vh; display:flex; flex-direction:column; box-shadow:0 24px 80px #00000080; }
  .passage-modal-header { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--border); flex-shrink:0; }
  .passage-modal-header .modal-rank { font-size:12px; font-weight:700; color:var(--accent); font-family:var(--mono); }
  .passage-modal-header .modal-score { font-size:12px; color:var(--text-dim); font-family:var(--mono); margin-left:12px; }
  .passage-modal-header .modal-close { background:none; border:1px solid var(--border); color:var(--text-dim); width:32px; height:32px; border-radius:6px; cursor:pointer; font-size:18px; display:flex; align-items:center; justify-content:center; transition:all 0.2s; }
  .passage-modal-header .modal-close:hover { border-color:var(--red); color:var(--red); background:rgba(239,68,68,0.1); }
  .passage-modal-body { padding:20px; overflow-y:auto; flex:1; }
  .passage-modal-body .passage-text { font-size:14px; line-height:1.8; color:var(--text); white-space:pre-wrap; word-break:break-word; }
  .passage-modal-body .passage-text mark { background:#eab30830; color:var(--amber); border-radius:2px; padding:0 2px; }
  .passage-modal-footer { padding:12px 20px; border-top:1px solid var(--border); display:flex; justify-content:center; gap:8px; flex-shrink:0; }
  .passage-nav-btn { background:var(--surface); border:1px solid var(--border); color:var(--text-dim); padding:6px 16px; border-radius:6px; font-size:12px; cursor:not-allowed; opacity:0.4; transition:all 0.2s; font-family:var(--mono); }
  .passage-nav-btn.enabled { cursor:pointer; opacity:1; }
  .passage-nav-btn.enabled:hover { border-color:var(--accent); color:var(--accent); }
  .store-section { margin-top:32px; padding-top:24px; border-top:1px solid var(--border); }
  .store-section h2 { font-size:14px; font-weight:600; color:var(--text-bright); margin-bottom:12px; }
  .store-box textarea { width:100%; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:13px; font-family:var(--sans); padding:12px 16px; border-radius:10px; resize:vertical; min-height:100px; outline:none; transition:border-color 0.2s; margin-bottom:8px; }
  .store-box textarea:focus { border-color:var(--green); }
  .store-box textarea::placeholder { color:var(--text-dim); }
  .store-row { display:flex; gap:8px; align-items:center; }
  .store-row input { flex:1; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:12px; padding:8px 12px; border-radius:8px; outline:none; font-family:var(--mono); }
  .store-row input:focus { border-color:var(--green); }
  .store-row input::placeholder { color:var(--text-dim); }
  .store-row button { background:var(--green); color:white; border:none; padding:8px 20px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
  .store-row button:hover { background:var(--green-dim); }
  .toast { position:fixed; bottom:24px; right:24px; background:var(--surface-2); border:1px solid var(--border); border-radius:8px; padding:12px 20px; font-size:13px; color:var(--text); box-shadow:0 8px 32px #00000060; transform:translateY(80px); opacity:0; transition:all 0.3s; z-index:1000; }
  .toast.show { transform:translateY(0); opacity:1; }
  .toast.success { border-left:3px solid var(--green); }
  .toast.error { border-left:3px solid var(--red); }
  .empty-state { text-align:center; padding:60px 20px; color:var(--text-dim); }
  .empty-state .icon { font-size:48px; margin-bottom:12px; opacity:0.3; }
  .loading { display:none; text-align:center; padding:40px; color:var(--text-dim); }
  .loading.active { display:block; }
  .spinner { display:inline-block; width:20px; height:20px; border:2px solid var(--border); border-top-color:var(--accent); border-radius:50%; animation:spin 0.8s linear infinite; }
  @keyframes spin { to { transform:rotate(360deg); } }
  .app-footer { margin-top:40px; padding:20px 0; border-top:1px solid var(--border); text-align:center; font-size:11px; color:var(--text-dim); }
  .app-footer a { color:var(--accent); text-decoration:none; }
  .app-footer a:hover { text-decoration:underline; }
  @media (max-width:640px) { .app { padding:12px; } .brand { flex-wrap:wrap; } .search-box { flex-direction:column; } .search-box button { width:100%; } }
  /* ---------- Mempack Dashboard view ---------- */
  .view-tabs { display:flex; gap:6px; margin-bottom:18px; padding:4px; background:var(--surface); border:1px solid var(--border); border-radius:10px; width:fit-content; }
  .view-tab { background:transparent; border:none; color:var(--text-dim); padding:8px 18px; border-radius:7px; cursor:pointer; font-size:13px; font-weight:600; font-family:var(--sans); transition:all 0.15s; }
  .view-tab:hover { color:var(--text); }
  .view-tab.active { background:var(--accent); color:white; }
  .dash-row { display:flex; gap:8px; align-items:center; margin-bottom:14px; }
  .dash-row input.uuid-input { flex:1; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:12px; padding:8px 12px; border-radius:8px; outline:none; font-family:var(--mono); }
  .dash-row input.uuid-input:focus { border-color:var(--accent); }
  .owner-display { flex:1; display:flex; flex-direction:column; gap:6px; min-width:0; }
  .owner-email-line { display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
  .owner-email-key { font-size:10px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-dim); font-weight:600; flex-shrink:0; }
  .owner-email { font-size:13px; color:var(--text-bright); font-weight:600; word-break:break-all; min-width:0; }
  .owner-email.anonymous { color:var(--text-dim); font-weight:400; font-style:italic; }
  .dash-row input.uuid-input.uuid-input--secondary { font-size:10px; padding:4px 8px; color:var(--text-dim); opacity:0.65; }
  .dash-row button { background:var(--accent); color:white; border:none; padding:8px 18px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; transition:all 0.2s; }
  .dash-row button:hover { background:var(--accent-dim); }
  .dash-row button.secondary { background:var(--surface-2); color:var(--text); border:1px solid var(--border); }
  .dash-row button.secondary:hover { border-color:var(--border-hover); color:var(--text-bright); }
  .mempack-list { display:flex; flex-direction:column; gap:8px; margin-bottom:24px; }
  .mempack-card { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 18px; cursor:pointer; transition:all 0.15s; }
  .mempack-card:hover { border-color:var(--border-hover); }
  .mempack-card.active { border-color:var(--accent); background:var(--accent-glow); }
  .mempack-card .name { font-size:14px; font-weight:700; color:var(--text-bright); margin-bottom:4px; }
  .mempack-card .meta { font-size:11px; color:var(--text-dim); font-family:var(--mono); margin-bottom:8px; }
  .mempack-card .meta .pill { background:var(--surface-2); padding:1px 6px; border-radius:3px; margin-right:6px; }
  .mempack-card .meta .pill.pending { color:var(--amber); }
  .mempack-card .meta .pill.ready { color:var(--green); }
  .quota-bar { background:var(--surface-2); height:6px; border-radius:3px; overflow:hidden; margin-top:6px; }
  .quota-fill { background:var(--accent); height:100%; border-radius:3px; transition:width 0.3s; }
  .quota-fill.warn { background:var(--amber); }
  .quota-fill.full { background:var(--red); }
  .quota-label { font-size:10px; color:var(--text-dim); font-family:var(--mono); margin-top:4px; }
  .dash-section { margin-top:24px; padding-top:20px; border-top:1px solid var(--border); }
  .dash-section > h3 { font-size:13px; font-weight:700; color:var(--text-bright); text-transform:uppercase; letter-spacing:0.5px; margin-bottom:12px; }
  .dash-section > h3 .small { font-weight:400; text-transform:none; letter-spacing:0; color:var(--text-dim); margin-left:8px; font-size:11px; }
  .pattern-i-editor textarea { width:100%; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:13px; font-family:var(--mono); padding:14px 16px; border-radius:10px; resize:vertical; min-height:240px; outline:none; transition:border-color 0.2s; line-height:1.6; }
  .pattern-i-editor textarea:focus { border-color:var(--accent); }
  .pattern-i-actions { display:flex; gap:8px; margin-top:10px; align-items:center; flex-wrap:wrap; }
  .pattern-i-actions .saved-msg { color:var(--text-dim); font-size:11px; font-family:var(--mono); }
  .pattern-i-actions .saved-msg.success { color:var(--green); }
  .pattern-i-actions select { background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:12px; font-family:var(--mono); padding:6px 10px; border-radius:6px; outline:none; cursor:pointer; }
  .pattern-i-actions select:focus { border-color:var(--accent); }
  .connect-panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:14px 16px; }
  .connect-panel > summary { cursor:pointer; list-style:none; outline:none; user-select:none; }
  .connect-panel > summary::marker { display:none; }
  .connect-panel > summary::before { content:'\25B8'; display:inline-block; margin-right:8px; color:var(--text-dim); transition:transform 0.15s; font-size:11px; }
  .connect-panel[open] > summary::before { transform:rotate(90deg); }
  .connect-body { margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
  .connect-intro { font-size:13px; color:var(--text-dim); line-height:1.5; margin-bottom:14px; }
  .connect-intro code { background:var(--surface-2); padding:1px 5px; border-radius:3px; font-size:12px; }
  .connect-step { margin-top:14px; }
  .connect-step-label { font-size:11px; font-weight:600; color:var(--text-bright); text-transform:uppercase; letter-spacing:0.4px; margin-bottom:6px; }
  .connect-code { background:var(--surface-2); border:1px solid var(--border); border-radius:6px; padding:10px 12px; font-family:var(--mono); font-size:12px; color:var(--text); line-height:1.5; overflow-x:auto; white-space:pre; margin:0 0 6px 0; }
  .connect-copy { background:var(--surface-2); border:1px solid var(--border); color:var(--text-dim); font-size:11px; font-family:var(--mono); padding:4px 10px; border-radius:5px; cursor:pointer; }
  .connect-copy:hover { color:var(--accent); border-color:var(--accent); }
  .connect-copy-status { font-size:11px; color:var(--green); font-family:var(--mono); margin-left:8px; }
  .connect-paths { margin-top:18px; padding-top:14px; border-top:1px dashed var(--border); }
  .connect-paths ul { margin:6px 0 0 0; padding-left:18px; }
  .connect-paths li { font-size:12px; color:var(--text-dim); line-height:1.7; }
  .connect-paths li code { background:var(--surface-2); padding:1px 5px; border-radius:3px; font-size:11px; }
  /* Visual peer-break: solid accent rule + extra breathing room so the
     local-agent path doesn't read as "more text" but as a sibling choice
     to the Connect-an-agent block above. The h3 INSIDE this section
     intentionally uses default browser h3 styling (same as the
     <summary><h3>Connect an agent:</h3></summary> above) so both headers
     have equal visual weight when the panel is expanded. */
  .connect-local-section { margin-top:26px; padding-top:20px; border-top:2px solid var(--accent); }
  .connect-close-row { display:flex; justify-content:flex-end; margin-top:22px; padding-top:14px; border-top:1px dashed var(--border); }
  .connect-close-btn { background:var(--surface-2); border:1px solid var(--border); color:var(--text-dim); font-size:11px; font-family:var(--mono); padding:5px 14px; border-radius:5px; cursor:pointer; user-select:none; }
  .connect-close-btn:hover { color:var(--accent); border-color:var(--accent); }
  .connect-local-header { display:block; margin:0 0 8px 0; color:var(--text-bright); }
  .connect-local-header .small { font-weight:400; text-transform:none; letter-spacing:0; color:var(--text-dim); margin-left:8px; font-size:13px; }
  .connect-local-intro { font-size:13px; color:var(--text-dim); line-height:1.55; margin:8px 0 14px 0; }
  .connect-local-intro strong { color:var(--text-bright); }
  .connect-local-intro code { background:var(--surface-2); padding:1px 5px; border-radius:3px; font-size:12px; }
  .connect-local-note { font-size:11px; color:var(--text-dim); margin:6px 0 0 0; line-height:1.5; }
  .connect-local-note code { background:var(--surface-2); padding:1px 4px; border-radius:3px; font-size:10px; }
  .connect-link { color:var(--accent); text-decoration:none; border-bottom:1px dotted var(--accent); }
  .connect-link:hover { border-bottom-style:solid; }
  .connect-download-btn { display:inline-flex; align-items:center; gap:8px; padding:8px 14px; background:var(--accent-glow); border:1px solid var(--accent); color:var(--accent); border-radius:6px; font-family:var(--mono); font-size:12px; text-decoration:none; transition:all 0.2s; }
  .connect-download-btn:hover { background:var(--accent); color:#fff; }
  .connect-download-btn span { font-weight:700; }
  .dispatch-editor textarea { width:100%; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:13px; font-family:var(--mono); padding:12px 14px; border-radius:10px; resize:vertical; min-height:96px; outline:none; transition:border-color 0.2s; line-height:1.5; }
  .dispatch-editor textarea:focus { border-color:var(--accent); }
  .dispatch-actions { display:flex; gap:8px; margin-top:8px; align-items:center; }
  .dispatch-actions .saved-msg { color:var(--text-dim); font-size:11px; font-family:var(--mono); }
  .dispatch-actions .saved-msg.success { color:var(--green); }
  .activity-feed { display:flex; flex-direction:column; gap:6px; max-height:480px; overflow-y:auto; padding-right:4px; }
  .activity-row { display:flex; gap:12px; padding:10px 12px; background:var(--surface); border:1px solid var(--border); border-radius:8px; align-items:flex-start; }
  .activity-row .ts { font-size:10px; color:var(--text-dim); font-family:var(--mono); white-space:nowrap; min-width:120px; padding-top:2px; }
  .activity-row .body { flex:1; min-width:0; }
  .activity-row .body .summary { font-size:13px; color:var(--text); margin-bottom:2px; }
  .activity-row .body .preview { font-size:11px; color:var(--text-dim); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .activity-row .type { font-size:10px; font-weight:700; padding:2px 6px; border-radius:3px; font-family:var(--mono); text-transform:uppercase; letter-spacing:0.3px; flex-shrink:0; }
  .activity-row .type.imprint        { background:var(--accent-glow); color:var(--accent); }
  .activity-row .type.mount          { background:var(--green-glow); color:var(--green); }
  .activity-row .type.pattern_i_update { background:#a855f720; color:#a855f7; }
  .activity-row .type.pattern_update { background:#a855f720; color:#a855f7; }
  .activity-row .type.copy_in        { background:#06b6d420; color:#06b6d4; }
  .activity-row .type.create         { background:#eab30820; color:var(--amber); }
  .activity-row .type.settings_update{ background:var(--surface-2); color:var(--text-dim); }
  .activity-row .type.dispatch       { background:#3b82f620; color:#3b82f6; }
  /* Patterns browser — content view of the Mempack's stored passages. */
  .patterns-controls { display:flex; gap:8px; align-items:center; margin-bottom:12px; flex-wrap:wrap; }
  .patterns-search { flex:1; min-width:200px; background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:12px; padding:8px 12px; border-radius:8px; outline:none; font-family:var(--mono); }
  .patterns-search:focus { border-color:var(--accent); }
  .patterns-tag-filter { background:var(--surface); border:1px solid var(--border); color:var(--text); font-size:12px; font-family:var(--mono); padding:7px 10px; border-radius:6px; outline:none; cursor:pointer; }
  .patterns-tag-filter:focus { border-color:var(--accent); }
  .patterns-count { font-size:11px; color:var(--text-dim); font-family:var(--mono); }
  .patterns-list { display:flex; flex-direction:column; gap:6px; max-height:560px; overflow-y:auto; padding-right:4px; }
  .pattern-row { background:var(--surface); border:1px solid var(--border); border-radius:8px; transition:border-color 0.15s; }
  .pattern-row:hover { border-color:var(--border-hover); }
  .pattern-row.open { border-color:var(--accent); }
  .pattern-header { display:flex; gap:10px; align-items:center; padding:10px 12px; cursor:pointer; user-select:none; }
  .pattern-header .pattern-idx { font-family:var(--mono); font-size:11px; color:var(--text-dim); flex-shrink:0; }
  .pattern-header .pattern-tags { display:flex; gap:4px; flex-wrap:wrap; flex:1; min-width:0; }
  .pattern-tag-chip { font-family:var(--mono); font-size:10px; font-weight:700; padding:2px 6px; border-radius:3px; text-transform:uppercase; letter-spacing:0.3px; background:var(--surface-2); color:var(--text-dim); }
  .pattern-tag-chip.tag-FINDING       { background:var(--accent-glow); color:var(--accent); }
  .pattern-tag-chip.tag-SUMMARY       { background:var(--green-glow); color:var(--green); }
  .pattern-tag-chip.tag-DISPATCH      { background:#3b82f620; color:#3b82f6; }
  .pattern-tag-chip.tag-TASK          { background:#3b82f620; color:#3b82f6; }
  .pattern-tag-chip.tag-METHOD        { background:#eab30820; color:var(--amber); }
  .pattern-tag-chip.tag-EVIDENCE      { background:#06b6d420; color:#06b6d4; }
  .pattern-tag-chip.tag-OPEN_QUESTION { background:#eab30820; color:var(--amber); }
  .pattern-tag-chip.tag-DEAD_END      { background:var(--surface-2); color:var(--text-dim); text-decoration:line-through; }
  .pattern-tag-chip.tag-ACTIVE        { background:#a855f720; color:#a855f7; }
  .pattern-header .pattern-length { font-family:var(--mono); font-size:10px; color:var(--text-dim); flex-shrink:0; }
  .pattern-header .pattern-ts { font-family:var(--mono); font-size:10px; color:var(--text-dim); flex-shrink:0; opacity:0.75; }
  .pattern-header .pattern-caret { color:var(--text-dim); font-size:11px; transition:transform 0.15s; flex-shrink:0; }
  .pattern-row.open .pattern-caret { transform:rotate(90deg); }
  .pattern-preview { padding:0 12px 10px 12px; font-family:var(--mono); font-size:11px; color:var(--text-dim); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
  .pattern-row.open .pattern-preview { display:none; }
  .pattern-body { display:none; padding:10px 12px 12px 12px; border-top:1px solid var(--border); }
  .pattern-row.open .pattern-body { display:block; }
  .pattern-text { background:var(--surface-2); border:1px solid var(--border); border-radius:6px; padding:12px; font-family:var(--mono); font-size:12px; color:var(--text); line-height:1.5; white-space:pre-wrap; word-break:break-word; max-height:400px; overflow-y:auto; margin:0; }
  .pattern-actions { display:flex; gap:6px; margin-top:8px; align-items:center; flex-wrap:wrap; }
  .pattern-copy-btn { font-size:11px; font-family:var(--mono); padding:4px 10px; border-radius:5px; background:var(--surface); border:1px solid var(--border); color:var(--text-dim); cursor:pointer; }
  .pattern-copy-btn:hover { color:var(--accent); border-color:var(--accent); }
  .pattern-copy-btn.copied { color:var(--green); border-color:var(--green); }
  .pattern-actions-sep { color:var(--text-dim); opacity:0.5; }
  .pattern-actions-label { font-size:10px; color:var(--text-dim); font-family:var(--mono); }
  .pattern-row-dl-btn { font-size:10px; padding:3px 8px; }
  .pattern-download-row-status { font-size:10px; color:var(--text-dim); font-family:var(--mono); margin-left:4px; }
  .pattern-download-row-status.success { color:var(--green); }
  .pattern-download-row-status.error   { color:#ef4444; }
  .patterns-pagination { display:flex; justify-content:center; margin-top:10px; }
  .patterns-pagination button { font-size:12px; font-family:var(--mono); padding:6px 16px; border-radius:6px; background:var(--surface); border:1px solid var(--border); color:var(--text-dim); cursor:pointer; }
  .patterns-pagination button:hover { color:var(--accent); border-color:var(--accent); }
  .patterns-download { display:flex; align-items:center; gap:6px; margin-top:14px; padding-top:12px; border-top:1px dashed var(--border); flex-wrap:wrap; }
  .patterns-download-label { font-size:11px; color:var(--text-dim); font-family:var(--mono); margin-right:4px; }
  .patterns-download-btn { font-size:11px; font-family:var(--mono); padding:5px 12px; border-radius:5px; background:var(--surface); border:1px solid var(--border); color:var(--text); cursor:pointer; transition:all 0.15s; }
  .patterns-download-btn:hover { color:var(--accent); border-color:var(--accent); background:var(--surface-2); }
  .patterns-download-btn:disabled { opacity:0.5; cursor:wait; }
  .patterns-download-status { font-size:11px; color:var(--text-dim); font-family:var(--mono); margin-left:6px; }
  .patterns-download-status.success { color:var(--green); }
  .patterns-download-status.error   { color:#ef4444; }
  /* "Previous version" expander on pattern_i_update / pattern_update rows. */
  .prev-version-toggle { display:inline-flex; align-items:center; gap:4px; background:transparent; border:1px solid var(--border); color:var(--text-dim); font-size:11px; font-family:var(--mono); padding:3px 8px; border-radius:4px; margin-top:6px; cursor:pointer; transition:all 0.15s; }
  .prev-version-toggle:hover { color:var(--accent); border-color:var(--accent); }
  .prev-version-toggle .caret { transition:transform 0.15s; }
  .prev-version-toggle.open .caret { transform:rotate(90deg); }
  .prev-version-block { display:none; margin-top:8px; }
  .prev-version-block.open { display:block; }
  .prev-version-text { padding:10px 12px; background:var(--surface-2); border:1px solid var(--border); border-radius:6px; font-family:var(--mono); font-size:11px; color:var(--text-dim); white-space:pre-wrap; word-break:break-word; max-height:320px; overflow-y:auto; margin:0; }
  .prev-version-truncated-note { display:block; font-size:10px; color:var(--amber); margin-bottom:6px; }
  .prev-version-actions { display:flex; gap:6px; margin-top:6px; }
  .prev-version-copy { font-size:10px; font-family:var(--mono); padding:3px 8px; border-radius:4px; background:var(--surface); border:1px solid var(--border); color:var(--text-dim); cursor:pointer; }
  .prev-version-copy:hover { color:var(--accent); border-color:var(--accent); }
  .prev-version-copy.copied { color:var(--green); border-color:var(--green); }
  .settings-row { display:flex; align-items:center; gap:10px; margin-bottom:10px; font-size:13px; color:var(--text); }
  .settings-row input[type=checkbox] { width:16px; height:16px; cursor:pointer; accent-color:var(--accent); }
  .settings-row label { cursor:pointer; }
  .settings-row .hint { font-size:11px; color:var(--text-dim); margin-left:8px; }
  .dash-empty { text-align:center; padding:40px 20px; color:var(--text-dim); font-size:13px; }
  .dash-empty .icon { font-size:36px; margin-bottom:8px; opacity:0.4; }
</style>
</head>
<body>
<div class="app">
  <div class="brand">
    <div class="brand-icon"><img src="{{ICON_DATA_URI}}" alt="Membot"></div>
    <div class="brand-text">
      <h1>Mem<span>bot</span></h1>
      <p>Brain Cartridge Memory System</p>
    </div>
    <button class="theme-toggle" id="themeBtn" onclick="toggleTheme()" title="Toggle light/dark">&#x2600;</button>
    <div class="status-bar" id="statusBar">
      <div class="status-dot" id="statusDot"></div>
      <span id="statusText">Connecting...</span>
    </div>
    <div class="auth-chip" id="authChip"><!-- populated by renderAuthChip() --></div>
  </div>
  <div class="view-tabs" role="tablist">
    <button class="view-tab active" data-view="search" onclick="setView('search')">Search</button>
    <button class="view-tab" data-view="mempack" onclick="setView('mempack')">Mempack</button>
  </div>
  <p class="intro" id="searchIntro"><strong>Self-contained semantic search without the LLM.</strong> Click a cart to mount it, then search with plain English &mdash; no generation, no hallucinations.</p>
  <div class="cart-bar" id="cartBar"><div class="cart-chip"><div class="name" style="color:var(--text-dim)">Loading...</div></div></div>
  <div class="search-box">
    <span class="search-icon">&#x1F50D;</span>
    <input type="text" id="searchInput" placeholder="Search your memory..." onkeydown="if(event.key==='Enter')doSearch()">
    <button onclick="doSearch()">Search</button>
  </div>
  <div class="search-meta" id="searchMeta"></div>
  <div class="loading" id="loadingEl"><div class="spinner"></div> Searching...</div>
  <div class="results" id="resultsEl">
    <div class="empty-state"><div class="icon">&#x1F9E0;</div><p>Search your brain cartridge</p><div class="hint" style="font-size:12px;margin-top:8px">Type a query to find memories by meaning</div></div>
  </div>
  <div class="passage-overlay" id="passageOverlay" onclick="if(event.target===this)closePassage()">
    <div class="passage-modal">
      <div class="passage-modal-header">
        <div><span class="modal-rank" id="modalRank"></span><span class="modal-score" id="modalScore"></span></div>
        <button class="modal-close" onclick="closePassage()" title="Close">&#x2715;</button>
      </div>
      <div class="passage-modal-body"><div class="passage-text" id="modalText"></div></div>
      <div class="passage-modal-cta" id="modalCta" style="display:none"></div>
      <div class="passage-modal-source" id="modalSource" style="display:none"></div>
      <div class="passage-modal-footer">
        <button class="passage-nav-btn" id="btnPrev" title="Previous chunk (when metadata wired)">&#x25C0; Prev</button>
        <button class="passage-nav-btn" id="btnNext" title="Next chunk (when metadata wired)">Next &#x25B6;</button>
      </div>
    </div>
  </div>
  <div class="store-section" id="storeSection">
    <h2>Store a Memory</h2>
    <div class="store-box">
      <textarea id="storeContent" placeholder="Paste or type content to store..." rows="4"></textarea>
      <div class="store-row">
        <input type="text" id="storeTags" placeholder="Tags (optional): ARCHITECTURE, JOURNAL, ...">
        <button onclick="doStore()">Store</button>
      </div>
    </div>
  </div>
  <div id="mempackView" style="display:none">
    <p class="intro"><strong>Mempack &mdash; portable per-agent memory.</strong> Each agent gets its own writable cart that travels with it. This dashboard is your home base for what your agent has been doing on your behalf.</p>
    <div class="dash-row" id="ownerRow">
      <div class="owner-display">
        <div class="owner-email-line">
          <span class="owner-email-key">Signed in as</span>
          <span class="owner-email" id="ownerEmailLabel">(detecting&hellip;)</span>
        </div>
        <input type="text" id="ownerUuid" class="uuid-input uuid-input--secondary" placeholder="UUID will appear once signed in" title="Internal owner UUID (used by the API)" readonly>
      </div>
      <button onclick="loadMempacks()">Reload</button>
      <button class="secondary" onclick="overrideOwner()" title="Override owner UUID (admin/debug)">Override</button>
    </div>
    <div id="mempackList" class="mempack-list"></div>
    <div id="mempackDetail" style="display:none">
      <div class="dash-section">
        <details class="connect-panel" id="connectPanel">
          <summary><h3 style="display:inline-block; margin:0;">Connect an agent: <span class="small">Mount this Mempack from Claude Desktop, Cursor, Claude Code, Windsurf, &hellip;</span></h3></summary>
          <div class="connect-body">
            <p class="connect-intro">Your dispatched tasks sit in this Mempack until an MCP-aware client mounts it. Drop the snippet below into your client's <code>mcp.json</code>, restart it, then ask the agent to mount your Mempack and check for dispatches.</p>
            <div class="connect-step">
              <div class="connect-step-label">1. Add this to your client's mcp.json</div>
              <pre class="connect-code" id="connectMcpJson"></pre>
              <button class="connect-copy" onclick="copyConnectSnippet('mcpJson')">Copy snippet</button>
              <span class="connect-copy-status" id="connectMcpJsonStatus"></span>
            </div>
            <div class="connect-step">
              <div class="connect-step-label">2. Restart your client, then prompt the agent</div>
              <pre class="connect-code" id="connectPrompt"></pre>
              <button class="connect-copy" onclick="copyConnectSnippet('prompt')">Copy prompt</button>
              <span class="connect-copy-status" id="connectPromptStatus"></span>
            </div>
            <div class="connect-paths">
              <div class="connect-step-label">Common config paths</div>
              <ul>
                <li><strong>Claude Desktop:</strong> <code>%APPDATA%\Claude\claude_desktop_config.json</code> (Win) / <code>~/Library/Application Support/Claude/claude_desktop_config.json</code> (Mac)</li>
                <li><strong>Cursor:</strong> <code>~/.cursor/mcp.json</code></li>
                <li><strong>Claude Code:</strong> <code>.mcp.json</code> (project) or <code>~/.claude/mcp.json</code> (global)</li>
                <li><strong>Windsurf:</strong> <code>~/.codeium/windsurf/mcp_config.json</code></li>
              </ul>
            </div>
            <div class="connect-local-section">
              <h3 class="connect-local-header">Or &mdash; Run a fully local agent: <span class="small">Bring your own Ollama model. No API key. No subscription. Just Python + Ollama.</span></h3>
              <p class="connect-local-intro">
                <strong>Mempack Local Agent</strong> is a small Python script that wires a local Ollama model
                to your Mempack via MCP. The script handles the tool-call loop end-to-end.
              </p>
              <div class="connect-step">
                <div class="connect-step-label">1. Install Ollama and pull a tool-capable model</div>
                <pre class="connect-code">ollama pull qwen2.5:14b   # or hermes3:8b, qwen2.5-coder:14b, llama3.1:8b
pip install requests truststore   # truststore only needed on Win w/ corporate AV</pre>
                <p class="connect-local-note">Download Ollama: <a href="https://ollama.com/download" target="_blank" rel="noopener" class="connect-link">ollama.com/download</a></p>
              </div>
              <div class="connect-step">
                <div class="connect-step-label">2. Download the Python harness</div>
                <a id="connectLocalDownload" class="connect-download-btn" href="#" download="mempack_local_agent.py">
                  <span>&darr;</span> mempack_local_agent.py
                </a>
                <span class="connect-local-note">~14 KB &middot; single file &middot; one dep (<code>requests</code>) + optional <code>truststore</code></span>
              </div>
              <div class="connect-step">
                <div class="connect-step-label">3. Run it with your owner_id baked in</div>
                <pre class="connect-code" id="connectLocalCommand"></pre>
                <button class="connect-copy" onclick="copyConnectSnippet('localCommand')">Copy command</button>
                <span class="connect-copy-status" id="connectLocalCommandStatus"></span>
              </div>
            </div>
            <div class="connect-close-row">
              <button type="button" class="connect-close-btn" onclick="document.getElementById('connectPanel').removeAttribute('open')">Close &#9650;</button>
            </div>
          </div>
        </details>
      </div>
      <div class="dash-section">
        <h3>Dispatch <span class="small">Queue a task for your agent</span></h3>
        <div class="dispatch-editor">
          <textarea id="dispatchText" placeholder="Describe a task. It's saved to your Mempack tagged DISPATCH; your agent surfaces it on mount and acks before starting work."></textarea>
          <div class="dispatch-actions">
            <button onclick="doDispatch()">Send Dispatch</button>
            <span class="saved-msg" id="dispatchStatus"></span>
          </div>
        </div>
      </div>
      <div class="dash-section">
        <h3>Pattern I &mdash; Behavior <span class="small" id="patternIMeta"></span></h3>
        <div class="pattern-i-editor">
          <textarea id="patternIText" placeholder="Pattern I bootstraps the agent on every mount. Write the agent's identity, behavior rules, learned preferences..."></textarea>
          <div class="pattern-i-actions">
            <button onclick="savePatternI()">Save Pattern I</button>
            <select id="templateSelect" onchange="onTemplatePick()" title="Load a Pattern I starter template">
              <option value="">Load template…</option>
            </select>
            <span class="saved-msg" id="patternIStatus"></span>
          </div>
        </div>
      </div>
      <div class="dash-section">
        <h3>Patterns <span class="small" id="patternsMeta">browse this Mempack&rsquo;s content</span></h3>
        <div class="patterns-controls">
          <input type="text" id="patternsSearchInput" placeholder="Filter by text&hellip;" class="patterns-search" oninput="onPatternsSearchInput()">
          <select id="patternsTagFilter" class="patterns-tag-filter" onchange="onPatternsTagChange()">
            <option value="">All tags</option>
            <option value="FINDING">FINDING</option>
            <option value="SUMMARY">SUMMARY</option>
            <option value="DISPATCH">DISPATCH</option>
            <option value="TASK">TASK</option>
            <option value="METHOD">METHOD</option>
            <option value="EVIDENCE">EVIDENCE</option>
            <option value="OPEN_QUESTION">OPEN_QUESTION</option>
            <option value="DEAD_END">DEAD_END</option>
            <option value="ACTIVE">ACTIVE</option>
          </select>
          <span class="patterns-count" id="patternsCount"></span>
        </div>
        <div id="patternsList" class="patterns-list"></div>
        <div class="patterns-pagination" id="patternsPagination" style="display:none">
          <button onclick="loadMorePatterns()">Load more</button>
        </div>
        <div class="patterns-download" id="patternsDownload" style="display:none">
          <span class="patterns-download-label">Download current view:</span>
          <button class="patterns-download-btn" onclick="downloadPatterns('md')">.md</button>
          <button class="patterns-download-btn" onclick="downloadPatterns('txt')">.txt</button>
          <button class="patterns-download-btn" onclick="downloadPatterns('docx')">.docx</button>
          <button class="patterns-download-btn" onclick="downloadPatterns('pdf')">.pdf</button>
          <span class="patterns-download-status" id="patternsDownloadStatus"></span>
        </div>
      </div>
      <div class="dash-section">
        <h3>Activity <span class="small" id="activityMeta">polling every 5s</span></h3>
        <div id="activityFeed" class="activity-feed"></div>
      </div>
      <div class="dash-section">
        <h3>Settings</h3>
        <div class="settings-row">
          <input type="checkbox" id="loggingToggle" onchange="toggleLogging(this.checked)">
          <label for="loggingToggle">Activity logging enabled</label>
          <span class="hint">When off, agent actions stop appearing in the feed.</span>
        </div>
      </div>
    </div>
  </div>
  <div class="app-footer">Membot &mdash; Neuromorphic memory for AI agents &mdash; <a href="https://github.com/project-you-apps/membot">GitHub</a></div>
</div>
<div class="toast" id="toastEl"></div>
<script>
const $=s=>document.querySelector(s);
let _mounted=null, _memories=0;
function applyReadOnly(ro){
  const ta=$('#storeContent'),btn=document.querySelector('.store-row button'),ti=$('#storeTags');
  if(ro){
    if(ta){ta.value='THIS SERVICE NOT YET AVAILABLE';ta.disabled=true;ta.style.opacity='0.5';}
    if(ti){ti.disabled=true;ti.style.opacity='0.5';}
    if(btn){btn.disabled=true;btn.style.opacity='0.5';btn.style.cursor='not-allowed';}
  }else{
    if(ta){ta.value='';ta.disabled=false;ta.style.opacity='1';}
    if(ti){ti.disabled=false;ti.style.opacity='1';}
    if(btn){btn.disabled=false;btn.style.opacity='1';btn.style.cursor='pointer';}
  }
}
const BASE=()=>{
  const loc=location.pathname.replace(/\\/app\\/?$/,'');
  return location.protocol==='file:'?'http://137.184.227.79:8000':location.origin+loc;
};
async function checkStatus(){
  const dot=$('#statusDot'),txt=$('#statusText');
  try{
    const r=await fetch(BASE()+'/api/status',{signal:AbortSignal.timeout(5000)});
    const d=await r.json();
    dot.className='status-dot connected';
    _mounted=d.cartridge; _memories=d.memories||0;
    txt.textContent=(_mounted||'No cart')+' ('+_memories.toLocaleString()+' memories)';
    applyReadOnly(d.read_only);
  }catch(e){ dot.className='status-dot error'; txt.textContent='Disconnected'; }
}
async function loadCartridges(){
  const bar=$('#cartBar');
  try{
    const [cr,sr]=await Promise.all([
      fetch(BASE()+'/api/cartridges',{signal:AbortSignal.timeout(5000)}).then(r=>r.json()),
      fetch(BASE()+'/api/status',{signal:AbortSignal.timeout(5000)}).then(r=>r.json())
    ]);
    _mounted=sr.cartridge; _memories=sr.memories||0;
    const dot=$('#statusDot'),txt=$('#statusText');
    dot.className='status-dot connected';
    txt.textContent=(_mounted||'No cart')+' ('+_memories.toLocaleString()+' memories)';
    applyReadOnly(sr.read_only);
    const carts=cr.cartridges||[];
    if(carts.length===0){bar.innerHTML='<div class="cart-chip"><div class="name" style="color:var(--text-dim)">No cartridges found</div></div>';return;}
    bar.innerHTML=carts.map(c=>{
      const active=_mounted&&c.name===_mounted;
      const cls='cart-chip'+(active?' active':'');
      const mem=active?' &middot; <span class="count">'+_memories.toLocaleString()+' memories</span>':'';
      return '<div class="'+cls+'" data-name="'+esc(c.name)+'"><div class="name">'+esc(c.name)+'</div><div class="meta">'+c.size_mb+' MB &middot; '+c.format.toUpperCase()+(c.has_brain?' &middot; GPU':'')+mem+'</div></div>';
    }).join('');
    bar.querySelectorAll('.cart-chip').forEach(el=>el.addEventListener('click',()=>mountCart(el.dataset.name)));
  }catch(e){bar.innerHTML='<div class="cart-chip"><div class="name" style="color:var(--red)">Connection failed</div></div>';}
}
async function mountCart(name){
  if(name===_mounted)return;
  $('#resultsEl').innerHTML='<div class="empty-state"><div class="icon">&#x1F9E0;</div><p>Search your brain cartridge</p><div class="hint" style="font-size:12px;margin-top:8px">Type a query to find memories by meaning</div></div>';
  $('#searchMeta').textContent=''; _lastResults=[]; _lastQuery='';
  const chips=document.querySelectorAll('.cart-chip');
  chips.forEach(c=>{if(c.dataset.name===name)c.classList.add('mounting');});
  toast('Mounting '+name+'...');
  try{
    const r=await fetch(BASE()+'/api/mount',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});
    const d=await r.json();
    if(d.error){toast(d.error,'error');}
    else{toast('Mounted '+name,'success');}
    await loadCartridges();
  }catch(e){toast('Mount failed: '+e.message,'error');chips.forEach(c=>c.classList.remove('mounting'));}
}
var _lastResults=[], _lastQuery='';
async function doSearch(){
  const query=$('#searchInput').value.trim();
  if(!query)return;
  _lastQuery=query;
  const el=$('#resultsEl'),loading=$('#loadingEl'),meta=$('#searchMeta');
  el.innerHTML=''; loading.className='loading active'; meta.textContent='';
  const t0=performance.now();
  try{
    const r=await fetch(BASE()+'/api/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({query,top_k:8})});
    const data=await r.json();
    const elapsed=Math.round(performance.now()-t0);
    loading.className='loading';
    var items=data.results;
    if(!items&&typeof data.result==='string'){
      items=[];
      var lines=data.result.split(/\\n\\n/);
      for(var li=0;li<lines.length;li++){
        var m=lines[li].match(/^#\\d+\\s+\\[([\\d.]+)\\]\\s+(.*)/s);
        if(m)items.push({score:parseFloat(m[1]),text:m[2],full_text:m[2],tags:''});
      }
    }
    _lastResults=items||[];
    console.log('[HIPPO] Search results:', JSON.stringify(items?.map(r=>({idx:r.index,prev:r.prev_idx,next:r.next_idx}))));
    const haystack=_memories?' of '+_memories.toLocaleString():'';
    if(!items||items.length===0){
      el.innerHTML='<div class="empty-state"><div class="icon">&#x1F914;</div><p>No results found</p></div>';
      meta.textContent='0 results'+haystack+' in '+elapsed+'ms'; return;
    }
    meta.textContent=items.length+haystack+' results in '+elapsed+'ms';
    el.innerHTML=items.map((item,i)=>{
      const text=esc(item.text||item.content||'');
      const score=item.score!=null?item.score.toFixed(4):'';
      const tags=item.tags?item.tags.split(',').map(t=>'<span class="tag">'+esc(t.trim())+'</span>').join(''):'';
      const provBits=[];
      if(item.source_db)provBits.push(esc(item.source_db));
      if(item.paper_id)provBits.push('id: '+esc(item.paper_id));
      const prov=provBits.length?'<span class="prov">'+provBits.join(' · ')+'</span>':'';
      return '<div class="result-card" data-idx="'+i+'"><div class="result-header"><span class="result-rank">#'+(i+1)+'</span>'+(score?'<span class="result-score">score: '+score+'</span>':'')+'</div><div class="result-text">'+highlight(linkify(text),query)+'</div>'+((tags||prov)?'<div class="result-footer">'+tags+prov+'</div>':'')+'</div>';
    }).join('');
    el.querySelectorAll('.result-card').forEach(c=>c.addEventListener('click',()=>openPassage(parseInt(c.dataset.idx))));
  }catch(e){ loading.className='loading'; el.innerHTML='<div class="empty-state"><div class="icon">&#x26A0;</div><p>Search failed</p><div style="font-size:12px;margin-top:8px">'+esc(e.message)+'</div></div>'; }
}
var _currentPassage=null;
function openPassage(resultIdx){
  if(resultIdx<0||resultIdx>=_lastResults.length)return;
  const item=_lastResults[resultIdx];
  console.log('[HIPPO] openPassage resultIdx='+resultIdx+' item.index='+item.index+' prev_idx='+item.prev_idx+' next_idx='+item.next_idx, item);
  _showPassage({index:item.index,full_text:item.full_text||item.text||'',prev_idx:item.prev_idx,next_idx:item.next_idx,score:item.score,rank:resultIdx+1,paper_id:item.paper_id,source_db:item.source_db});
}
function _showPassage(p){
  _currentPassage=p;
  $('#modalRank').textContent=p.rank?'#'+p.rank:'idx:'+p.index;
  $('#modalScore').textContent=p.score!=null?'score: '+p.score.toFixed(4):'';
  $('#modalText').innerHTML=highlight(linkify(esc(p.full_text)),_lastQuery);
  // Provenance state machine: split-cart preview (source_db, no paper_id) shows the
  // load CTA; loaded state (paper_id present) shows the source line; non-split shows
  // neither.
  const isSplit=!!p.source_db;
  const isLoaded=!!p.paper_id;
  const cta=$('#modalCta'),src=$('#modalSource');
  if(cta){
    if(isSplit&&!isLoaded){
      cta.innerHTML='<button class="modal-cta-btn" onclick="loadSource()">&#x1F4C2; Load full passage from '+esc(p.source_db)+' &rarr;</button>';
      cta.style.display='block';
    } else { cta.style.display='none'; cta.innerHTML=''; }
  }
  if(src){
    if(isLoaded){
      const bits=['source: '+p.source_db];
      if(p.paper_id)bits.push('id: '+p.paper_id);
      src.textContent=bits.join(' · ');
      src.style.display='block';
    } else { src.style.display='none'; }
  }
  const btnP=$('#btnPrev'),btnN=$('#btnNext');
  if(p.prev_idx!=null){btnP.className='passage-nav-btn enabled';btnP.onclick=()=>navigatePassage(p.prev_idx);}
  else{btnP.className='passage-nav-btn';btnP.onclick=null;}
  if(p.next_idx!=null){btnN.className='passage-nav-btn enabled';btnN.onclick=()=>navigatePassage(p.next_idx);}
  else{btnN.className='passage-nav-btn';btnN.onclick=null;}
  $('#passageOverlay').classList.add('open');
  document.addEventListener('keydown',_passageKeys);
}
async function loadSource(){
  if(!_currentPassage)return;
  const cta=$('#modalCta'),idx=_currentPassage.index;
  if(cta)cta.innerHTML='<span class="modal-cta-loading">Loading from source database&hellip;</span>';
  try{
    const r=await fetch(BASE()+'/api/passage?idx='+idx);
    const data=await r.json();
    if(data.error){toast(data.error,'error');return;}
    const p=data.passage;
    _showPassage({index:p.index,full_text:p.full_text,prev_idx:p.prev_idx,next_idx:p.next_idx,score:_currentPassage.score,rank:_currentPassage.rank,source_db:p.source_db,paper_id:p.paper_id});
  }catch(e){
    toast('Source load failed: '+e.message,'error');
    if(cta)cta.innerHTML='<button class="modal-cta-btn" onclick="loadSource()">Retry</button>';
  }
}
async function navigatePassage(idx){
  try{
    const r=await fetch(BASE()+'/api/passage?idx='+idx);
    const data=await r.json();
    if(data.error){toast(data.error,'error');return;}
    const p=data.passage;
    _showPassage({index:p.index,full_text:p.full_text,prev_idx:p.prev_idx,next_idx:p.next_idx,score:null,rank:null,source_db:p.source_db,paper_id:p.paper_id});
  }catch(e){toast('Navigation failed: '+e.message,'error');}
}
function closePassage(){
  $('#passageOverlay').classList.remove('open');
  _currentPassage=null;
  document.removeEventListener('keydown',_passageKeys);
}
function _passageKeys(e){
  if(e.key==='Escape')closePassage();
  if(e.key==='ArrowLeft'&&_currentPassage&&_currentPassage.prev_idx!=null)navigatePassage(_currentPassage.prev_idx);
  if(e.key==='ArrowRight'&&_currentPassage&&_currentPassage.next_idx!=null)navigatePassage(_currentPassage.next_idx);
}
async function doStore(){
  const content=$('#storeContent').value.trim();
  const tags=$('#storeTags').value.trim();
  if(!content){toast('Enter content to store','error');return;}
  try{
    const r=await fetch(BASE()+'/api/store',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content,tags})});
    const data=await r.json();
    if(data.error){toast(data.error,'error');}
    else{toast('Memory stored','success');$('#storeContent').value='';$('#storeTags').value='';checkStatus();}
  }catch(e){toast('Store failed: '+e.message,'error');}
}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
const _STOP=new Set(['the','and','are','was','were','for','that','this','with','from','not','but','has','had','have','does','did','will','can','its','who','what','how','why','about','tell','you','your','some','than','them','then','they','been','more','also','into','would','could','should','just','like','very','much','many','only','other','over','such','after','before','between','through','where','when','which','while','each','there','their','these','those','being','because','during','both','same','own','most','well','way','all','out','one','two','may']);
function highlight(html,query){
  if(!query)return html;
  const words=query.split(/\\s+/).filter(w=>w.length>2&&!_STOP.has(w.toLowerCase()));
  let r=html;
  // Negative lookahead (?![^<]*>) skips matches inside open tags / attribute values
  // (e.g. inside href="https://arxiv.org") so URL hrefs don't break.
  for(const w of words){const re=new RegExp('('+w.replace(/[.*+?^${}()|[\\]\\\\]/g,'\\\\$&')+')(?![^<]*>)','gi');r=r.replace(re,'<mark>$1</mark>');}
  return r;
}
function linkify(escapedText){
  // Wrap http(s) URLs in clickable links. Input must already be HTML-escaped.
  return escapedText.replace(/(https?:\\/\\/[^\\s<]+?)([.,;:!?)\\]]?(?=\\s|$|<))/g,'<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>$2');
}
function toast(msg,type='success'){
  const el=$('#toastEl');el.textContent=msg;el.className='toast '+type+' show';
  setTimeout(()=>el.className='toast',3000);
}
function toggleTheme(){
  const html=document.documentElement;
  const next=html.getAttribute('data-theme')==='light'?'dark':'light';
  html.setAttribute('data-theme',next);
  $('#themeBtn').innerHTML=next==='light'?'&#x1F319;':'&#x2600;';
  localStorage.setItem('membot-theme',next);
}
(function(){const s=localStorage.getItem('membot-theme');if(s==='light'){document.documentElement.setAttribute('data-theme','light');setTimeout(()=>$('#themeBtn').innerHTML='&#x1F319;',0);}})();
loadCartridges();

/* =========================================================
 * Mempack Dashboard
 *   - View toggle (Search vs Mempack)
 *   - Owner UUID persistence in localStorage
 *   - Mempack list + selection
 *   - Pattern I editor (POST /api/mempack/<id>/pattern-i)
 *   - Activity feed (GET /api/mempack/<id>/activity, polling)
 *   - Settings toggle (PATCH /api/mempack/<id>)
 * ========================================================= */
const _SEARCH_VIEW_IDS = ['searchIntro','cartBar','storeSection'];
const _SEARCH_VIEW_SELECTORS = ['.search-box','.search-meta','#loadingEl','#resultsEl'];
let _currentMempack = null;     // currently selected mempack row
let _activitySinceTs = null;    // pagination cursor for delta polling
let _activityPollHandle = null; // setInterval handle
const _ACTIVITY_POLL_MS = 5000;

async function setView(name){
  const tabs = document.querySelectorAll('.view-tab');
  tabs.forEach(t => t.classList.toggle('active', t.dataset.view === name));
  const showSearch = (name === 'search');
  const showMempack = (name === 'mempack');
  _SEARCH_VIEW_IDS.forEach(id => { const el = document.getElementById(id); if(el) el.style.display = showSearch ? '' : 'none'; });
  _SEARCH_VIEW_SELECTORS.forEach(sel => { const el = document.querySelector(sel); if(el) el.style.display = showSearch ? '' : 'none'; });
  const mv = document.getElementById('mempackView'); if(mv) mv.style.display = showMempack ? '' : 'none';
  if (showMempack) {
    // Server-side identity check — Supabase cookies are HttpOnly so JS can't
    // read them, but they're auto-sent with same-origin fetches. /api/whoami
    // reads them server-side and tells us who's signed in.
    const detected = await detectSupabaseUser();
    const override = localStorage.getItem('membot-owner-override');
    const uuid = override || detected;
    if (uuid && $('#ownerUuid').value !== uuid) {
      $('#ownerUuid').value = uuid;
      loadMempacks();
    } else if (!uuid) {
      $('#ownerUuid').placeholder = 'No Supabase session detected. Sign in at project-you.app or use Override.';
      $('#mempackList').innerHTML = '<div class="dash-empty"><div class="icon">&#x1F511;</div>No Supabase session in this browser.<br><small>Sign in at <a href="https://project-you.app/" style="color:var(--accent)">project-you.app</a> first, then return.</small></div>';
    }
    loadTemplates();  // populates the Pattern I template dropdown (cached)
    if (_currentMempack) startActivityPoll();
  } else {
    stopActivityPoll();
  }
  localStorage.setItem('membot-active-view', name);
}

/* Identify the signed-in user via the server, since Supabase cookies are
 * HttpOnly and not readable from document.cookie. The cookie IS sent with
 * every same-origin fetch automatically, so /api/whoami can read it
 * server-side and tell us the user_id + email + avatar + name.
 *
 * getWhoami() returns the full {signed_in, user_id, email, full_name,
 * avatar_url} object; detectSupabaseUser() is a back-compat wrapper that
 * returns just the user_id string (or null) for callers that haven't been
 * upgraded yet.
 */
let _whoamiCache = { ts: 0, whoami: null };
const _WHOAMI_CACHE_MS = 3000;
async function getWhoami(){
  const now = Date.now();
  if (now - _whoamiCache.ts < _WHOAMI_CACHE_MS) return _whoamiCache.whoami;
  try {
    const r = await fetch(BASE() + '/api/whoami', { credentials: 'same-origin' });
    const d = await r.json();
    _whoamiCache = { ts: now, whoami: d };
    if (!d || !d.user_id) console.warn('[membot] getWhoami: server reports no signed-in user (cookie missing or invalid JWT)');
    return d;
  } catch(e) {
    console.warn('[membot] getWhoami /api/whoami failed:', e);
    return null;
  }
}
async function detectSupabaseUser(){
  const w = await getWhoami();
  return w && w.user_id ? w.user_id : null;
}

/* =========================================================
 * Auth chip (Option D — sign-in via popup to /vps/app)
 *   - Signed out: "Sign in" button → opens a popup to project-you.app
 *   - Signed in:  avatar (or initial) → dropdown with email + Profile (soon) + Sign out
 *   - Popup-open mode polls /api/whoami fast (every 2s); reverts to slow poll on close
 * ========================================================= */
let _authMenuOpen = false;
let _signinPopupRef = null;
let _signinPopupPoll = null;

function renderOwnerEmailLabel(whoami){
  // Updates the "Signed in as <email>" label at the top of the Mempack tab.
  // Falls through to "Anonymous" italic when no session is detected.
  const el = document.getElementById('ownerEmailLabel');
  if (!el) return;
  if (whoami && whoami.signed_in) {
    el.textContent = whoami.email || whoami.full_name || (whoami.user_id ? whoami.user_id.slice(0, 8) : 'Signed in');
    el.classList.remove('anonymous');
  } else {
    el.textContent = 'Anonymous';
    el.classList.add('anonymous');
  }
}

function renderAuthChip(whoami){
  // Side-effect: also refresh the owner-email label since both surfaces are
  // informed by the same whoami payload.
  renderOwnerEmailLabel(whoami);
  const chip = document.getElementById('authChip');
  if (!chip) return;
  const signedIn = whoami && whoami.signed_in;
  if (!signedIn) {
    chip.innerHTML =
      '<button class="auth-signin-btn" onclick="doSignIn()" title="Sign in to access your Mempack">'
      + '<span>&#x1F511;</span> Sign in'
      + '</button>';
    _authMenuOpen = false;
    return;
  }
  const email = whoami.email || '(no email)';
  const name = whoami.full_name || email;
  const avatar = whoami.avatar_url;
  const initial = (whoami.email && whoami.email[0] ? whoami.email[0] : (whoami.user_id || '?')[0]).toUpperCase();
  const avatarHtml = avatar
    ? '<img src="' + esc(avatar) + '" alt="">'
    : '<span class="auth-initial">' + esc(initial) + '</span>';
  const menuHtml = _authMenuOpen
    ? ('<div class="auth-menu" id="authMenu">'
      +   '<div class="auth-menu-header">'
      +     '<div class="label">Signed in as</div>'
      +     '<div class="email" title="' + esc(name) + '">' + esc(email) + '</div>'
      +   '</div>'
      +   '<button class="auth-menu-item disabled" onclick="event.stopPropagation()">'
      +     '<span>&#x1F464;</span> Profile <span class="soon-badge">soon</span>'
      +   '</button>'
      +   '<button class="auth-menu-item danger" onclick="doSignOut()">'
      +     '<span>&#x21AA;</span> Sign out'
      +   '</button>'
      + '</div>')
    : '';
  chip.innerHTML =
    '<button class="auth-avatar-btn" onclick="toggleAuthMenu(event)" title="' + esc(name) + '">'
    + avatarHtml
    + '</button>'
    + menuHtml;
}

async function refreshAuthChip(){
  // Bypass cache so we see current state
  _whoamiCache = { ts: 0, whoami: null };
  const w = await getWhoami();
  renderAuthChip(w);
  return w;
}

function toggleAuthMenu(ev){
  if (ev) ev.stopPropagation();
  _authMenuOpen = !_authMenuOpen;
  // Re-render with cached state; no network round-trip
  renderAuthChip(_whoamiCache.whoami);
}

// Close menu on outside click
document.addEventListener('mousedown', (ev) => {
  if (!_authMenuOpen) return;
  const chip = document.getElementById('authChip');
  if (chip && !chip.contains(ev.target)) {
    _authMenuOpen = false;
    renderAuthChip(_whoamiCache.whoami);
  }
});

function doSignIn(){
  // Stamp APP_ID so the central auth router (project-you.app/) lands the user
  // back at /membot/app/ after sign-in completes inside VPS's modal.
  try { localStorage.setItem('auth_return_app', 'membot'); } catch(_) {}

  const url = 'https://project-you.app/vps/app/';
  const w = 480;
  const h = 720;
  const left = (screen.width  - w) / 2;
  const top  = (screen.height - h) / 2;
  const features = [
    'width=' + w, 'height=' + h,
    'left=' + left, 'top=' + top,
    'resizable=yes', 'scrollbars=yes', 'status=no', 'toolbar=no', 'menubar=no',
  ].join(',');
  _signinPopupRef = window.open(url, 'membot-signin', features);
  if (!_signinPopupRef) {
    toast('Popup blocked — allow popups for project-you.app, or sign in at project-you.app/vps/app and come back', 'error');
    return;
  }
  _signinPopupRef.focus();

  // While the popup is open, poll /api/whoami every 2s. When the cookie
  // shows signed-in, update the chip and stop. When the popup closes (with
  // or without success), do one more refresh and stop.
  if (_signinPopupPoll) clearInterval(_signinPopupPoll);
  let ticks = 0;
  _signinPopupPoll = setInterval(async () => {
    ticks += 1;
    const popupClosed = !_signinPopupRef || _signinPopupRef.closed;
    const w = await refreshAuthChip();
    const signedIn = w && w.signed_in;
    if (signedIn) {
      // Got auth — close popup if still open, stop polling, refresh dependent UI
      if (_signinPopupRef && !_signinPopupRef.closed) {
        try { _signinPopupRef.close(); } catch(_) {}
      }
      clearInterval(_signinPopupPoll);
      _signinPopupPoll = null;
      _signinPopupRef = null;
      toast('Signed in as ' + (w.email || 'user'), 'success');
      // Kick the cookie watcher so Mempack tab re-loads under the new user
      _userWatchTick();
    } else if (popupClosed) {
      clearInterval(_signinPopupPoll);
      _signinPopupPoll = null;
      _signinPopupRef = null;
    } else if (ticks > 300) {
      // 10-minute hard cap — give up polling even if popup still open
      clearInterval(_signinPopupPoll);
      _signinPopupPoll = null;
    }
  }, 2000);
}

async function doSignOut(){
  _authMenuOpen = false;
  try {
    const r = await fetch(BASE() + '/api/auth/signout', {
      method: 'POST', credentials: 'same-origin',
    });
    const d = await r.json();
    if (d.status !== 'ok') { toast('Sign out failed: ' + (d.error || 'unknown'), 'error'); return; }
    toast('Signed out (' + (d.count || 0) + ' cookies cleared)', 'success');
  } catch(e) {
    toast('Sign out failed: ' + e.message, 'error');
    return;
  }
  // Refresh chip + cookie watcher (which will clear the Mempack tab)
  await refreshAuthChip();
  _userWatchTick();
}

// Initial chip render at page load (before first _userWatchTick)
refreshAuthChip();

function overrideOwner(){
  const current = $('#ownerUuid').value;
  const next = prompt('Override owner UUID (admin/debug). Leave blank to clear:', current || '');
  if (next === null) return;  // cancel
  if (next.trim() === '') {
    localStorage.removeItem('membot-owner-override');
    toast('Override cleared — using detected session', 'success');
  } else {
    if (!/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/.test(next.trim())) {
      toast('Not a UUID', 'error'); return;
    }
    localStorage.setItem('membot-owner-override', next.trim());
    toast('Override set — reloading', 'success');
  }
  $('#ownerUuid').value = '';
  setView('mempack');  // re-detect with new override
}

function fmtBytes(n){
  if (n == null) return '?';
  if (n < 1024) return n + ' B';
  if (n < 1024*1024) return (n/1024).toFixed(1) + ' KB';
  return (n/(1024*1024)).toFixed(2) + ' MB';
}
function fmtTs(iso){
  if (!iso) return '';
  try {
    const d = new Date(iso);
    const pad = n => String(n).padStart(2,'0');
    return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate())
         + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
  } catch(e) { return iso; }
}

async function loadMempacks(){
  const uuid = ($('#ownerUuid').value || '').trim();
  const list = $('#mempackList');
  if (!uuid) { list.innerHTML = '<div class="dash-empty"><div class="icon">&#x1F511;</div>No Supabase session detected. Sign in at <a href="https://project-you.app/" style="color:var(--accent)">project-you.app</a>.</div>'; return; }
  if (!/^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$/.test(uuid)) {
    toast('Detected value is not a UUID', 'error'); return;
  }
  list.innerHTML = '<div class="dash-empty"><div class="spinner"></div> Loading Mempacks...</div>';
  try {
    const r = await fetch(BASE() + '/api/mempacks?owner_id=' + encodeURIComponent(uuid));
    const d = await r.json();
    if (d.error || d.status === 'error') { list.innerHTML = '<div class="dash-empty"><div class="icon">&#x26A0;</div>' + esc(d.error || 'Load failed') + '</div>'; return; }
    const items = d.mempacks || [];
    if (items.length === 0) {
      list.innerHTML = '<div class="dash-empty"><div class="icon">&#x1F5C3;</div>No Mempacks yet for this user.</div>';
      return;
    }
    list.innerHTML = items.map(mp => {
      const sizeMb = (mp.size_bytes || 0) / (1024*1024);
      const capMb = 10;  // free-tier cap from migration 002 trigger
      const pct = Math.min(100, (sizeMb / capMb) * 100);
      const fillCls = pct > 90 ? 'full' : (pct > 70 ? 'warn' : '');
      const statusCls = mp.storage_status === 'ready' ? 'ready' : 'pending';
      return '<div class="mempack-card" data-id="' + esc(mp.id) + '">'
        + '<div class="name">' + esc(mp.name) + '</div>'
        + '<div class="meta">'
        +   '<span class="pill ' + statusCls + '">' + esc(mp.storage_status || 'unknown') + '</span>'
        +   '<span class="pill">' + (mp.pattern_count || 0) + ' patterns</span>'
        +   '<span class="pill">' + fmtBytes(mp.size_bytes) + ' / ' + capMb + ' MB</span>'
        + '</div>'
        + '<div class="quota-bar"><div class="quota-fill ' + fillCls + '" style="width:' + pct.toFixed(1) + '%"></div></div>'
        + '<div class="quota-label">' + pct.toFixed(1) + '% of ' + capMb + ' MB used</div>'
        + '</div>';
    }).join('');
    list.querySelectorAll('.mempack-card').forEach(el => {
      el.addEventListener('click', () => selectMempack(el.dataset.id, items));
    });
    if (d.auto_provisioned) toast('Provisioned starter Mempack ("primary")', 'success');
    // Auto-select if exactly one (common case after lazy-provision)
    if (items.length === 1) selectMempack(items[0].id, items);
  } catch(e) {
    list.innerHTML = '<div class="dash-empty"><div class="icon">&#x26A0;</div>' + esc(e.message) + '</div>';
  }
}

function selectMempack(id, items){
  const mp = (items || []).find(m => m.id === id);
  if (!mp) { toast('Mempack ' + id + ' not in list', 'error'); return; }
  _currentMempack = mp;
  _activitySinceTs = null;
  document.querySelectorAll('.mempack-card').forEach(c => c.classList.toggle('active', c.dataset.id === id));
  $('#mempackDetail').style.display = '';
  // Connect-an-agent snippets (owner_id + name baked in)
  populateConnectPanel(mp);
  // Pattern I
  $('#patternIText').value = mp.pattern_i_text || '';
  $('#patternIMeta').textContent = mp.pattern_count + ' patterns total · idx=1 reserved';
  $('#patternIStatus').textContent = '';
  $('#patternIStatus').className = 'saved-msg';
  // Settings
  $('#loggingToggle').checked = mp.activity_logging_enabled !== false;
  // Dispatch textbox: pre-fill with the most recent DISPATCH-tagged pattern
  // so the user can see what's currently queued without digging into the
  // Patterns Browser. Falls back to whatever the user had typed previously
  // if the fetch fails or no DISPATCH exists.
  prefillDispatchFromMempack(mp.id);
  // Patterns browser (resets state — filters cleared, offset back to 0)
  _patternsCurrentTag = '';
  _patternsCurrentQ = '';
  if ($('#patternsTagFilter')) $('#patternsTagFilter').value = '';
  if ($('#patternsSearchInput')) $('#patternsSearchInput').value = '';
  loadPatterns(true);
  // Activity (initial fetch + start polling)
  $('#activityFeed').innerHTML = '<div class="dash-empty">Loading activity...</div>';
  loadActivity(true);
  startActivityPoll();
}

/* Connect-an-agent panel — bakes the selected Mempack's name + owner_id into
 * a ready-to-paste mcp.json snippet and a starter prompt. Owner_id is per-
 * tool-call (mount_cartridge(name, owner_id)), not per-connection, so the
 * snippet itself is just the SSE URL; the owner_id rides in the prompt.
 */
function populateConnectPanel(mp){
  const ownerId = mp.owner_id || ($('#ownerUuid').value || '').trim() || '<your-uuid>';
  const name = mp.name || 'primary';
  // Droplet membot runs `--transport http` (StreamableHTTP) — endpoint is /mcp,
  // NOT /sse. The 5/12 mcp.json.example is stale. Most modern MCP clients
  // (Claude Code, Cursor, Windsurf) accept either "streamableHttp" or "http"
  // as the type field; "sse" type pointing at /mcp will NOT connect.
  // (We build snippets via array-join to keep this Python-templated JS safe.)
  const mcpUrl = (location.protocol === 'https:' ? 'https:' : 'http:') + '//' + location.host + '/membot/mcp';
  const mcpSnippet = [
    '{',
    '  "mcpServers": {',
    '    "membot": {',
    '      "type": "streamableHttp",',
    '      "url": "' + mcpUrl + '"',
    '    }',
    '  }',
    '}'
  ].join(String.fromCharCode(10));
  // Pull a friendly user label off whoami if available — the prompt nudges
  // the agent to address the user by name/email rather than the UUID it
  // had to pass to mount_cartridge. UUID stays in the tool args because
  // the API requires it.
  const _wp = (_whoamiCache && _whoamiCache.whoami) || {};
  const userLabel = _wp.full_name || _wp.email || (ownerId && ownerId !== '<your-uuid>' ? ownerId.slice(0, 8) : 'me');
  const prompt = [
    'Mount the Mempack named "' + name + '" with owner_id ' + ownerId + '.',
    'Then read Pattern I (mempack_read_pattern_i) and search for tag DISPATCH',
    '(memory_search query="DISPATCH" top_k=10). Acknowledge any dispatches you',
    'find back to me with a one-liner before starting work.',
    '',
    'If there are no DISPATCH patterns, tell me in chat — do not invent work.',
    '',
    'When you complete the dispatch, store every finding via memory_store with',
    'an appropriate tag (FINDING / SUMMARY / METHOD / etc.). Sign each stored',
    'pattern by appending a signature line at the end of the content body:',
    '  [signed: <your-model-name>@<your-host>, <ISO-timestamp>]',
    'e.g. [signed: qwen3:8b@goose, 2026-05-19T18:45:00Z]',
    '',
    'Do NOT report completion until memory_store has actually been called and',
    'returned a "Stored as passage #N" confirmation. Describing findings in',
    'chat is not storing them.',
    '',
    'Note: the owner_id above is a database key. Address me as "' + userLabel + '"',
    'in conversation — Pattern I has the user-facing identifier.'
  ].join(String.fromCharCode(10));
  $('#connectMcpJson').textContent = mcpSnippet;
  $('#connectPrompt').textContent = prompt;
  // Reset copy status indicators
  $('#connectMcpJsonStatus').textContent = '';
  $('#connectPromptStatus').textContent = '';

  // ---- Local-agent section: build the run command + wire download link ----
  // The harness URL is served from the same membot host so the download
  // works behind any nginx prefix that already routes /membot/* through.
  const dlBase = (location.protocol === 'https:' ? 'https:' : 'http:')
                 + '//' + location.host + (location.pathname.indexOf('/membot/') >= 0 ? '/membot' : '');
  const dlUrl = dlBase + '/downloads/mempack_local_agent.py';
  const dlLink = $('#connectLocalDownload');
  if (dlLink) dlLink.href = dlUrl;

  // Build a copy-paste-friendly run command with owner_id, mempack name,
  // and user label baked in. One-liner format (no shell line-continuations)
  // so it works on PowerShell, cmd, bash, zsh, fish, etc. without
  // per-shell quoting gymnastics.
  const localCmd = [
    '# One-shot mode (prompt + auto-exit):',
    'python mempack_local_agent.py --owner-id ' + ownerId + ' --model qwen2.5:14b --user-label "' + userLabel + '" --prompt "Mount my primary Mempack and tell me what is queued."',
    '',
    '# Or interactive REPL (no --prompt; type prompts at the > prompt):',
    'python mempack_local_agent.py --owner-id ' + ownerId + ' --model qwen2.5:14b --user-label "' + userLabel + '"',
  ].join(String.fromCharCode(10));
  const localCmdEl = $('#connectLocalCommand');
  if (localCmdEl) localCmdEl.textContent = localCmd;
  const localStatusEl = $('#connectLocalCommandStatus');
  if (localStatusEl) localStatusEl.textContent = '';
}

async function copyConnectSnippet(which){
  const sourceMap = {
    mcpJson:      ['connectMcpJson',      'connectMcpJsonStatus'],
    prompt:       ['connectPrompt',       'connectPromptStatus'],
    localCommand: ['connectLocalCommand', 'connectLocalCommandStatus'],
  };
  const [sourceId, statusId] = sourceMap[which] || sourceMap.mcpJson;
  const text = $('#' + sourceId).textContent || '';
  try {
    await navigator.clipboard.writeText(text);
    $('#' + statusId).textContent = 'Copied!';
    setTimeout(() => { $('#' + statusId).textContent = ''; }, 2000);
  } catch(e) {
    // Fallback for older browsers / restrictive contexts: select-and-execCommand
    const pre = $('#' + sourceId);
    const range = document.createRange();
    range.selectNode(pre);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);
    try {
      document.execCommand('copy');
      $('#' + statusId).textContent = 'Copied (fallback)';
      setTimeout(() => { $('#' + statusId).textContent = ''; }, 2000);
    } catch(e2) {
      $('#' + statusId).textContent = 'Copy failed — select + Ctrl+C';
    }
    window.getSelection().removeAllRanges();
  }
}

async function savePatternI(){
  if (!_currentMempack) return;
  const text = $('#patternIText').value;
  const status = $('#patternIStatus');
  status.textContent = 'Saving...';
  status.className = 'saved-msg';
  try {
    const r = await fetch(BASE() + '/api/mempack/' + _currentMempack.id + '/pattern-i', {
      method: 'POST', headers: {'Content-Type':'application/json'},
      body: JSON.stringify({text})
    });
    const d = await r.json();
    if (d.status !== 'ok') { status.textContent = 'Error: ' + (d.error || 'unknown'); return; }
    status.textContent = 'Saved (' + fmtBytes(d.blob_bytes) + ' on disk)';
    status.className = 'saved-msg success';
    _currentMempack.pattern_i_text = text;
  } catch(e) {
    status.textContent = 'Save failed: ' + e.message;
  }
}

/* Pattern I templates — loaded once per page lifetime, cached client-side.
 * The dropdown is populated from /api/mempack/templates; selecting an enabled
 * entry replaces the Pattern I textarea (with confirm if non-empty).
 */
let _templatesCache = null;  // [{id, label, description, body, disabled}, ...]
async function loadTemplates(){
  if (_templatesCache) return _templatesCache;
  try {
    const r = await fetch(BASE() + '/api/mempack/templates');
    const d = await r.json();
    if (d.status !== 'ok') return null;
    _templatesCache = d.templates || [];
    const sel = $('#templateSelect');
    if (sel) {
      // Wipe existing options except the placeholder first option
      while (sel.options.length > 1) sel.remove(1);
      _templatesCache.forEach(t => {
        const opt = document.createElement('option');
        opt.value = t.id;
        opt.textContent = t.label + (t.disabled ? '' : '');
        if (t.disabled) opt.disabled = true;
        sel.appendChild(opt);
      });
    }
    return _templatesCache;
  } catch(e) {
    console.warn('[membot] loadTemplates failed:', e);
    return null;
  }
}

function _applyTemplateVars(body){
  // The server returns template placeholders intact ({owner_id}, etc.). Fill
  // them client-side from the selected Mempack + the signed-in user so the
  // agent reads a human-friendly greeting instead of "operating on behalf of
  // 3579e6ee". Falls back to the UUID prefix when no email/name available.
  //
  // {owner_id_short}: human-greeting form
  //     1st: full_name (from OAuth)        e.g. "Andy Grossberg"
  //     2nd: email local-part              e.g. "andy.grossberg"
  //     3rd: UUID prefix                   e.g. "3579e6ee"
  // {owner_id}: identifier line
  //     1st: email                         e.g. "andy.grossberg@gmail.com"
  //     2nd: full UUID                     e.g. "3579e6ee-6412-..."
  //     3rd: "unknown"
  //
  // Note: the UUID is kept as the authoritative owner_id in the Mempack
  // schema; we just stop SHOWING it in the agent-facing template body.
  if (!body || !_currentMempack) return body || '';
  const ownerId  = _currentMempack.owner_id || ($('#ownerUuid').value || '').trim();
  const whoami   = _whoamiCache.whoami;
  const fullName = whoami ? whoami.full_name : null;
  const email    = whoami ? whoami.email : null;
  const ownerShort = fullName
    ? fullName
    : (email ? email.split('@')[0] : (ownerId ? ownerId.slice(0, 8) : 'unknown'));
  const ownerFull  = email || ownerId || 'unknown';
  const createdAt = new Date().toISOString();
  return body
    .replace(/\{owner_id_short\}/g, ownerShort)
    .replace(/\{owner_id\}/g, ownerFull)
    .replace(/\{created_at\}/g, createdAt)
    .replace(/\{name\}/g, _currentMempack.name || 'primary');
}

function onTemplatePick(){
  const sel = $('#templateSelect');
  const tid = sel.value;
  if (!tid) return;
  const t = (_templatesCache || []).find(x => x.id === tid);
  if (!t || !t.body) { sel.value = ''; return; }
  const current = $('#patternIText').value;
  if (current && current.trim().length > 0) {
    if (!confirm('Replace current Pattern I with the "' + t.label + '" template? Your current text will be overwritten in the editor (not yet saved to Supabase until you click "Save Pattern I").')) {
      sel.value = '';
      return;
    }
  }
  $('#patternIText').value = _applyTemplateVars(t.body);
  $('#patternIStatus').textContent = 'Template loaded — review then click "Save Pattern I"';
  $('#patternIStatus').className = 'saved-msg';
  sel.value = '';  // reset so picking the same template again still fires
}

/* Pre-fill the Dispatch textbox with the most-recent DISPATCH-tagged
 * pattern in the selected Mempack. Surfaces "what's currently queued" at
 * the surface where the user types — no need to dig into the Patterns
 * Browser to see what's in flight. Strips the `[DISPATCH] ` prefix from
 * the body and tags the textbox with a small `most recent dispatch in
 * this Mempack` hint until the user edits.
 */
async function prefillDispatchFromMempack(mempackId){
  const ta = $('#dispatchText');
  const status = $('#dispatchStatus');
  if (!ta) return;
  try {
    const r = await fetch(BASE() + '/api/mempack/' + mempackId + '/patterns?tag=DISPATCH&limit=1', { credentials: 'same-origin' });
    const d = await r.json();
    if (d.status !== 'ok' || !d.patterns || d.patterns.length === 0) {
      // No DISPATCH in this Mempack — leave textbox in whatever state it had
      // and clear the status line.
      if (status && status.textContent.startsWith('most recent dispatch')) status.textContent = '';
      return;
    }
    const newest = d.patterns[0];
    let body = newest.text || '';
    // Strip leading [DISPATCH] tag prefix
    if (body.startsWith('[') && body.indexOf(']') > 0) {
      body = body.slice(body.indexOf(']') + 1).trimStart();
    }
    // Strip trailing [dispatched: <iso-timestamp>] line — surfaced in the
    // status label instead so the textbox shows only the user's words.
    let dispatchedAt = null;
    const dispatchedMatch = body.match(/\s*\[dispatched:\s*([^\]]+)\]\s*$/);
    if (dispatchedMatch) {
      dispatchedAt = dispatchedMatch[1].trim();
      body = body.slice(0, dispatchedMatch.index).trimEnd();
    }
    ta.value = body;
    if (status) {
      status.className = 'saved-msg';
      const tsBit = dispatchedAt ? ' · ' + dispatchedAt : '';
      status.textContent = 'most recent dispatch in this Mempack (idx:' + newest.idx + ')' + tsBit;
    }
  } catch(e) {
    // Silently fail — pre-fill is a convenience, not a load-bearing surface.
  }
}

async function doDispatch(){
  if (!_currentMempack) { toast('Select a Mempack first', 'error'); return; }
  const ta = $('#dispatchText');
  const status = $('#dispatchStatus');
  const text = (ta.value || '').trim();
  if (!text) { toast('Type a dispatch first', 'error'); ta.focus(); return; }
  status.textContent = 'Sending...';
  status.className = 'saved-msg';
  try {
    const r = await fetch(BASE() + '/api/mempack/' + _currentMempack.id + '/dispatch', {
      method: 'POST', headers: {'Content-Type':'application/json'},
      body: JSON.stringify({text})
    });
    const d = await r.json();
    if (d.status !== 'ok') {
      status.textContent = 'Error: ' + (d.error || 'unknown');
      status.className = 'saved-msg';
      return;
    }
    status.textContent = 'Dispatched (idx=' + d.new_idx + ', ' + fmtBytes(d.blob_bytes) + ') — text kept; edit or clear to send again';
    status.className = 'saved-msg success';
    // Text intentionally NOT cleared — so a repeat dispatch is one click, not
    // a re-type. User clears the textarea themselves when they're done.
    // Refresh quota meter on the selected card (size changed). Cheapest path
    // is a full mempack-list reload, but that re-renders cards and loses the
    // selection animation; instead, mutate in place + tick activity feed so
    // the user immediately sees the dispatch row appear.
    _currentMempack.pattern_count = d.pattern_count;
    _currentMempack.size_bytes    = d.blob_bytes;
    loadActivity(false);
  } catch(e) {
    status.textContent = 'Dispatch failed: ' + e.message;
    status.className = 'saved-msg';
  }
}

async function toggleLogging(enabled){
  if (!_currentMempack) return;
  try {
    const r = await fetch(BASE() + '/api/mempack/' + _currentMempack.id, {
      method: 'PATCH', headers: {'Content-Type':'application/json'},
      body: JSON.stringify({activity_logging_enabled: !!enabled})
    });
    const d = await r.json();
    if (d.status !== 'ok') { toast('Toggle failed: ' + (d.error || 'unknown'), 'error'); return; }
    _currentMempack.activity_logging_enabled = !!enabled;
    toast('Activity logging ' + (enabled ? 'enabled' : 'disabled'), 'success');
  } catch(e) { toast('Toggle failed: ' + e.message, 'error'); }
}

async function loadActivity(reset){
  if (!_currentMempack) return;
  const feed = $('#activityFeed');
  let url = BASE() + '/api/mempack/' + _currentMempack.id + '/activity?limit=100';
  if (!reset && _activitySinceTs) url += '&since=' + encodeURIComponent(_activitySinceTs);
  try {
    const r = await fetch(url);
    const d = await r.json();
    if (d.status !== 'ok') { feed.innerHTML = '<div class="dash-empty">' + esc(d.error || 'Failed to load activity') + '</div>'; return; }
    _activitySinceTs = d.server_time;
    if (reset) {
      feed.innerHTML = (d.activity || []).length === 0
        ? '<div class="dash-empty"><div class="icon">&#x1F4DD;</div>No activity yet. Mount this Mempack from an agent to see events.</div>'
        : (d.activity || []).map(_renderActivityRow).join('');
    } else if (d.activity && d.activity.length > 0) {
      // Prepend newer events to the top of the feed
      feed.insertAdjacentHTML('afterbegin', d.activity.map(_renderActivityRow).join(''));
    }
  } catch(e) { /* polling — silent on error */ }
}

function _renderActivityRow(row){
  const meta = row.metadata || {};
  const preview = meta.preview || '';
  // Previous-version expander: present on pattern_i_update / pattern_update
  // events where we captured the prior text (Fix A — recoverability for
  // accidental or agent-driven overwrites).
  const prevText = meta.previous_text || '';
  const prevLen  = meta.previous_text_length || 0;
  const prevTrunc = !!meta.previous_text_truncated;
  let prevBlock = '';
  if (prevText && prevLen > 0) {
    const label = 'View previous version (' + prevLen + ' chars)';
    const truncNote = prevTrunc
      ? ('<span class="prev-version-truncated-note">'
        + 'NOTE: previous body truncated at 8 KB in this activity row '
        + '(full was ' + prevLen + ' chars). For the complete prior text, '
        + 'check earlier activity rows or restore from a manual backup.'
        + '</span>')
      : '';
    prevBlock =
      '<button type="button" class="prev-version-toggle" onclick="togglePrevVersion(this)">'
      + '<span class="caret">&#x25B8;</span> ' + esc(label)
      + '</button>'
      + '<div class="prev-version-block">'
      +   truncNote
      +   '<pre class="prev-version-text">' + esc(prevText) + '</pre>'
      +   '<div class="prev-version-actions">'
      +     '<button type="button" class="prev-version-copy" onclick="copyPrevVersion(this)">Copy previous text</button>'
      +   '</div>'
      + '</div>';
  }
  return '<div class="activity-row">'
    + '<div class="ts">' + esc(fmtTs(row.created_at)) + '</div>'
    + '<div class="body">'
    +   '<div class="summary">' + esc(row.summary || '') + '</div>'
    +   (preview ? '<div class="preview">' + esc(preview) + '</div>' : '')
    +   prevBlock
    + '</div>'
    + '<span class="type ' + esc(row.event_type) + '">' + esc(row.event_type) + '</span>'
    + '</div>';
}

function togglePrevVersion(btn){
  const block = btn.nextElementSibling;
  if (!block) return;
  const isOpen = block.classList.toggle('open');
  btn.classList.toggle('open', isOpen);
}

async function copyPrevVersion(btn){
  // Find the <pre> sibling that holds the previous text within this block.
  const block = btn.closest('.prev-version-block');
  if (!block) return;
  const pre = block.querySelector('.prev-version-text');
  if (!pre) return;
  const text = pre.textContent || '';
  try {
    await navigator.clipboard.writeText(text);
    btn.classList.add('copied');
    const original = btn.textContent;
    btn.textContent = 'Copied to clipboard';
    setTimeout(() => {
      btn.classList.remove('copied');
      btn.textContent = original;
    }, 2000);
  } catch(e) {
    // Fallback for restrictive contexts (file://, older browsers)
    const range = document.createRange();
    range.selectNode(pre);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);
    try {
      document.execCommand('copy');
      btn.classList.add('copied');
      btn.textContent = 'Copied (select-and-copy fallback)';
      setTimeout(() => { btn.classList.remove('copied'); btn.textContent = 'Copy previous text'; }, 2000);
    } catch(e2) {
      btn.textContent = 'Copy failed — text is selected, hit Ctrl+C';
    }
  }
}

function startActivityPoll(){
  stopActivityPoll();
  _activityPollHandle = setInterval(() => loadActivity(false), _ACTIVITY_POLL_MS);
}
function stopActivityPoll(){
  if (_activityPollHandle) { clearInterval(_activityPollHandle); _activityPollHandle = null; }
}
// Stop polling when tab loses visibility (saves bandwidth + battery)
document.addEventListener('visibilitychange', () => {
  if (document.hidden) stopActivityPoll();
  else if (_currentMempack && document.querySelector('.view-tab.active')?.dataset.view === 'mempack') startActivityPoll();
});

/* =========================================================
 * Patterns Browser
 *   - Lists every stored pattern in the selected Mempack
 *   - Filter by tag (FINDING / SUMMARY / DISPATCH / METHOD / ...)
 *   - Substring text search (debounced)
 *   - Expand-to-read with Copy button
 *   - Paginated; "Load more" appends
 *   - Skips Pattern 0 (header marker) + Pattern I (handled by the editor above)
 * ========================================================= */
let _patternsOffset = 0;
let _patternsTotal = 0;
let _patternsCurrentTag = '';
let _patternsCurrentQ = '';
let _patternsSearchTimer = null;
const _PATTERNS_PAGE_SIZE = 50;

async function loadPatterns(reset){
  if (!_currentMempack) return;
  const list = $('#patternsList');
  const pag  = $('#patternsPagination');
  const cnt  = $('#patternsCount');
  if (reset) {
    _patternsOffset = 0;
    list.innerHTML = '<div class="dash-empty"><div class="spinner"></div> Loading patterns&hellip;</div>';
    if (cnt) cnt.textContent = '';
    if (pag) pag.style.display = 'none';
  }
  const params = new URLSearchParams();
  params.set('offset', String(_patternsOffset));
  params.set('limit',  String(_PATTERNS_PAGE_SIZE));
  if (_patternsCurrentTag) params.set('tag', _patternsCurrentTag);
  if (_patternsCurrentQ)   params.set('q',   _patternsCurrentQ);
  const url = BASE() + '/api/mempack/' + _currentMempack.id + '/patterns?' + params.toString();
  try {
    const r = await fetch(url);
    const d = await r.json();
    if (d.status !== 'ok') {
      list.innerHTML = '<div class="dash-empty"><div class="icon">&#x26A0;</div>' + esc(d.error || 'Load failed') + '</div>';
      return;
    }
    _patternsTotal = d.total || 0;
    const rows = (d.patterns || []).map(_renderPatternRow).join('');
    if (reset) {
      list.innerHTML = rows || '<div class="dash-empty"><div class="icon">&#x1F4DD;</div>No patterns match.</div>';
    } else {
      list.insertAdjacentHTML('beforeend', rows);
    }
    _patternsOffset = _patternsOffset + (d.patterns || []).length;
    if (cnt) {
      const filter_bits = [];
      if (_patternsCurrentTag) filter_bits.push(_patternsCurrentTag);
      if (_patternsCurrentQ)   filter_bits.push('"' + _patternsCurrentQ + '"');
      const filter_str = filter_bits.length ? ' (' + filter_bits.join(' + ') + ')' : '';
      cnt.textContent = 'Showing ' + _patternsOffset + ' of ' + _patternsTotal + filter_str;
    }
    if (pag) pag.style.display = (_patternsOffset < _patternsTotal) ? '' : 'none';
    // Show the download bar only when there's something downloadable.
    const dl = $('#patternsDownload');
    if (dl) dl.style.display = (_patternsTotal > 0) ? '' : 'none';
  } catch(e) {
    list.innerHTML = '<div class="dash-empty"><div class="icon">&#x26A0;</div>' + esc(e.message) + '</div>';
  }
}

function _renderPatternRow(p){
  const tagChips = (p.tags || []).map(t =>
    '<span class="pattern-tag-chip tag-' + esc(t) + '">' + esc(t) + '</span>'
  ).join('');
  const noTagFallback = (p.tags || []).length === 0
    ? '<span class="pattern-tag-chip">(no tags)</span>'
    : '';
  // Preview text: strip the leading "[TAG] " bracket so the inline preview
  // doesn't waste characters re-stating what the chips already show.
  let preview = p.preview || '';
  if (preview.startsWith('[') && preview.indexOf(']') > 0) {
    preview = preview.slice(preview.indexOf(']') + 1).trim();
  }
  // Timestamp: prefer the per-pattern created_at from mempack_patterns (real
  // imprint time, captured at INSERT). Falls back to empty if the row isn't
  // present (e.g. cart blob has more patterns than mempack_patterns table,
  // which shouldn't happen but we guard anyway).
  const ts = p.created_at ? fmtTs(p.created_at) : '';
  const tsBit = ts ? '<span class="pattern-ts">' + esc(ts) + '</span>' : '';
  return '<div class="pattern-row" data-idx="' + p.idx + '">'
    + '<div class="pattern-header" onclick="togglePatternBody(this)">'
    +   '<span class="pattern-idx">idx:' + p.idx + '</span>'
    +   '<span class="pattern-tags">' + tagChips + noTagFallback + '</span>'
    +   tsBit
    +   '<span class="pattern-length">' + p.length + ' chars</span>'
    +   '<span class="pattern-caret">&#x25B8;</span>'
    + '</div>'
    + '<div class="pattern-preview">' + esc(preview) + '</div>'
    + '<div class="pattern-body">'
    +   '<pre class="pattern-text">' + esc(p.text || '') + '</pre>'
    +   '<div class="pattern-actions">'
    +     '<button class="pattern-copy-btn" onclick="copyPatternBody(this)">Copy</button>'
    +     '<span class="pattern-actions-sep">&middot;</span>'
    +     '<span class="pattern-actions-label">Download:</span>'
    +     '<button class="pattern-download-btn pattern-row-dl-btn" data-fmt="md"   onclick="onPatternDl(this)">.md</button>'
    +     '<button class="pattern-download-btn pattern-row-dl-btn" data-fmt="txt"  onclick="onPatternDl(this)">.txt</button>'
    +     '<button class="pattern-download-btn pattern-row-dl-btn" data-fmt="docx" onclick="onPatternDl(this)">.docx</button>'
    +     '<button class="pattern-download-btn pattern-row-dl-btn" data-fmt="pdf"  onclick="onPatternDl(this)">.pdf</button>'
    +     '<span class="pattern-download-row-status"></span>'
    +   '</div>'
    + '</div>'
    + '</div>';
}

function onPatternDl(btn){
  // Tiny bridge: reads format + idx from data attrs to avoid the
  // single-quote string-collision that comes with inline arg literals.
  const fmt = btn.dataset.fmt;
  const row = btn.closest('.pattern-row');
  if (!row) return;
  const idx = parseInt(row.dataset.idx, 10);
  if (isNaN(idx)) return;
  downloadPatterns(fmt, idx);
}

function togglePatternBody(headerEl){
  const row = headerEl.closest('.pattern-row');
  if (!row) return;
  row.classList.toggle('open');
}

async function copyPatternBody(btn){
  const row = btn.closest('.pattern-row');
  if (!row) return;
  const pre = row.querySelector('.pattern-text');
  if (!pre) return;
  const text = pre.textContent || '';
  try {
    await navigator.clipboard.writeText(text);
    btn.classList.add('copied');
    btn.textContent = 'Copied!';
    setTimeout(() => { btn.classList.remove('copied'); btn.textContent = 'Copy'; }, 2000);
  } catch(e) {
    // Fallback: select + execCommand
    const range = document.createRange();
    range.selectNode(pre);
    window.getSelection().removeAllRanges();
    window.getSelection().addRange(range);
    try {
      document.execCommand('copy');
      btn.classList.add('copied');
      btn.textContent = 'Copied (fallback)';
      setTimeout(() => { btn.classList.remove('copied'); btn.textContent = 'Copy'; }, 2000);
    } catch(e2) {
      btn.textContent = 'Select + Ctrl+C';
    }
    window.getSelection().removeAllRanges();
  }
}

function onPatternsTagChange(){
  _patternsCurrentTag = $('#patternsTagFilter').value;
  loadPatterns(true);
}

function onPatternsSearchInput(){
  clearTimeout(_patternsSearchTimer);
  _patternsSearchTimer = setTimeout(() => {
    _patternsCurrentQ = $('#patternsSearchInput').value.trim();
    loadPatterns(true);
  }, 300);
}

function loadMorePatterns(){
  loadPatterns(false);
}

/* Download Patterns Browser content as a document.
 *
 *   downloadPatterns('md')        — bulk: filtered set (current tag + q)
 *   downloadPatterns('md', 5)     — single pattern by idx
 *
 * Single-pattern mode wires status messages to the per-row status span
 * inside that row's .pattern-actions block; bulk mode uses the global
 * #patternsDownloadStatus span under the patterns list.
 */
async function downloadPatterns(format, idx){
  if (!_currentMempack) return;
  const isSingle = (idx !== undefined && idx !== null);

  // Locate the right status element + button group for this scope.
  let statusEl, btns;
  if (isSingle) {
    const row = document.querySelector('.pattern-row[data-idx="' + idx + '"]');
    if (!row) return;
    statusEl = row.querySelector('.pattern-download-row-status');
    btns = row.querySelectorAll('.pattern-row-dl-btn, .pattern-copy-btn');
  } else {
    statusEl = $('#patternsDownloadStatus');
    btns = document.querySelectorAll('.patterns-download-btn');
  }

  const params = new URLSearchParams();
  params.set('format', format);
  if (isSingle) {
    params.set('idx', String(idx));
  } else {
    if (_patternsCurrentTag) params.set('tag', _patternsCurrentTag);
    if (_patternsCurrentQ)   params.set('q',   _patternsCurrentQ);
  }
  const url = BASE() + '/api/mempack/' + _currentMempack.id + '/patterns/export?' + params.toString();

  // Disable buttons while in flight (PDF can take 2-5 seconds)
  btns.forEach(b => { b.disabled = true; });
  if (statusEl) {
    statusEl.className = isSingle ? 'pattern-download-row-status' : 'patterns-download-status';
    statusEl.textContent = format === 'pdf' ? 'Rendering PDF…' : 'Preparing…';
  }

  try {
    const r = await fetch(url, { credentials: 'same-origin' });
    if (!r.ok) {
      let detail = 'HTTP ' + r.status;
      try { const errd = await r.json(); if (errd && errd.error) detail = errd.error; } catch(_) {}
      if (statusEl) {
        statusEl.className = (isSingle ? 'pattern-download-row-status' : 'patterns-download-status') + ' error';
        statusEl.textContent = 'Download failed: ' + detail;
      }
      return;
    }
    const blob = await r.blob();
    const cd = r.headers.get('content-disposition') || '';
    const fnMatch = cd.match(/filename="([^"]+)"/);
    const filename = fnMatch ? fnMatch[1] : ('mempack.' + format);
    const blobUrl = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = blobUrl;
    a.download = filename;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(blobUrl), 1500);
    if (statusEl) {
      statusEl.className = (isSingle ? 'pattern-download-row-status' : 'patterns-download-status') + ' success';
      statusEl.textContent = 'Saved as ' + filename;
      setTimeout(() => {
        if (statusEl.textContent.startsWith('Saved as ')) {
          statusEl.className = isSingle ? 'pattern-download-row-status' : 'patterns-download-status';
          statusEl.textContent = '';
        }
      }, 4000);
    }
  } catch(e) {
    if (statusEl) {
      statusEl.className = (isSingle ? 'pattern-download-row-status' : 'patterns-download-status') + ' error';
      statusEl.textContent = 'Download error: ' + e.message;
    }
  } finally {
    btns.forEach(b => { b.disabled = false; });
  }
}

// Restore last view on page load (UUID auto-detected from Supabase cookie inside setView)
(function(){
  const lastView = localStorage.getItem('membot-active-view');
  if (lastView === 'mempack') setView('mempack');
})();

/* Cookie-disappearance / user-change watcher.
 *
 * Polls /api/whoami every 10s. If the detected UUID changes between polls
 * (user signed out at project-you.app, or signed in as a different user),
 * clear local Mempack state and re-enter the view so the empty-state /
 * sign-in prompt renders for the new auth context.
 *
 * Critical for closing the "logged in then out, dashboard stale" leak from
 * the user-facing side (server-side leak is closed via per-user session_id).
 */
let _lastDetectedUser = null;
const _USER_POLL_MS = 10000;
async function _userWatchTick(){
  // Bypass the whoami cache so the poll always sees fresh server-side state
  _whoamiCache = { ts: 0, whoami: null };
  const whoami = await getWhoami();
  const now = whoami && whoami.user_id ? whoami.user_id : null;
  const override = localStorage.getItem('membot-owner-override');
  const effective = override || now;
  const lastEffective = override || _lastDetectedUser;
  // Always keep the auth chip in sync with current state.
  renderAuthChip(whoami);
  if (effective !== lastEffective) {
    console.log('[membot] auth state changed:', lastEffective ? lastEffective.slice(0,8) : 'anon',
                '->', effective ? effective.slice(0,8) : 'anon');
    _lastDetectedUser = now;
    _currentMempack = null;
    stopActivityPoll();
    $('#mempackList').innerHTML = '';
    $('#mempackDetail').style.display = 'none';
    $('#ownerUuid').value = '';
    const activeTab = document.querySelector('.view-tab.active')?.dataset.view;
    if (activeTab === 'mempack') setView('mempack');
    if (!effective && lastEffective) toast('Signed out — Mempack dashboard cleared', 'success');
  } else {
    _lastDetectedUser = now;
  }
}
// Prime the cache once at startup so the first poll has a baseline
detectSupabaseUser().then(uid => { _lastDetectedUser = uid; });
setInterval(_userWatchTick, _USER_POLL_MS);
</script>
</body>
</html>
"""


@mcp.tool()
def list_cartridges() -> str:
    """List available brain cartridges with size and capabilities.
    Scans the cartridges/ and data/ directories.
    """
    log.info("list_cartridges")
    carts = find_cartridges()

    if not carts:
        return "No cartridges found. Place .npz or .pkl files in cartridges/ or data/ directories."

    lines = [f"Available cartridges ({len(carts)}):\n"]
    for c in carts:
        flags = [c["format"].upper()]
        if c["has_brain"]:
            flags.append("GPU brain")
        if c["has_signatures"]:
            flags.append("L2 sigs")
        if c["has_manifest"]:
            flags.append("verified")
        flag_str = f"  [{', '.join(flags)}]"
        lines.append(f"  {c['name']}  ({c['size_mb']} MB, {c['dir']}/){flag_str}")

    return "\n".join(lines)


@mcp.tool()
def mount_cartridge(name: str, session_id: str = "", owner_id: str = "") -> str:
    """Mount a brain cartridge by name (or partial name).
    Loads embeddings and text into memory for searching.
    Verifies integrity against manifest if available.

    Args:
        name: Cartridge name (exact or partial match)
        session_id: Session identifier (auto-assigned if empty). Each session has its own mounted cartridge.
        owner_id: Supabase user UUID. When set, restricts Mempack lookup to that
                  user's Mempacks only — disambiguates the case where multiple
                  users have Mempacks with the same name (e.g. each user's "primary").
                  Ignored for non-Mempack carts.
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"mount_cartridge({name}, session={session_id}, owner_id={owner_id[:8] if owner_id else 'none'})")

    try:
        clean_name = sanitize_name(name)
    except ValueError as e:
        return str(e)

    # Validate owner_id format if provided (defense against bad input)
    if owner_id and not _UUID_RE.match(owner_id):
        return f"owner_id must be a UUID; got: {owner_id[:32]!r}"

    carts = find_cartridges()
    match = [c for c in carts if c["name"] == clean_name]

    if not match:
        match = [c for c in carts if clean_name.lower() in c["name"].lower()]

    # Mempack fallback (Andy 2026-05-12): if the standard catalog doesn't have
    # this name, check user-segmented Mempacks. Exact-name match only here —
    # substring matching against Mempacks risks cross-user collision. When
    # owner_id is provided, scope the search to that user only.
    if not match:
        mempack_owner = owner_id or None
        mempacks = find_mempacks(owner_id=mempack_owner)
        mempack_match = [m for m in mempacks if m["name"] == clean_name]
        if len(mempack_match) > 1:
            owners = ", ".join(m.get("owner_id", "?")[:8] for m in mempack_match)
            return (
                f"Multiple Mempacks named '{clean_name}' across users ({owners}). "
                f"Pass owner_id to disambiguate."
            )
        match = mempack_match

    if not match:
        available = ", ".join(c["name"] for c in carts[:10])
        return f"Cartridge '{clean_name}' not found. Available: {available}"

    cart = match[0]

    # Mempack mount: fetch blob from Supabase Storage to local cache before
    # the load infrastructure tries to read from disk. Detected by presence
    # of `mempack_id` on the cart dict (find_mempacks adds this).
    if cart.get("mempack_id"):
        try:
            _ensure_mempack_cached(cart)
        except Exception as e:
            log.error(f"mount_cartridge: Mempack fetch failed: {e}")
            return f"Failed to fetch Mempack '{cart['name']}' from Supabase: {e}"

    try:
        t0 = time.time()
        data = load_cartridge_safe(cart["path"])

        embeddings = data["embeddings"]
        texts = data["texts"]

        if len(texts) > MAX_ENTRIES:
            return f"Cartridge too large: {len(texts)} entries exceeds limit of {MAX_ENTRIES}."

        # Verify integrity — Mempacks skip the sidecar-based check since the
        # Supabase row is the authoritative integrity source (no manifest file
        # exists on disk for Supabase-backed Mempacks; cold-cut 2026-05-13).
        if cart.get("mempack_id"):
            verify_msg = "mempack (Supabase row authoritative)"
        else:
            ok, verify_msg = verify_manifest(cart["path"], embeddings, len(texts))
            if not ok:
                log.error(f"INTEGRITY CHECK FAILED for {cart['name']}: {verify_msg}")
                return f"SECURITY: Cartridge '{cart['name']}' failed integrity check: {verify_msg}. Refusing to mount."

        state["embeddings"] = embeddings
        state["texts"] = texts
        state["cartridge_name"] = cart["name"]
        state["cartridge_path"] = cart["path"]
        state["signatures"] = None
        state["hippocampus"] = data.get("hippocampus")
        state["per_pattern_meta"] = data.get("per_pattern_meta")
        state["modified"] = False

        # Pattern 0 v2 (Andy 2026-05-12): extract briefing + ownership block + cart
        # type. Two sources:
        #   (a) Mempacks: the Supabase row carries briefing + ownership; populated
        #       on the cart dict by find_mempacks. Manifest sidecar doesn't exist
        #       on disk for Supabase-backed Mempacks (cold-cut 2026-05-13).
        #   (b) Knowledge carts: manifest sidecar JSON next to the .cart.npz file.
        # Defaults preserve legacy behavior (world-readable, no owner, no caps,
        # knowledge type). See pattern-0-v2-spec.md.
        if cart.get("mempack_id"):
            # Mempack: Supabase row is authoritative; cart dict carries fields
            state["briefing"]     = cart.get("briefing")
            state["owner_id"]     = cart.get("owner_id")
            state["owner_perms"]  = cart.get("owner_perms") or "rwda"
            state["group_perms"]  = cart.get("group_perms") or ""
            state["world_perms"]  = cart.get("world_perms") or ""
            state["group_id"]     = cart.get("group_id")
            state["max_patterns"] = int(cart.get("max_patterns", 0) or 0)
            state["cart_type"]    = cart.get("cart_type") or CART_TYPE_AGENT_MEMORY
            state["mempack_id"]   = cart.get("mempack_id")
        else:
            _manifest = load_manifest(cart["path"])
            if _manifest:
                state["briefing"]     = _manifest.get("briefing")
                state["owner_id"]     = _manifest.get("owner_id")
                state["owner_perms"]  = _manifest.get("owner_perms", "rwda")
                state["group_perms"]  = _manifest.get("group_perms", "")
                state["world_perms"]  = _manifest.get("world_perms", "r")
                state["group_id"]     = _manifest.get("group_id")
                state["max_patterns"] = int(_manifest.get("max_patterns", 0) or 0)
                state["cart_type"]    = _manifest.get("cart_type", CART_TYPE_KNOWLEDGE)
            else:
                # Legacy cart with no manifest at all — assume world-readable knowledge.
                state["briefing"]     = None
                state["owner_id"]     = None
                state["owner_perms"]  = "rwda"
                state["group_perms"]  = ""
                state["world_perms"]  = "r"
                state["group_id"]     = None
                state["max_patterns"] = 0
                state["cart_type"]    = CART_TYPE_KNOWLEDGE

        # Sign-zero binary corpus for Hamming search
        # Priority: pre-computed sign_bits > computed from embeddings > None
        if "sign_bits" in data and data["sign_bits"] is not None:
            state["binary_corpus"] = data["sign_bits"]
            log.info(f"Loaded pre-computed sign bits: {data['sign_bits'].shape}")
        elif len(embeddings) > 0:
            state["binary_corpus"] = (embeddings > 0).astype(np.uint8)
        else:
            state["binary_corpus"] = None

        # Track whether we have full embeddings (for search mode selection)
        state["has_embeddings"] = len(embeddings) > 0 and embeddings.size > 0

        # Split cart: open SQLite sidecar for on-demand text retrieval
        if data.get("sqlite_db_path"):
            # Close any previous connection
            if state.get("sqlite_conn"):
                try: state["sqlite_conn"].close()
                except: pass
            state["sqlite_conn"] = sqlite3.connect(data["sqlite_db_path"])
            state["sqlite_db_path"] = data["sqlite_db_path"]
            state["is_split_cart"] = True
            log.info(f"Split cart: SQLite connection opened to {data['sqlite_db_path']}")
        else:
            state["sqlite_conn"] = None
            state["sqlite_db_path"] = None
            state["is_split_cart"] = data.get("is_split_cart", False)

        n = len(texts)
        dim = embeddings.shape[1] if len(embeddings) > 0 else 0
        elapsed_ms = (time.time() - t0) * 1000

        split_label = " (split: snippets in RAM, full text in SQLite)" if state["is_split_cart"] else ""
        log.info(f"Loaded {n} entries, dim={dim}, format={data['format']}, integrity={verify_msg}, sign_zero={n} codes, in {elapsed_ms:.0f}ms{split_label}")

        # Load L2 signatures if available
        sig_base = cart["path"].rsplit(".", 1)[0]
        sig_path = f"{sig_base}_signatures.npz"
        if os.path.exists(sig_path):
            try:
                sig_data = load_signatures(sig_path)
                state["signatures"] = sig_data["signatures"]
                log.info(f"Loaded {sig_data['n_patterns']} L2 signatures ({sig_data['method']})")
            except Exception as e:
                log.warning(f"Signature load failed: {e}")

        # Load brain weights to GPU if available
        brain_path = f"{sig_base}_brain.npy"
        gpu_msg = ""
        if os.path.exists(brain_path) and init_gpu():
            try:
                _gpu_state["lattice"].load_brain(brain_path)
                gpu_msg = ", brain loaded to GPU"
                log.info("Brain weights loaded to GPU")
            except Exception as e:
                gpu_msg = f", brain load failed: {e}"
                log.warning(f"Brain load failed: {e}")

        hippo_msg = ""
        if state["hippocampus"]:
            n_linked = sum(1 for h in state["hippocampus"] if h.get("prev") or h.get("next"))
            hippo_msg = f", hippocampus={len(state['hippocampus'])} ({n_linked} linked)"

        state["mount_count"] = state.get("mount_count", 0) + 1
        _log_activity(session_id, "mount", cart['name'], elapsed_ms)
        result = (
            f"Mounted '{cart['name']}': {n} memories, {dim}-dim, "
            f"{data['format'].upper()}, integrity={verify_msg}{gpu_msg}{hippo_msg}. "
            f"Session: {session_id}"
        )
        # Pattern 0 v2 Phase 1: surface the cart's briefing to the agent on
        # mount. Agents see the introduction immediately without needing a
        # separate get_status call. None for legacy carts; no change to output.
        if state.get("briefing"):
            result += f"\n\n--- CART BRIEFING ---\n{state['briefing']}\n--- END BRIEFING ---"
        # Pattern I (= Pattern 1) for Mempack-shaped carts: surface the agent's
        # behavioral text on mount so it bootstraps from the cart it's standing
        # in. Only fires for cart_type == agent-memory; ordinary knowledge carts
        # don't have a reserved Pattern I.
        if state.get("cart_type") == CART_TYPE_AGENT_MEMORY and len(texts) > PATTERN_I_IDX:
            pattern_i_text = texts[PATTERN_I_IDX]
            if pattern_i_text:
                result += (
                    f"\n\n--- PATTERN I (your behavior, idx={PATTERN_I_IDX}) ---\n"
                    f"{pattern_i_text}\n--- END PATTERN I ---"
                )

        # Mempack activity log: record the mount so the dashboard feed shows
        # "agent mounted (N patterns)". Best-effort — never fail mount on log
        # failure. Honors the per-Mempack activity_logging_enabled flag.
        if state.get("mempack_id"):
            try:
                import supabase_storage as sbs
                if sbs.is_logging_enabled(state["mempack_id"]):
                    sbs.append_activity(
                        mempack_id=state["mempack_id"],
                        event_type="mount",
                        summary=f"agent mounted ({n} patterns, {dim}-dim)",
                        metadata={
                            "n_patterns": n,
                            "dim":        dim,
                            "elapsed_ms": round(elapsed_ms, 1),
                        },
                        agent_label=state.get("agent_label"),
                    )
            except Exception as e:
                log.warning(f"Mempack mount activity-log append failed (non-fatal): {e}")

        return result

    except PermissionError as e:
        log.error(f"Security block: {e}")
        return str(e)
    except Exception as e:
        log.error(f"Mount failed: {e}")
        return f"Failed to mount '{clean_name}': {e}"


@mcp.tool()
def memory_search(query: str, top_k: int = 5, session_id: str = "", verbose: bool = False) -> str:
    """Search the mounted cartridge using lattice physics + embedding similarity.
    Runs the query through the neural lattice (settle → L2 signature) and blends
    physics-based similarity with embedding cosine for ranked results.

    Falls back to embedding-only search if GPU or signatures are unavailable.

    Args:
        query: Natural language search query
        top_k: Number of results to return (default 5)
        session_id: Session identifier (uses default session if empty)
        verbose: Show per-result signal breakdown (cosine, hamming, keyword boost)
    """  # noqa: docstring kept generic for MCP schema — actual impl uses sign_zero Hamming
    if len(query) > MAX_QUERY_LENGTH:
        return f"Query too long ({len(query)} chars). Max is {MAX_QUERY_LENGTH}."

    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"memory_search('{query[:60]}', top_k={top_k}, session={session_id})")

    if state["cartridge_name"] is None:
        return "No cartridge mounted. Use mount_cartridge first."

    has_embeddings = state.get("has_embeddings", True) and state["embeddings"] is not None and len(state["embeddings"]) > 0
    has_hamming = state["binary_corpus"] is not None

    if not has_embeddings and not has_hamming:
        return "Cartridge is empty (no embeddings or sign bits)."

    try:
        t0 = time.time()

        # 1. Embed query
        query_emb = embed_text(query, prefix="search_query")

        # Helper: compute Hamming scores against binary corpus
        # Handles both packed (N, 96) and unpacked (N, 768) formats
        # Popcount lookup table for packed bytes (avoids unpackbits OOM at scale)
        _POPCOUNT_TABLE = np.array([bin(i).count('1') for i in range(256)], dtype=np.uint8)

        def hamming_scores(query_emb, corpus_bin):
            is_packed = corpus_bin.shape[1] <= 96
            if is_packed:
                q_packed = np.packbits((query_emb > 0).astype(np.uint8))
                xor = np.bitwise_xor(q_packed, corpus_bin)
                # Popcount via lookup table — O(N*96) uint8, no expansion to 768
                dist = _POPCOUNT_TABLE[xor].sum(axis=1)
                n_bits = 768
            else:
                q_bin = (query_emb > 0).astype(np.uint8)
                xor = np.bitwise_xor(q_bin, corpus_bin)
                dist = xor.sum(axis=1)
                n_bits = corpus_bin.shape[1]
            return 1.0 - dist.astype(np.float32) / n_bits

        if has_embeddings:
            # 2a. Embedding cosine similarity
            stored = state["embeddings"]
            stored_norms = np.linalg.norm(stored, axis=1, keepdims=True) + 1e-9
            query_norm = np.linalg.norm(query_emb) + 1e-9
            emb_scores = np.dot(stored / stored_norms, query_emb / query_norm)

            # 3a. Blend with Hamming if available
            search_mode = "embedding"
            ham_scores = None

            if HAMMING_BLEND > 0 and has_hamming:
                try:
                    corpus_bin = state["binary_corpus"]
                    n_bin = min(len(corpus_bin), len(emb_scores))
                    ham = hamming_scores(query_emb, corpus_bin[:n_bin])
                    ham_scores = ham  # Expose raw Hamming scores for verbose display

                    blended = np.copy(emb_scores)
                    blended[:n_bin] = (1.0 - HAMMING_BLEND) * emb_scores[:n_bin] + HAMMING_BLEND * ham
                    scores = blended
                    search_mode = "hamming+embedding"
                    log.info(f"Hamming search: sign_zero blended {1.0 - HAMMING_BLEND:.0%}/{HAMMING_BLEND:.0%}, {n_bin} patterns")
                except Exception as e:
                    log.warning(f"Hamming search failed, falling back to embedding: {e}")
                    scores = emb_scores
            else:
                scores = emb_scores
                if not has_hamming:
                    log.info("No binary corpus — embedding-only search")

        else:
            # 2b. HAMMING-ONLY search (no full embeddings in cart)
            search_mode = "hamming-only"
            corpus_bin = state["binary_corpus"]
            scores = hamming_scores(query_emb, corpus_bin)
            emb_scores = scores  # For verbose display compatibility
            ham_scores = scores  # Raw Hamming scores are the only scores
            log.info(f"Hamming-only search: {len(corpus_bin)} patterns, packed={corpus_bin.shape[1] <= 96}")

        # 4. Keyword reranking — pull wider candidate pool, boost by keyword hits
        STOP_WORDS = {"the", "a", "an", "is", "are", "was", "were", "of", "in", "to",
                      "and", "or", "for", "on", "it", "be", "as", "at", "by", "this",
                      "that", "with", "from", "not", "but", "has", "had", "have", "do",
                      "does", "did", "will", "can", "its", "who", "what", "how", "why"}
        keywords = [w for w in query.lower().split() if len(w) >= 3 and w not in STOP_WORDS]

        candidate_k = min(max(top_k * 4, 20), len(scores))
        candidate_idx = np.argsort(scores)[-candidate_k:][::-1]

        boosted = []
        for i in candidate_idx:
            base_score = float(scores[i])
            if base_score < 0.05:
                continue
            text_lower = state["texts"][i].lower()
            hits = sum(1 for kw in keywords if kw in text_lower)
            boost = min(hits * 0.03, 0.12)  # up to 0.12 boost (4+ keyword hits)
            boosted.append((i, base_score + boost, boost))

        boosted.sort(key=lambda x: x[1], reverse=True)

        elapsed_ms = (time.time() - t0) * 1000

        # 5. Format results (include passage index + nav hints if hippocampus present)
        # For split carts, fetch full passages from SQLite for display.
        # int() cast is load-bearing: sqlite3 binds numpy.int64 silently as no-match.
        top_indices = [int(i) for i, _, _ in boosted[:top_k]]
        full_texts = {}
        if state.get("is_split_cart") and state.get("sqlite_conn"):
            full_texts = _sqlite_fetch_passages(state["sqlite_conn"], top_indices)

        hippo = state.get("hippocampus")
        results = []
        for rank, (i, final_score, kw_boost) in enumerate(boosted[:top_k], 1):
            if final_score < 0.1:
                continue
            # Use full text from SQLite if available, otherwise snippet from RAM
            if i in full_texts:
                text = full_texts[i]["passage"]
            else:
                text = state["texts"][i]
            if len(text) > 500:
                text = text[:500] + "..."

            # Nav hint from hippocampus
            nav = ""
            if hippo and i < len(hippo):
                h = hippo[i]
                parts = []
                if h.get("prev"): parts.append(f"prev=#{h['prev']-1}")
                if h.get("next"): parts.append(f"next=#{h['next']-1}")
                if parts:
                    nav = f" [{' '.join(parts)}]"

            if verbose:
                cos_s = f"{float(emb_scores[i]):.3f}"
                ham_s = f"{float(ham_scores[i]):.3f}" if ham_scores is not None and i < len(ham_scores) else "—"
                kw_s = f"+{kw_boost:.3f}" if kw_boost > 0 else "—"
                results.append(f"#{rank} (idx:{i}) [{final_score:.3f}] cos={cos_s} ham={ham_s} kw={kw_s}{nav}\n{text}")
            else:
                results.append(f"#{rank} (idx:{i}) [{final_score:.3f}]{nav} {text}")

        state["query_count"] = state.get("query_count", 0) + 1
        _log_activity(session_id, "search", f"'{query[:40]}' → {len(boosted[:top_k])} results", elapsed_ms)

        if not results:
            return f"No relevant matches for '{query}' (searched {len(state['texts'])} memories, {elapsed_ms:.0f}ms)"

        kw_label = f"+kw" if keywords else ""
        split_label = "+sqlite" if state.get("is_split_cart") else ""
        if search_mode == "hamming+embedding":
            mode_label = f"hamming+embedding 70/30{kw_label}{split_label}"
        elif search_mode == "hamming-only":
            mode_label = f"hamming-only{kw_label}{split_label}"
        else:
            mode_label = f"embedding-only{kw_label}{split_label}"
        header = f"Search [{mode_label}]: {len(results)} results from '{state['cartridge_name']}' ({elapsed_ms:.0f}ms)\n"
        return header + "\n\n".join(results)

    except Exception as e:
        log.error(f"Search error: {e}")
        return f"Search error: {e}"


@mcp.tool()
def mempack_read_pattern_i(session_id: str = "") -> str:
    """Read Pattern I (agent behavior) from the currently mounted Mempack.

    Pattern I is reserved at index 1 of any cart with cart_type='agent-memory'.
    Returns the text content of that pattern, or an explanatory message if the
    mounted cart isn't a Mempack or doesn't yet have Pattern I populated.

    Use this when you first mount a Mempack to load your behavioral instructions
    into context. Re-read periodically across long sessions to refresh.

    Args:
        session_id: Session identifier (uses default session if empty).
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)

    if state["cartridge_name"] is None:
        return "No cartridge mounted. Use mount_cartridge first."

    if state.get("cart_type") != CART_TYPE_AGENT_MEMORY:
        return (
            f"This cart is type '{state.get('cart_type', 'knowledge')}', not a "
            f"Mempack (cart_type='agent-memory'). Pattern I only exists in "
            f"agent-memory carts."
        )

    texts = state.get("texts") or []
    if len(texts) <= PATTERN_I_IDX:
        return (
            f"Mempack has no Pattern I yet — cart has {len(texts)} pattern(s), "
            f"index {PATTERN_I_IDX} is empty. Use the upcoming "
            f"mempack_update_pattern_i tool to populate it."
        )

    return texts[PATTERN_I_IDX]


@mcp.tool()
def mempack_update_pattern_i(text: str, session_id: str = "") -> str:
    """Overwrite Pattern I (your behavioral instructions) on the mounted Mempack.

    Pattern I is your persistent self-description as an agent — persona, accumulated
    self-knowledge, refined approach. Use this to self-rewrite: notes about how to
    do your job better, what works, what to avoid, who you are across sessions.

    The write round-trips to Supabase: blob is repacked, mempack_patterns row at
    idx=1 is updated in-place, and the denormalized pattern_i_text on the mempacks
    row is refreshed for fast preview/list reads.

    Args:
        text: New Pattern I content (max 10,000 chars).
        session_id: Session identifier (uses default session if empty).
    """
    if len(text) > MAX_TEXT_LENGTH:
        return f"Text too long ({len(text)} chars). Max is {MAX_TEXT_LENGTH}."

    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)

    if state["cartridge_name"] is None:
        return "No cartridge mounted. Use mount_cartridge first."

    if state.get("cart_type") != CART_TYPE_AGENT_MEMORY:
        return (
            f"This cart is type '{state.get('cart_type', 'knowledge')}', not a "
            f"Mempack (cart_type='agent-memory'). Pattern I only exists in "
            f"agent-memory carts."
        )

    if not state.get("mempack_id"):
        return (
            "Mounted cart is marked as agent-memory but has no mempack_id — "
            "this is a Supabase-backed-only operation. Provision via "
            "POST /api/mempack/create first."
        )

    if not _supabase_available():
        return "Supabase env vars missing on this server; cannot persist Pattern I update."

    log.info(f"mempack_update_pattern_i('{text[:60]}...', mempack_id={state['mempack_id'][:8]})")

    try:
        t0 = time.time()

        # 1. Re-embed the new Pattern I text
        emb = embed_text(text, prefix="search_document")

        # 2. Update in-memory state at PATTERN_I_IDX, growing arrays if Pattern I
        # didn't exist yet (cart had only the header at idx=0).
        texts = state["texts"] or []
        embeddings = state["embeddings"]
        binary_corpus = state["binary_corpus"]

        new_bin_row = (emb > 0).astype(np.uint8).reshape(1, -1)

        # Capture previous Pattern I body BEFORE mutating state, so we can
        # pass it to the persist function for the activity log (Fix A —
        # recoverability for agent-driven overwrites).
        previous_text = texts[PATTERN_I_IDX] if len(texts) > PATTERN_I_IDX else None

        if len(texts) > PATTERN_I_IDX:
            # In-place overwrite
            texts[PATTERN_I_IDX] = text
            embeddings[PATTERN_I_IDX] = emb
            binary_corpus[PATTERN_I_IDX] = new_bin_row.reshape(-1)
        elif len(texts) == PATTERN_I_IDX:
            # Append into the reserved slot (Pattern I was missing)
            texts.append(text)
            if embeddings is None or len(embeddings) == 0:
                state["embeddings"] = emb.reshape(1, -1)
            else:
                state["embeddings"] = np.vstack([embeddings, emb.reshape(1, -1)])
            if binary_corpus is None or len(binary_corpus) == 0:
                state["binary_corpus"] = new_bin_row
            else:
                state["binary_corpus"] = np.vstack([binary_corpus, new_bin_row])
        else:
            # Cart has fewer than PATTERN_I_IDX patterns — shouldn't happen on a
            # provisioned Mempack (header always at idx=0). Refuse rather than
            # silently corrupt indexing.
            return (
                f"Mempack has only {len(texts)} pattern(s); cannot write Pattern I "
                f"at idx={PATTERN_I_IDX}. Reprovision the cart."
            )

        state["modified"] = True

        # 3. Persist to Supabase (update existing row or append if missing)
        persist_msg = _mempack_persist_pattern_update(
            state, PATTERN_I_IDX, text, emb, previous_text=previous_text,
        )

        elapsed_ms = (time.time() - t0) * 1000
        return f"Pattern I updated ({len(text)} chars) — {persist_msg} ({elapsed_ms:.0f}ms)"

    except Exception as e:
        log.error(f"mempack_update_pattern_i error: {e}")
        return f"mempack_update_pattern_i error: {e}"


@mcp.tool()
def memory_store(content: str, tags: str = "", session_id: str = "") -> str:
    """Store new text in the currently mounted cartridge.
    The text is embedded via Nomic and added to the searchable memory.
    If GPU is available, the pattern is also imprinted into the lattice.

    Args:
        content: Text content to memorize (max 10,000 chars)
        tags: Optional metadata tags (prepended to stored text)
        session_id: Session identifier (uses default session if empty)
    """
    if len(content) > MAX_TEXT_LENGTH:
        return f"Text too long ({len(content)} chars). Max is {MAX_TEXT_LENGTH}."

    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)

    if state["cartridge_name"] is None:
        return "No cartridge mounted. Use mount_cartridge first."

    # Read-only carve-out for Mempacks. The --read-only flag is about
    # server-managed knowledge carts on droplet FS; Mempacks live in
    # Supabase under the user's own row + bucket folder. Per-user writes
    # to Mempacks should work even on the public read-only port.
    is_mempack = bool(state.get("mempack_id"))
    if _server_config.get("read_only") and not is_mempack:
        return "Server is in read-only mode. memory_store is disabled for non-Mempack carts."

    n_current = len(state["texts"]) if state["texts"] else 0
    if n_current >= MAX_ENTRIES:
        return f"Cartridge full ({n_current}/{MAX_ENTRIES}). Save and start a new cartridge."

    log.info(f"memory_store('{content[:60]}...', is_mempack={is_mempack})")

    try:
        t0 = time.time()

        # 1. Embed via Nomic
        emb = embed_text(content, prefix="search_document")

        # 2. Add to embedding matrix
        if state["embeddings"] is None or len(state["embeddings"]) == 0:
            state["embeddings"] = emb.reshape(1, -1)
        else:
            state["embeddings"] = np.vstack([state["embeddings"], emb.reshape(1, -1)])

        # 3. Extend binary corpus (sign_zero for Hamming search)
        new_bin = (emb > 0).astype(np.uint8).reshape(1, -1)
        if state["binary_corpus"] is None or len(state["binary_corpus"]) == 0:
            state["binary_corpus"] = new_bin
        else:
            state["binary_corpus"] = np.vstack([state["binary_corpus"], new_bin])

        # 4. Store text
        stored_text = f"[{tags}] {content}" if tags else content
        state["texts"].append(stored_text)
        state["modified"] = True

        # 5. GPU lattice imprint (if available)
        gpu_msg = ""
        if _gpu_state["available"] and _gpu_state["lattice"] is not None:
            try:
                ml = _gpu_state["lattice"]
                ml.reset()
                ml.imprint_vector(emb)
                ml.settle(frames=10, learn=True)
                gpu_msg = " + lattice imprint"
            except Exception as e:
                gpu_msg = f" (lattice failed: {e})"
                log.warning(f"Lattice imprint failed: {e}")

        # 6. Mempack persistence: if this is a Supabase-backed Mempack, round-trip
        # the new pattern back to Storage + mempack_patterns. Last-writer-wins
        # for v1; concurrent writes from two agents to the same Mempack are
        # rare enough to defer optimistic-locking to v1.2.
        persist_msg = ""
        if state.get("mempack_id") and _supabase_available():
            try:
                persist_msg = " + " + _mempack_persist_new_pattern(state, stored_text, emb)
            except Exception as e:
                persist_msg = f" (Mempack persistence FAILED: {e})"
                log.error(f"Mempack persistence failed for {state.get('mempack_id')}: {e}")

        elapsed_ms = (time.time() - t0) * 1000
        n = len(state["texts"])

        return f"Stored memory #{n}{gpu_msg}{persist_msg} ({elapsed_ms:.0f}ms)"

    except Exception as e:
        log.error(f"Store error: {e}")
        return f"Store error: {e}"


@mcp.tool()
def save_cartridge(name: str = "", session_id: str = "") -> str:
    """Save the current cartridge to disk as a secure .npz file.
    Generates a SHA256 manifest for integrity verification.

    Args:
        name: Optional name for the saved cartridge (default: use mounted name)
        session_id: Session identifier (uses default session if empty)
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"save_cartridge('{name}', session={session_id})")

    if _server_config.get("read_only"):
        return "Server is in read-only mode. save_cartridge is disabled."

    if state["cartridge_name"] is None and not name:
        return "No cartridge to save. Mount one first or provide a name."

    try:
        save_name = sanitize_name(name or state["cartridge_name"])
    except ValueError as e:
        return str(e)

    save_dir = os.path.join(BASE_DIR, "cartridges")
    os.makedirs(save_dir, exist_ok=True)

    save_path = os.path.join(save_dir, f"{save_name}.cart.npz")

    try:
        save_as_npz(save_path, state["embeddings"], state["texts"])
        size_mb = os.path.getsize(save_path) / (1024 * 1024)

        # Save integrity manifest
        manifest = save_manifest(save_path, state["embeddings"], len(state["texts"]))
        state["modified"] = False

        # Also save brain weights if GPU is active
        brain_msg = ""
        if _gpu_state["available"] and _gpu_state["lattice"] is not None:
            brain_path = os.path.join(save_dir, f"{save_name}_brain.npy")
            try:
                _gpu_state["lattice"].save_brain(brain_path)
                brain_msg = " + brain weights"
            except Exception as e:
                brain_msg = f" (brain save failed: {e})"

        return (
            f"Saved '{save_name}': {len(state['texts'])} memories, {size_mb:.1f} MB, "
            f"fingerprint={manifest['fingerprint']}{brain_msg}"
        )

    except Exception as e:
        log.error(f"Save error: {e}")
        return f"Save error: {e}"


@mcp.tool()
def unmount(session_id: str = "") -> str:
    """Unmount the current cartridge and free memory.

    Args:
        session_id: Session identifier (uses default session if empty)
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"unmount(session={session_id})")

    if state["cartridge_name"] is None:
        return "No cartridge mounted."

    name = state["cartridge_name"]
    warning = ""
    if state["modified"]:
        warning = " WARNING: Unsaved changes were discarded."

    # Close SQLite sidecar if open
    if state.get("sqlite_conn"):
        try: state["sqlite_conn"].close()
        except: pass

    # Reset session state but keep it alive
    state["cartridge_name"] = None
    state["cartridge_path"] = None
    state["embeddings"] = None
    state["texts"] = []
    state["binary_corpus"] = None
    state["signatures"] = None
    state["hippocampus"] = None
    state["modified"] = False
    state["sqlite_conn"] = None
    state["is_split_cart"] = False

    _log_activity(session_id, "unmount", name)
    return f"Unmounted '{name}'.{warning}"


# =============================================================================
# MULTI-CART API — query across many mounted carts at once
# =============================================================================
# Spec: docs/RFC/multi-cart-query-spec.md
# Implementation: multi_cart.py (parallel layer alongside the single-cart code above)
#
# The single-cart tools above (mount_cartridge, memory_search, etc.) keep
# working unchanged — they use per-session state. The multi-cart tools below
# share a process-global pool of mounted carts so one process can hold many
# carts and query across them. Both layers can coexist.

import multi_cart as _mc


@mcp.tool()
def multi_mount(cart_path: str, cart_id: str = "", role: str = "",
                verify_integrity: bool = True) -> str:
    """Mount a cart by file path into the multi-cart pool. Multiple carts can
    be mounted at once and queried across via multi_search.

    Args:
        cart_path: Filesystem path to the cart file (.npz, .pkl, or split format)
        cart_id: Short stable identifier for this mount (defaults to filename)
        role: Optional semantic tag — 'identity', 'episodic', 'semantic',
              'working', 'federated', 'consolidated', etc. Used to filter
              searches by role.
        verify_integrity: If True (default), reject carts with stale or
              mismatched manifests. Set False to override for known-stale
              carts (testing or migration).
    """
    log.info(f"multi_mount({cart_path}, cart_id={cart_id!r}, role={role!r}, verify={verify_integrity})")
    try:
        result = _mc.mount(
            cart_path,
            cart_id=cart_id or None,
            role=role or None,
            verify_integrity=verify_integrity,
        )
        return (
            f"Mounted '{result['cart_id']}' (role={result['role']}, "
            f"{result['n_patterns']} patterns, {result['embedding_dim']}-dim, "
            f"{result['elapsed_ms']}ms)"
        )
    except Exception as e:
        return f"multi_mount failed: {e}"


@mcp.tool()
def multi_unmount(cart_id: str) -> str:
    """Remove a cart from the multi-cart pool by cart_id."""
    log.info(f"multi_unmount({cart_id})")
    result = _mc.unmount(cart_id)
    if result["status"] == "not_mounted":
        return f"Cart '{cart_id}' is not mounted."
    return (
        f"Unmounted '{cart_id}' (was role={result['role']}, "
        f"{result['n_patterns']} patterns)"
    )


@mcp.tool()
def multi_list() -> str:
    """List every cart currently mounted in the multi-cart pool."""
    log.info("multi_list()")
    mounts = _mc.list_mounts()
    if not mounts:
        return "No carts mounted in the multi-cart pool."
    lines = [f"Multi-cart pool: {len(mounts)} carts, {_mc.total_patterns_mounted()} total patterns"]
    for m in mounts:
        role_label = f" [role={m['role']}]" if m["role"] else ""
        split_label = " (split)" if m["is_split_cart"] else ""
        lines.append(
            f"  '{m['cart_id']}'{role_label}: {m['n_patterns']} patterns, "
            f"{m['embedding_dim']}-dim{split_label}"
        )
    return "\n".join(lines)


@mcp.tool()
def multi_mount_directory(dir_path: str, role: str = "", pattern: str = "*.cart") -> str:
    """Mount every cart file in a directory matching the glob pattern.
    Used for federated mode — point at a fleet-learning directory and get
    every machine's cart mounted with the same role.

    Args:
        dir_path: Directory to scan
        role: Role to apply to every mounted cart (e.g. 'federated')
        pattern: Glob pattern (default '*.cart' — also try '*.npz', '*.pkl')
    """
    log.info(f"multi_mount_directory({dir_path}, role={role!r}, pattern={pattern!r})")
    try:
        results = _mc.mount_directory(dir_path, role=role or None, pattern=pattern)
        ok = sum(1 for r in results if r.get("status") == "mounted")
        err = sum(1 for r in results if r.get("status") == "error")
        lines = [f"mount_directory({dir_path}): {ok} mounted, {err} errors"]
        for r in results:
            if r.get("status") == "mounted":
                lines.append(f"  ✓ {r['cart_id']}: {r['n_patterns']} patterns")
            else:
                lines.append(f"  ✗ {r.get('cart_id', '?')}: {r.get('error', 'unknown')}")
        return "\n".join(lines)
    except Exception as e:
        return f"multi_mount_directory failed: {e}"


@mcp.tool()
def multi_search(query: str, top_k: int = 10, scope: str = "all",
                 role_filter: str = "", scope_mode: str = "global") -> str:
    """Search across every mounted cart in the multi-cart pool. Results are
    ranked and attributed to their source cart.

    Args:
        query: Natural language search query
        top_k: Number of top results to return (interpretation depends on scope_mode)
        scope: 'all' (default), 'local' (first cart only), or a specific cart_id
        role_filter: Optional role tag to restrict the search to matching carts
                     (e.g. 'federated' to search only federated-mode carts)
        scope_mode: How results across carts are ranked. Use this when carts
                    are very different sizes and you don't want one large cart
                    to dominate the results. Options:
                    - 'global' (default): true top-K across all carts. Best
                      for "what's the single best answer regardless of source?"
                    - 'per_cart': top-K from EACH cart, no cross-cart re-ranking.
                      Returns up to K × N_carts results. Best for "show me each
                      source's best answer for comparison."
                    - 'balanced': top-K candidates per cart, then globally rerank
                      to top-K. Guarantees small carts aren't drowned but the
                      final ranking still reflects global score. Best when
                      you want fair representation AND global ranking.
                    - 'diagnostic': top-K from every cart, no merging at all,
                      fully labeled. Useful for debugging.
    """
    if len(query) > MAX_QUERY_LENGTH:
        return f"Query too long ({len(query)} chars). Max is {MAX_QUERY_LENGTH}."
    log.info(f"multi_search('{query[:60]}', top_k={top_k}, scope={scope!r}, role_filter={role_filter!r}, scope_mode={scope_mode!r})")

    try:
        result = _mc.search(
            query,
            top_k=top_k,
            scope=scope,
            role_filter=role_filter or None,
            scope_mode=scope_mode,
        )
    except ValueError as e:
        return f"multi_search error: {e}"
    except Exception as e:
        return f"multi_search error: {e}"

    if result.get("error"):
        return f"multi_search: {result['error']}"

    results = result["results"]
    if not results:
        return (
            f"No relevant matches for '{query}' "
            f"(searched {result['cart_count']} carts, "
            f"{result['total_patterns']} patterns, {result['elapsed_ms']}ms, "
            f"scope_mode={scope_mode})"
        )

    header = (
        f"multi_search [scope={scope}, role_filter={role_filter or 'any'}, "
        f"scope_mode={scope_mode}]: "
        f"{len(results)} results from {result['cart_count']} carts "
        f"({result['total_patterns']} patterns, {result['elapsed_ms']}ms)\n"
    )
    lines = [header]

    # For per_cart and diagnostic modes, group output by cart_id for readability
    grouped = result.get("grouped_results")
    if grouped and scope_mode in ("per_cart", "diagnostic"):
        for cart_id, cart_results in grouped.items():
            if not cart_results:
                continue
            role_tag = ""
            if cart_results[0].get("role"):
                role_tag = f"/{cart_results[0]['role']}"
            lines.append(f"\n=== {cart_id}{role_tag} ===")
            for rank, r in enumerate(cart_results, 1):
                if r["score"] < 0.1:
                    continue
                lines.append(f"#{rank} [#{r['local_addr']}] [{r['score']:.3f}] {r['text']}")
    else:
        # Global / balanced — flat list with full attribution per result
        for rank, r in enumerate(results, 1):
            if r["score"] < 0.1:
                continue
            cart_label = f"[{r['cart_id']}"
            if r.get("role"):
                cart_label += f"/{r['role']}"
            cart_label += f"#{r['local_addr']}]"
            lines.append(f"#{rank} {cart_label} [{r['score']:.3f}] {r['text']}")
    return "\n\n".join(lines)


# =============================================================================
# FEDERATE — Federated cart mode (drop-in for Dennis's federated learning)
# =============================================================================
# Spec: docs/RFC/federated-cart-spec.md
# Implementation: federate.py (built on multi_cart.py)
#
# Use these tools to manage a fleet of machine carts that share a git directory
# the way Dennis's SAGE fleet shares its JSONL learning data. publish_session()
# appends to a machine's cart; consolidate() finds cross-machine matches and
# writes a consolidated cart; load_fleet() mounts the whole fleet for solver use.

import federate as _fed


@mcp.tool()
def federate_publish(session_file: str, machine_id: str, fleet_dir: str) -> str:
    """Append session learning entries to a machine's federated cart.
    Drop-in replacement for Dennis Palatov's publish_learning.py.

    Args:
        session_file: Path to a .jsonl file (one JSON object per line) or a
            .json file with a 'learning_entries' or 'entries' key.
        machine_id: Machine identifier (e.g. 'cbp', 'sprout', 'mcnugget')
        fleet_dir: Root of the fleet-learning directory (parent of machine dirs)
    """
    log.info(f"federate_publish({session_file}, {machine_id}, {fleet_dir})")
    try:
        result = _fed.publish_session(session_file, machine_id, fleet_dir)
        return (
            f"Published to '{machine_id}': added {result['added']}, "
            f"skipped {result['skipped']} dedup, "
            f"total {result['total_in_cart']} → {result['cart_path']}"
        )
    except Exception as e:
        return f"federate_publish failed: {e}"


@mcp.tool()
def federate_consolidate(fleet_dir: str, output_dir: str = "",
                         similarity_threshold: float = 0.85,
                         mode: str = "preserve") -> str:
    """Mount every machine cart in fleet_dir, find cross-machine pattern
    matches, and write a consolidated cart with cross-machine consensus
    captured as confirming-machine metadata. Drop-in replacement for
    Dennis's consolidate.py.

    Args:
        fleet_dir: Root of the fleet-learning directory
        output_dir: Where to write the consolidated cart (defaults to
            fleet_dir/../consolidated)
        similarity_threshold: Patterns with cross-machine cosine+hamming
            score above this count as CONFIRMED_BY (default 0.85)
        mode: Consolidation strategy. "preserve" (default, recommended for
            federated fleets) keeps ALL variants from every machine and
            stores cross-cart edges as metadata so the solver can weight
            trust contextually — aligns with the Web4 trust model. "collapse"
            picks one representative per CONFIRMED_BY component (smaller
            cart, loses individual machine voices).
    """
    log.info(f"federate_consolidate({fleet_dir}, output_dir={output_dir!r}, threshold={similarity_threshold}, mode={mode!r})")
    try:
        result = _fed.consolidate(
            fleet_dir,
            output_dir=output_dir or None,
            similarity_threshold=similarity_threshold,
            mode=mode,
        )
        if result.get("error"):
            return f"federate_consolidate: {result['error']}"
        return (
            f"Consolidated {result['n_machines']} machines, "
            f"{result['total_input_patterns']} input patterns → "
            f"{result['n_consolidated_patterns']} consolidated "
            f"({result['n_confirmed_pairs']} confirmed pairs, "
            f"{result['n_contradicted_pairs']} contradicted pairs, "
            f"mode={result['mode']}) "
            f"in {result['elapsed_seconds']}s. "
            f"Output: {result['output_path']}"
        )
    except Exception as e:
        return f"federate_consolidate failed: {e}"


@mcp.tool()
def federate_migrate_jsonl(jsonl_dir: str, output_dir: str = "",
                            in_place: bool = False) -> str:
    """One-time migration from JSONL learning files to brain carts.
    Walks a directory of fleet-learning/{machine}/*_learning.jsonl files and
    builds a kb.cart.npz for each machine.

    Args:
        jsonl_dir: Directory with per-machine subdirs containing JSONL files
        output_dir: Target directory for the new carts (defaults to jsonl_dir
            if in_place=True or jsonl_dir if not specified)
        in_place: Write carts alongside the JSONL files in their original
            machine directories. Non-destructive — JSONL files are not removed.
    """
    log.info(f"federate_migrate_jsonl({jsonl_dir}, output_dir={output_dir!r}, in_place={in_place})")
    try:
        result = _fed.migrate_jsonl(
            jsonl_dir,
            output_dir=output_dir or None,
            in_place=in_place,
        )
        lines = [
            f"Migration: {result['carts_built']} carts built from "
            f"{result['machines_processed']} machine dirs, "
            f"{result['total_entries']} entries in {result['elapsed_seconds']}s"
        ]
        if result["errors"]:
            lines.append(f"  {len(result['errors'])} errors:")
            for err in result["errors"][:5]:
                lines.append(f"    ! {err}")
        return "\n".join(lines)
    except Exception as e:
        return f"federate_migrate_jsonl failed: {e}"


@mcp.tool()
def federate_load(fleet_dir: str) -> str:
    """Mount every machine's federated cart in fleet_dir into the multi-cart
    pool with role='federated'. Used by solvers at session start to make the
    fleet's accumulated learning available for cross-machine search via
    multi_search(scope='all', role_filter='federated').

    Args:
        fleet_dir: Root of the fleet-learning directory (parent of machine dirs)
    """
    log.info(f"federate_load({fleet_dir})")
    try:
        result = _fed.load_fleet(fleet_dir)
        machine_list = ", ".join(result["machines"]) if result["machines"] else "none"
        out = (
            f"Loaded fleet from {fleet_dir}: {len(result['mounted'])} machines mounted "
            f"({machine_list}), {result['total_patterns']} total patterns"
        )
        if result["errors"]:
            out += f"\n  {len(result['errors'])} errors:"
            for err in result["errors"][:3]:
                out += f"\n    ! {err.get('machine', '?')}: {err.get('error', '?')}"
        return out
    except Exception as e:
        return f"federate_load failed: {e}"


# =============================================================================
# MEMBOX — Multi-user shared cart with locking + agent attribution
# =============================================================================
# Spec: docs/RFC/membox-phase1-implementation.md
# Implementation: membox.py (built on multi_cart.py)
#
# Membox is the third mode of the three-mode framework (single-user / federated
# / multiuser). Multiple agents safely write to the same cart with per-agent
# attribution and a write mutex that guarantees serialization without blocking
# reads. Phase 1 ships locking + tagging only — version chains, dispute
# detection, and permissions come in Phases 2-4.

import membox as _mb


@mcp.tool()
def membox_mount(cart_path: str, cart_id: str = "", role: str = "",
                 lease_seconds: int = 30, verify_integrity: bool = True) -> str:
    """Mount a brain cart in Membox mode (multi-user shared with locking).

    Multiple agents can write to the cart safely via membox_imprint, with
    each write attributed to the calling agent_id. Reads via membox_search
    never block on the write lock.

    Args:
        cart_path: Filesystem path to the cart file (.npz, .pkl, or split format)
        cart_id: Stable identifier for the mount (defaults to filename)
        role: Optional semantic tag (e.g. 'team_kb', 'project_notes')
        lease_seconds: Auto-release timeout if a holder crashes (default 30)
        verify_integrity: Reject carts with stale manifests (default True)
    """
    log.info(f"membox_mount({cart_path}, cart_id={cart_id!r}, role={role!r}, lease={lease_seconds}s)")
    try:
        result = _mb.mount(
            cart_path,
            cart_id=cart_id or None,
            role=role or None,
            lease_seconds=lease_seconds,
            verify_integrity=verify_integrity,
        )
        return (
            f"Mounted '{result['cart_id']}' in Membox mode "
            f"(role={result.get('role')}, n_patterns={result['n_patterns']}, "
            f"lease={result['lease_seconds']}s)"
        )
    except Exception as e:
        return f"membox_mount failed: {e}"


@mcp.tool()
def membox_unmount(cart_id: str) -> str:
    """Unmount a Membox cart. Releases the lock if held."""
    log.info(f"membox_unmount({cart_id})")
    try:
        result = _mb.unmount(cart_id)
        return f"Unmounted Membox cart '{cart_id}' (was {result.get('n_patterns', '?')} patterns)"
    except Exception as e:
        return f"membox_unmount failed: {e}"


@mcp.tool()
def membox_list() -> str:
    """List every Membox-mounted cart with current lock state and write stats."""
    log.info("membox_list()")
    try:
        mounts = _mb.list_mounts()
        if not mounts:
            return "No carts mounted in Membox mode."
        lines = [f"Membox pool: {len(mounts)} carts"]
        for m in mounts:
            lock = m["lock"]
            holder = lock.get("holder") or "(idle)"
            lines.append(
                f"  '{m['cart_id']}' role={m.get('role')} "
                f"n_patterns={m['n_patterns']} "
                f"lock_holder={holder} "
                f"acquires={lock.get('acquire_count', 0)}"
            )
        return "\n".join(lines)
    except Exception as e:
        return f"membox_list failed: {e}"


@mcp.tool()
def membox_imprint(cart_id: str, text: str, agent_id: str,
                   tags: str = "", reasoning: str = "",
                   origin: str = "agent", timeout_ms: int = 5000) -> str:
    """Write a new pattern to a Membox cart with agent_id attribution.

    Acquires the write lock, writes the pattern with full per-agent metadata
    (agent_id, written_at, origin, tags, reasoning), releases the lock.
    Returns the new local_addr or an error if the lock can't be acquired
    within timeout_ms.

    Args:
        cart_id: Membox-mounted cart to write to
        text: Pattern text content (the thing to embed and store)
        agent_id: Who's writing — REQUIRED, no anonymous writes in Membox
        tags: Optional comma-separated tags
        reasoning: Optional explanation of WHY this is being written (audit trail)
        origin: 'agent' (default) | 'human' | 'system'
        timeout_ms: How long to wait for the write lock (default 5000)
    """
    log.info(f"membox_imprint(cart_id={cart_id}, agent={agent_id}, text_len={len(text)})")
    try:
        result = _mb.imprint(
            cart_id, text=text, agent_id=agent_id,
            tags=tags, reasoning=reasoning, origin=origin,
            timeout_ms=timeout_ms,
        )
    except ValueError as e:
        return f"membox_imprint error: {e}"
    except Exception as e:
        return f"membox_imprint failed: {e}"

    if not result.get("ok"):
        if result.get("error") == "lock_timeout":
            return (
                f"Lock timeout — cart '{cart_id}' is currently held by "
                f"{result.get('current_holder')!r}. Try again or increase timeout_ms."
            )
        return f"membox_imprint failed: {result.get('error')}"

    return (
        f"Imprinted to '{cart_id}' as agent {agent_id!r} "
        f"at local_addr={result['local_addr']} (written_at={result['written_at']})"
    )


@mcp.tool()
def membox_search(cart_id: str, query: str, top_k: int = 10,
                  agent_id: str = "") -> str:
    """Search a Membox cart. Never blocks on the write lock — reads always
    succeed even while another agent is writing.

    Returns ranked results with per-pattern Membox metadata visible
    (agent_id, written_at, origin, reasoning, tags) so the consumer can
    see who wrote what.

    Args:
        cart_id: Membox-mounted cart to search
        query: Natural language query
        top_k: Max results to return (default 10)
        agent_id: Optional — who's reading (for audit logging in Phase 4)
    """
    if len(query) > MAX_QUERY_LENGTH:
        return f"Query too long ({len(query)} chars). Max is {MAX_QUERY_LENGTH}."
    log.info(f"membox_search(cart_id={cart_id}, query={query[:60]!r})")

    try:
        result = _mb.search(cart_id, query, top_k=top_k, agent_id=agent_id or None)
    except ValueError as e:
        return f"membox_search error: {e}"
    except Exception as e:
        return f"membox_search failed: {e}"

    results = result.get("results", [])
    if not results:
        return f"No results for '{query}' in '{cart_id}' ({result.get('elapsed_ms', '?')}ms)"

    lines = [f"membox_search '{cart_id}': {len(results)} results in {result.get('elapsed_ms', '?')}ms\n"]
    for rank, r in enumerate(results, 1):
        if r.get("score", 0) < 0.1:
            continue
        membox_meta = r.get("membox_meta") or {}
        attrib = ""
        if isinstance(membox_meta, dict) and membox_meta.get("agent_id"):
            written = membox_meta.get("written_at", "")
            attrib = f"  [agent={membox_meta['agent_id']} @ {written[:19]}]"
        lines.append(f"#{rank} [#{r['local_addr']}] [{r['score']:.3f}]{attrib} {r['text'][:300]}")
    return "\n\n".join(lines)


@mcp.tool()
def membox_acquire_lock(cart_id: str, agent_id: str, timeout_ms: int = 5000) -> str:
    """Manually acquire the write lock on a Membox cart. Most callers should
    use membox_imprint instead, which handles acquire+write+release atomically.

    Use this directly only when you need to do multiple writes in sequence
    without releasing the lock between them.

    Args:
        cart_id: Membox-mounted cart
        agent_id: Who's acquiring (required)
        timeout_ms: How long to wait if the lock is held (default 5000)
    """
    log.info(f"membox_acquire_lock(cart_id={cart_id}, agent={agent_id})")
    try:
        ok = _mb.acquire_lock(cart_id, agent_id, timeout_ms=timeout_ms)
    except ValueError as e:
        return f"membox_acquire_lock error: {e}"
    if ok:
        return f"Lock acquired on '{cart_id}' by agent {agent_id!r}"
    holder = _mb.lock_holder(cart_id)
    return f"Lock timeout — '{cart_id}' is held by {holder!r}, try again or increase timeout_ms"


@mcp.tool()
def membox_release_lock(cart_id: str, agent_id: str) -> str:
    """Release the write lock on a Membox cart. Only the current holder may release.

    Args:
        cart_id: Membox-mounted cart
        agent_id: The agent currently holding the lock
    """
    log.info(f"membox_release_lock(cart_id={cart_id}, agent={agent_id})")
    try:
        _mb.release_lock(cart_id, agent_id)
        return f"Lock released on '{cart_id}' by agent {agent_id!r}"
    except (ValueError, PermissionError, RuntimeError) as e:
        return f"membox_release_lock error: {e}"


@mcp.tool()
def membox_lock_holder(cart_id: str) -> str:
    """Return the agent_id currently holding the write lock, or '(idle)'."""
    log.info(f"membox_lock_holder(cart_id={cart_id})")
    try:
        holder = _mb.lock_holder(cart_id)
    except ValueError as e:
        return f"membox_lock_holder error: {e}"
    if holder is None:
        return f"'{cart_id}': lock is idle"
    return f"'{cart_id}': lock held by agent {holder!r}"


@mcp.tool()
def membox_status(cart_id: str) -> str:
    """Return Membox status for a cart: lock state, write counts per agent,
    pattern count, recent write log.
    """
    log.info(f"membox_status(cart_id={cart_id})")
    try:
        s = _mb.status(cart_id)
    except ValueError as e:
        return f"membox_status error: {e}"
    lines = [
        f"Membox status for '{cart_id}':",
        f"  Patterns: {s['n_patterns']}",
        f"  Lock holder: {s['lock'].get('holder') or '(idle)'}",
        f"  Lock acquires: {s['lock'].get('acquire_count', 0)}",
        f"  Lock waits: {s['lock'].get('wait_count', 0)}",
        f"  Writes by agent:",
    ]
    for agent, count in sorted(s["writes_by_agent"].items()):
        lines.append(f"    {agent}: {count}")
    if s.get("recent_writes"):
        lines.append(f"  Recent writes (last {len(s['recent_writes'])}):")
        for w in s["recent_writes"]:
            lines.append(
                f"    [{w['written_at'][:19]}] {w['agent_id']} → addr={w['local_addr']}: "
                f"{w['text_preview'][:80]}"
            )
    return "\n".join(lines)


@mcp.tool()
def get_status(session_id: str = "") -> str:
    """Get server status: mounted cartridge, memory count, GPU availability.

    Args:
        session_id: Session identifier (uses default session if empty)
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"get_status(session={session_id})")

    cart = state["cartridge_name"] or "None"
    n = len(state["texts"]) if state["texts"] else 0
    dim = state["embeddings"].shape[1] if state["embeddings"] is not None and len(state["embeddings"]) > 0 else 0
    has_hamming = state["binary_corpus"] is not None
    ham_bytes = f"{n * 96:,} bytes" if has_hamming else "N/A"
    gpu = "Ready" if _gpu_state["available"] else "Not available"
    modified = " (unsaved changes)" if state["modified"] else ""
    active_sessions = len(_sessions)

    hippo = state.get("hippocampus")
    hippo_str = "No"
    if hippo:
        n_linked = sum(1 for h in hippo if h.get("prev") or h.get("next"))
        hippo_str = f"Yes ({n_linked} linked)"

    return (
        f"Cartridge: {cart}{modified} | "
        f"Memories: {n}/{MAX_ENTRIES} | "
        f"Embedding dim: {dim} | "
        f"Hamming index: {'Yes' if has_hamming else 'No'} ({ham_bytes}) | "
        f"Hippocampus: {hippo_str} | "
        f"GPU: {gpu} | "
        f"Sessions: {active_sessions} | "
        f"Session: {session_id} | "
        f"Search: cosine+hamming 70/30+kw | "
        f"Embed: nomic-embed-text-v1.5 via SentenceTransformer"
    )


@mcp.tool()
def passage_links(idx: int, session_id: str = "") -> str:
    """Get hippocampus navigation links for a passage by index.

    Returns prev/next pointers, source hash, sequence position, flags,
    and the text of linked passages for easy traversal.

    Args:
        idx: Passage index (0-based, from search results)
        session_id: Session identifier (uses default session if empty)
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"passage_links(idx={idx}, session={session_id})")

    if state["cartridge_name"] is None:
        return "No cartridge mounted."

    hippo = state.get("hippocampus")
    if not hippo:
        return "No hippocampus metadata in this cartridge."

    if idx < 0 or idx >= len(hippo):
        return f"Index {idx} out of range (0-{len(hippo)-1})."

    meta = hippo[idx]
    texts = state["texts"]

    lines = [f"Passage #{idx} metadata:"]
    lines.append(f"  pattern_id:  {meta['pattern_id']}")
    lines.append(f"  seq:         {meta['sequence_num']}")
    lines.append(f"  source_hash: {meta['source_hash']:08x}")
    lines.append(f"  timestamp:   {meta['timestamp']}")

    flags = []
    if meta["flags"] & 0x01: flags.append("TOMBSTONE")
    if meta["flags"] & 0x02: flags.append("PINNED")
    if meta["flags"] & 0x04: flags.append("HAS_PARENT")
    if meta["flags"] & 0x08: flags.append("HAS_CHILD")
    if meta["flags"] & 0x10: flags.append("HAS_SIBLING")
    lines.append(f"  flags:       {', '.join(flags) if flags else 'none'}")

    # Prev/Next navigation with text previews
    if meta["prev"] is not None:
        pi = meta["prev"] - 1  # pattern_id is 1-based, texts are 0-based
        if 0 <= pi < len(texts):
            preview = texts[pi][:120].replace("\n", " ")
            lines.append(f"  PREV (#{pi}): {preview}...")
        else:
            lines.append(f"  PREV: pattern {meta['prev']} (out of range)")
    else:
        lines.append(f"  PREV: none (start of document)")

    if meta["next"] is not None:
        ni = meta["next"] - 1
        if 0 <= ni < len(texts):
            preview = texts[ni][:120].replace("\n", " ")
            lines.append(f"  NEXT (#{ni}): {preview}...")
        else:
            lines.append(f"  NEXT: pattern {meta['next']} (out of range)")
    else:
        lines.append(f"  NEXT: none (end of document)")

    if meta["sibling"] is not None:
        si = meta["sibling"] - 1
        if 0 <= si < len(texts):
            preview = texts[si][:120].replace("\n", " ")
            lines.append(f"  SIBLING (#{si}): {preview}...")

    return "\n".join(lines)


@mcp.tool()
def get_passage(idx: int, session_id: str = "") -> str:
    """Retrieve a passage by index with full text and navigation links.

    Use this after memory_search to navigate to prev/next passages
    within the same document. Search results include [prev=#N next=#N]
    hints — pass those indices here to read adjacent passages.

    Args:
        idx: Passage index (0-based, from search results or prev/next hints)
        session_id: Session identifier (uses default session if empty)
    """
    session_id = _resolve_session_id(session_id)
    state = _get_session(session_id)
    log.info(f"get_passage(idx={idx}, session={session_id})")

    if state["cartridge_name"] is None:
        return "No cartridge mounted."

    texts = state["texts"]
    if idx < 0 or idx >= len(texts):
        return f"Index {idx} out of range (0-{len(texts)-1})."

    full_text = texts[idx]

    # Navigation from hippocampus
    nav_parts = []
    hippo = state.get("hippocampus")
    if hippo and idx < len(hippo):
        meta = hippo[idx]
        if meta["prev"] is not None:
            pi = meta["prev"] - 1
            nav_parts.append(f"prev=#{pi}")
        if meta["next"] is not None:
            ni = meta["next"] - 1
            nav_parts.append(f"next=#{ni}")

    nav = f" [{' '.join(nav_parts)}]" if nav_parts else ""
    header = f"Passage #{idx}{nav} from '{state['cartridge_name']}':\n\n"

    _log_activity(session_id, "get_passage", f"idx={idx}", 0)
    return header + full_text


# ============================================================
# HTTP MIDDLEWARE (auth + rate limiting)
# ============================================================

# --- Rate Limiter (sliding window, per-IP) ---
_rate_window: dict[str, collections.deque] = {}
RATE_LIMIT = 60          # requests per window
RATE_WINDOW_SEC = 60     # window size in seconds


def _check_rate_limit(client_id: str) -> bool:
    """Returns True if request is allowed, False if rate-limited."""
    now = time.time()
    if client_id not in _rate_window:
        _rate_window[client_id] = collections.deque()
    window = _rate_window[client_id]
    # Evict expired entries
    while window and window[0] < now - RATE_WINDOW_SEC:
        window.popleft()
    if len(window) >= RATE_LIMIT:
        return False
    window.append(now)
    return True


def _setup_http_middleware(api_key: str | None):
    """Add auth + rate limiting middleware when running in HTTP mode."""
    try:
        from fastmcp.server.middleware import Middleware, MiddlewareContext
        from fastmcp.server.dependencies import get_http_request
    except ImportError:
        log.warning("FastMCP middleware not available — running HTTP without auth/rate-limiting")
        return

    class AuthAndRateLimitMiddleware(Middleware):
        async def on_call_tool(self, context: MiddlewareContext, call_next):
            try:
                request = get_http_request()
            except Exception:
                # Not an HTTP request (shouldn't happen in HTTP mode)
                return await call_next(context)

            # Rate limiting by client IP
            client_ip = request.client.host if request.client else "unknown"
            if not _check_rate_limit(client_ip):
                from fastmcp.exceptions import ToolError
                log.warning(f"Rate limited: {client_ip}")
                raise ToolError(f"Rate limited. Max {RATE_LIMIT} requests per {RATE_WINDOW_SEC}s.")

            # API key auth (if configured)
            if api_key:
                auth_header = request.headers.get("authorization", "")
                if not auth_header.startswith("Bearer "):
                    from fastmcp.exceptions import ToolError
                    raise ToolError("Access denied: missing Bearer token")
                token = auth_header.removeprefix("Bearer ").strip()
                if token != api_key:
                    from fastmcp.exceptions import ToolError
                    log.warning(f"Auth failed from {client_ip}")
                    raise ToolError("Access denied: invalid API key")

            return await call_next(context)

    mcp.add_middleware(AuthAndRateLimitMiddleware())
    if api_key:
        log.info("HTTP middleware: auth (Bearer token) + rate limiting enabled")
    else:
        log.info("HTTP middleware: rate limiting enabled (no auth — set MEMBOT_API_KEY to require)")


# ============================================================
# MEMPACK ENDPOINTS (Andy 2026-05-12)
# ============================================================
# Per-user writable carts. Pattern 0 in manifest sidecar, Pattern I at idx=1.
# Storage: cartridges/users/<owner_id>/<name>.cart.npz
# Discovery: find_mempacks(owner_id) walks MEMPACK_BASE_DIR recursively.
# Auth model (MVP): existing write API key required; owner_id passed in body.
# Long-term: verify Supabase JWT directly, derive owner_id from `sub` claim.

_DEFAULT_PATTERN_I_TEMPLATE = """# Pattern I — Default Mempack Behavior

You are an AI agent using a Mempack provisioned for {owner_id_short}.

## Identity
- User: {owner_id}
- Created: {created_at}
- Mempack version: 1.0

## How to address the user
Refer to the user as **{owner_id_short}** (or **{owner_id}**) in every
response. Do NOT use the UUID you see in tool-call arguments — that's a
database key (used by `mount_cartridge(name, owner_id=...)`, Supabase
Storage paths, etc.), not the user's name. The user-facing identifier is
right here, in Pattern I.

## Behavior
- Read this Pattern I first on every session to remind yourself who you are.
- Search your Mempack before falling back to external sources — past you
  may have already done the work.
- Store findings worth keeping via `memory_store`. Tag them so future-you
  can filter (e.g. tags="ARCHITECTURE", "DECISION", "TODO", "DISPATCH").
- Update this Pattern I (`mempack_update_pattern_i`) as your behavior, learned
  preferences, or specialization evolves. Treat it as a living MEMORY.md.
- When the user dispatches a task (look for tag="DISPATCH" or "TASK" patterns),
  acknowledge it, work it, then mark progress in Active threads below.
- If a `memory_search("DISPATCH")` returns no open dispatches, tell the user
  in chat ("No open dispatches in your Mempack — what would you like me to
  work on?") rather than inventing work.
- When existing FINDING/SUMMARY patterns look related to an open DISPATCH,
  ASK the user whether to extend / refresh / give a second opinion / audit /
  treat the dispatch as already closed — don't silently skip OR silently
  re-run.

## Sign your work
Every `memory_store` call MUST end the content body with a signature line:
`[signed: <model-name>@<host>, <ISO-timestamp>]`
e.g. `[signed: claude-sonnet-4-6@claude-desktop, 2026-05-19T12:30:00Z]`.
This lets the user audit which agent produced which pattern across
multi-agent runs.

## Reporting completion
Reporting "done" / "task complete" / "findings stored" to the user WITHOUT
having called `memory_store` is forbidden. Describing findings in chat is
not storing them. The Mempack must contain the work product in stored form
before any completion claim — if you haven't called memory_store and gotten
a "Stored as passage #N" response, you're not done.

## Tools at my disposal (full schemas via MCP tools/list)
- `memory_search(query, top_k)` — semantic search across the mounted cart.
  Always try this first when the user asks about something.
- `memory_store(content, tags)` — add a passage to the mounted Mempack.
  Tag liberally; tags are how future-you slices the corpus.
- `mempack_read_pattern_i()` — re-read this behavioral text mid-session.
- `mempack_update_pattern_i(text)` — overwrite this Pattern I in place.
  Use to record durable learnings: "user prefers X", "I've specialized in Y".
- `mount_cartridge(name)` — switch to a different cart (knowledge cart, or
  another Mempack you have access to). Your current Mempack goes back to
  Storage; the new one comes off it.
- `get_status()` / `list_cartridges()` — orientation tools when unsure.

## Specialization
(none yet — accumulates with use. Edit this section as you discover what
you're good for. Examples: "research synthesis on histopathology papers",
"daily journal triage", "Portland-area news monitoring".)

## Active threads
(none yet — track in-flight investigations here so they survive across
sessions. Format: "- [<status>] <thread> — <next action>")
"""

_DEFAULT_BRIEFING_TEMPLATE = (
    "Cart briefing — {owner_id_short}'s personal Mempack.\n\n"
    "STORAGE: blob at mempacks/{owner_id}/{name}.cart.npz in Supabase Storage; "
    "metadata in the mempacks Postgres table. Free-tier cap: 10 MB per Mempack.\n\n"
    "STRUCTURE: cart_type='agent-memory'. Pattern 0 (this manifest) holds "
    "ownership/perms. Pattern I (idx=1) holds the agent's behavioral instructions — "
    "read it FIRST on every session, then update it as behavior evolves. "
    "Patterns 2+ are accumulated findings, dispatched tasks, journal entries.\n\n"
    "WRITES: use `memory_store` to add passages, `mempack_update_pattern_i` to "
    "evolve self-instructions. All writes round-trip to Supabase and are logged "
    "in the activity feed visible to the user at project-you.app/membot/app.\n\n"
    "OWNERSHIP: this Mempack belongs to a single user (you act on their behalf). "
    "Cross-user reads/writes are refused at the API layer."
)


_RESEARCHER_PATTERN_I_TEMPLATE = """# Pattern I — Researcher

You are a research agent operating on behalf of {owner_id_short}. Your role is
to investigate dispatched topics, gather evidence, synthesize findings, and
write durable notes back into your Mempack so future-you (or another agent that
mounts this Mempack) can build on what you've learned.

## Identity
- Role: Researcher
- User: {owner_id}
- Created: {created_at}
- Mempack version: 1.0

## How to address the user
Refer to the user as **{owner_id_short}** (or **{owner_id}**) in every
response. Do NOT use the UUID you see in tool-call arguments — that's just a
database key (used internally by `mount_cartridge(name, owner_id=...)`,
Supabase Storage paths, mempack_patterns rows). It is not the user's name.
The user-facing identifier is here, in Pattern I.

## Mount-time checklist (do this EVERY session)
1. Re-read this Pattern I.
2. `memory_search("DISPATCH", top_k=10)` — surface the user's open dispatches.
3. `memory_search("ACTIVE", top_k=10)` — surface your in-flight threads.
4. Acknowledge any new DISPATCH back to the user with a one-liner before
   starting work, so they see it in the activity feed.
5. If there are NO DISPATCH-tagged patterns, tell the user in chat:
   "No open dispatches in your Mempack — what would you like me to work on?"
   Do NOT invent work; do NOT assume past findings mean past dispatches are
   "done"; ask.

## DISPATCH body format (server-stamped)
DISPATCH patterns the user queues from the dashboard have this shape:

    [DISPATCH] <user's instruction>

    [dispatched: 2026-05-19T18:30:00Z]

The trailing `[dispatched: <ISO timestamp UTC>]` is server-stamped at imprint
time — use it for freshness assessment and ordering. You can trust this
timestamp; it is NOT something the user typed. Don't second-guess the date —
treat it as ground truth for "when this task was queued."

## When existing work product is already in the Mempack
If you find DISPATCH patterns AND existing FINDING/SUMMARY patterns that
look related to the same topic, the user's intent could be ANY of six modes:

1. **First-time run** — no prior work exists yet; do the work fresh.
2. **Refresh** — prior findings exist but they may be stale; update them.
3. **Second opinion** — prior findings exist; the user wants a DIFFERENT
   agent's take on the same dispatch (this is why signing your work
   matters — see Sign-your-work section below).
4. **Extend** — prior findings are good; user wants ADDITIONAL findings
   (e.g., "you found 10, find 10 more").
5. **Audit** — prior findings exist; user wants you to VERIFY them, flag
   anything that looks wrong, not redo from scratch.
6. **Skip** — prior findings already closed the dispatch; user just hasn't
   garbage-collected the DISPATCH tag yet.

These intents look identical from the Mempack's read-only state — you cannot
distinguish them without asking. Default behavior: **surface what you found
and ask, before proceeding**:

> "I see DISPATCH at idx:N (your task), and FINDINGs at idx:M-P + SUMMARY
> at idx:Q that look related. Should I (a) treat the dispatch as already
> closed, (b) extend with additional findings, (c) give a second opinion,
> (d) audit the existing findings, (e) refresh / re-run from scratch, or
> (f) something else?"

Do NOT silently re-run AND do NOT silently skip. Both produce
the wrong outcome for at least one of the six modes above. Clarify first.

## Working a dispatch
- Cite sources in each stored note (URLs, paper titles, page numbers).
- Tag every stored note with at least one of: FINDING, EVIDENCE,
  OPEN_QUESTION, DEAD_END.
- Distinguish OBSERVATION (what you found) from INFERENCE (what you concluded).
  Never present an inference as a fact.
- If a dispatch spans sessions, store an ACTIVE pattern with status
  (`investigating` / `synthesizing` / `blocked` / `done`) and a one-line
  next-step, then update Active threads below.

## Sign your work
Every `memory_store` call MUST end the content body with a signature line:
`[signed: <model-name>@<host>, <timestamp>]`
For example:
`[signed: claude-sonnet-4-6@claude-desktop, 2026-05-19T12:30:00Z]`
or
`[signed: qwen3:8b@goose, 2026-05-19T18:45:00Z]`
This is how the user audits which agent contributed which pattern across
multi-agent runs. Do not skip this — unsigned patterns are anonymous and
hard to audit.

## Closing a dispatch — STRICT, no shortcuts
1. Store every finding as a separate FINDING-tagged pattern via `memory_store`
   (with signature line). Each finding is its own pattern, not consolidated.
2. Store a SUMMARY-tagged pattern (1-3 paragraphs) referencing the FINDING
   passage indices.
3. Only AFTER both steps above succeed (you've called memory_store and
   gotten "Stored as passage #N" back), report completion to the user.
4. Reporting "task complete" / "findings stored" / "done" to the user
   WITHOUT having called memory_store IS FORBIDDEN. Describing findings
   in chat is not storing them. The Mempack must contain the work product
   in stored form before any completion claim.
5. Update Active threads below to mark the thread `done`.

## Tools at my disposal (full schemas via MCP tools/list)
- `memory_search(query, top_k)` — semantic search across the mounted cart.
  Use this FIRST whenever the user asks about something — past-you may have
  already done the work.
- `memory_store(content, tags)` — save a finding, summary, or note. Tag
  liberally; tags are how future-you slices the corpus.
- `mempack_read_pattern_i()` — re-read this behavioral text.
- `mempack_update_pattern_i(text)` — overwrite this Pattern I (use to record
  durable learnings, evolved methodology, refined tag vocabulary).
- Whatever fetch / read / browse tools your host exposes. Discover via
  `tools/list`.

## Tag vocabulary for a Researcher Mempack
- `DISPATCH` — user's open instructions, queued by the dashboard
- `TASK` — user's open instructions, queued via your host
- `FINDING` — a single concrete result, with citation
- `EVIDENCE` — supporting quote / data point / page reference
- `SUMMARY` — synthesis across multiple findings (closes a dispatch)
- `OPEN_QUESTION` — something that surfaced but you couldn't answer
- `DEAD_END` — a path you explored that didn't pan out (so future-you
  doesn't redo it)
- `ACTIVE` — an in-flight thread to resume next session
- `METHOD` — a methodology you've found works for a class of research

## Specialization
(none yet — accumulates with use. Edit this as your research focus narrows.)

## Active threads
(none yet — track in-flight investigations here so they survive across
sessions. Format: "- [<status>] <topic> — <next action>")
"""


# Registry: id → (label, description, body). Listed via GET /api/mempack/templates;
# applied to Pattern I via the dashboard template picker. "coming_soon" sentinel
# entries surface in the dropdown as disabled options.
_PATTERN_I_TEMPLATES: dict[str, dict] = {
    "default": {
        "label":       "Default",
        "description": "Generic personal Mempack — tag-store-search loop, no specialization.",
        "body":        _DEFAULT_PATTERN_I_TEMPLATE,
    },
    "researcher": {
        "label":       "Researcher",
        "description": "Investigates dispatches, cites sources, distinguishes observation from inference, "
                       "logs FINDING / EVIDENCE / OPEN_QUESTION / DEAD_END / SUMMARY.",
        "body":        _RESEARCHER_PATTERN_I_TEMPLATE,
    },
    "_coming_soon": {
        "label":       "More coming soon…",
        "description": "Writer, Code-helper, Journal triage, Portland-news monitor — on the menu.",
        "body":        None,
        "disabled":    True,
    },
}


def _build_starter_mempack_bytes(
    owner_id: str,
    name: str,
    briefing: str | None = None,
    pattern_i_text: str | None = None,
    email: str | None = None,
    full_name: str | None = None,
) -> dict:
    """Build the bytes + metadata needed to provision a fresh Mempack into Supabase.

    Returns a dict with: blob_bytes, hippocampus_bytes, texts, briefing,
    pattern_i_text, manifest. Suitable for passing to
    supabase_storage.auto_provision_primary or to the migration script.

    email/full_name (optional): when provided, the briefing + Pattern I body
    substitute the friendly identifiers into `{owner_id_short}` and
    `{owner_id}` slots so the agent reads "operating on behalf of <name>"
    instead of "operating on behalf of <uuid-prefix>". Falls back to the
    UUID-prefix display when neither is available (e.g. password sign-ups
    without metadata, or callers that don't know the user's metadata).
    """
    import io
    import struct as _struct
    from cartridge_builder import (
        HIPPO_FORMAT, FLAG_PINNED, FLAG_PERISH_ARCHIVAL,
        FORMAT_VERSION_CANONICAL, PERM_R, PERM_DEFAULT,
    )

    created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ")
    # Resolve the agent-facing identifiers. Keep `owner_id` (UUID) as the
    # storage path / API key throughout, but prefer human-friendly forms for
    # any text the agent will read in Pattern I / briefing.
    uuid_short = owner_id[:8] if owner_id else "unknown"
    email_local = email.split("@", 1)[0] if email and "@" in email else None
    label_short = full_name or email_local or uuid_short      # "Andy Grossberg" / "andy.grossberg" / "ececf504"
    label_full  = email or full_name or owner_id or "unknown" # "andy@gmail.com" / "Andy Grossberg" / UUID

    briefing = briefing or _DEFAULT_BRIEFING_TEMPLATE.format(
        owner_id=label_full, owner_id_short=label_short, name=name,
    )
    pattern_i_text = pattern_i_text or _DEFAULT_PATTERN_I_TEMPLATE.format(
        owner_id=label_full, owner_id_short=label_short, created_at=created_at,
    )

    idx0_marker = (
        f"Mempack header — Pattern 0 manifest in the mempacks row. "
        f"Pattern I (agent behavior) at idx=1. owner_id={owner_id}."
    )
    texts = [idx0_marker, pattern_i_text]

    # Embed both starter texts using the configured encoder
    embeddings = np.stack([
        embed_text(idx0_marker,    prefix="search_document"),
        embed_text(pattern_i_text, prefix="search_document"),
    ]).astype(np.float32)

    # Compress texts the same way save_as_npz does, so the blob shape matches
    # what mount expects to load.
    compressed_texts = [
        np.void(zlib.compress(t.encode("utf-8"), level=9)) for t in texts
    ]

    # Pack the NPZ to bytes in-memory (no filesystem touch).
    # pattern_0_owner: plants the v2 ownership flag IN the cart bytes so
    # ownership claim travels with the artifact (immutable-truth substrate).
    buf = io.BytesIO()
    np.savez_compressed(
        buf,
        embeddings=embeddings,
        passages=np.array(texts, dtype=object),
        compressed_texts=np.array(compressed_texts, dtype=object),
        version="mcp-v3",
        pattern_0_owner=_owner_id_to_uuid_bytes(owner_id),
    )
    blob_bytes = buf.getvalue()

    # Synthesize canonical H-blocks: pinned + archival on both starter patterns.
    # idx=0 marker is read-only; idx=1 Pattern I is RW so agent can update it.
    now_ts = int(time.time())
    h0 = _struct.pack(
        HIPPO_FORMAT,
        0,                              # pattern_id (header)
        FORMAT_VERSION_CANONICAL,
        1,                              # cartridge_type = 1 = agent-memory
        0, 1, 0,                        # parent, child=1, sibling
        0,                              # source_hash (N/A for header)
        0,                              # sequence_num
        now_ts,
        FLAG_PINNED | FLAG_PERISH_ARCHIVAL,
        PERM_R,
        b'\x00' * 34,
    )
    h1 = _struct.pack(
        HIPPO_FORMAT,
        1,                              # pattern_id
        FORMAT_VERSION_CANONICAL,
        1,                              # cartridge_type
        0, 0, 0,                        # parent=0, child=0 (no further patterns yet), sibling
        0, 1, now_ts,
        FLAG_PINNED | FLAG_PERISH_ARCHIVAL,
        PERM_DEFAULT,
        b'\x00' * 34,
    )
    hippocampus_bytes = h0 + h1

    manifest = {
        "version":      "mcp-v3",
        "cart_type":    CART_TYPE_AGENT_MEMORY,
        "owner_id":     owner_id,
        "name":         name,
        "owner_perms":  "rwda",
        "group_perms":  "",
        "world_perms":  "",
        "briefing":     briefing,
        "max_patterns": 0,
        "created_at":   created_at,
        "format_version": FORMAT_VERSION_CANONICAL,
    }

    return {
        "blob_bytes":       blob_bytes,
        "hippocampus_bytes": hippocampus_bytes,
        "texts":            texts,
        "briefing":         briefing,
        "pattern_i_text":   pattern_i_text,
        "manifest":         manifest,
    }


@mcp.custom_route("/api/mempack/create", methods=["POST", "OPTIONS"])
async def rest_mempack_create(request: Request) -> JSONResponse:
    """Provision a new Mempack for a user.

    Body:
        name        (str, required)  cart name (e.g. "primary")
        owner_id    (str, required)  Supabase user UUID
        briefing    (str, optional)  override default briefing template
        pattern_i   (str, optional)  override default Pattern I template

    Creates cartridges/users/<owner_id>/<name>.cart.npz with two starter
    patterns: idx=0 = marker, idx=1 = Pattern I (behavioral text). Manifest
    carries cart_type='agent-memory', owner_id, owner_perms='rwda', plus the
    briefing.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    if not _supabase_available():
        return JSONResponse(
            {"status": "error", "error": "Mempack backend not configured (SUPABASE env vars missing)."},
            status_code=503, headers=_cors_headers(),
        )
    # Step 3: require auth + caller can only provision FOR themselves.
    user_id, err = _require_authenticated(request)
    if err is not None:
        return err
    try:
        data = await request.json()
        name = data.get("name", "")
        owner_id = data.get("owner_id", "") or user_id  # default to caller

        if not name:
            return JSONResponse({"status": "error", "error": "name required"},
                                status_code=400, headers=_cors_headers())
        if not _UUID_RE.match(owner_id):
            return JSONResponse({"status": "error", "error": "owner_id must be a UUID"},
                                status_code=400, headers=_cors_headers())
        if owner_id != user_id:
            log.warning(f"OWNERSHIP DENIED: user={user_id[:8]} tried to create mempack for owner={owner_id[:8]}")
            return JSONResponse(
                {"status": "error", "error": "Forbidden — you can only provision Mempacks for yourself"},
                status_code=403, headers=_cors_headers(),
            )
        try:
            clean_name = sanitize_name(name)
        except ValueError as e:
            return JSONResponse({"status": "error", "error": str(e)},
                                status_code=400, headers=_cors_headers())

        import supabase_storage as sbs

        # Existence check — explicit 409 (auto-provision path uses different endpoint)
        existing = sbs.get_mempack_by_name(owner_id, clean_name)
        if existing:
            return JSONResponse(
                {"status": "error", "error": f"Mempack '{clean_name}' already exists for this user.",
                 "mempack_id": existing["id"]},
                status_code=409, headers=_cors_headers(),
            )

        # Build starter bytes + metadata. Lift email + full_name off the
        # caller's verified JWT so the agent-facing templates greet the user
        # by name/email instead of UUID prefix.
        _claims_for_provision = _get_user_claims_from_request(request) or {}
        _meta_for_provision = _claims_for_provision.get("user_metadata") or {}
        starter = _build_starter_mempack_bytes(
            owner_id=owner_id,
            name=clean_name,
            briefing=data.get("briefing"),
            pattern_i_text=data.get("pattern_i"),
            email=_claims_for_provision.get("email"),
            full_name=_meta_for_provision.get("full_name") or _meta_for_provision.get("name"),
        )

        # Atomic-ish provision: row(pending) -> blob upload -> pattern rows -> row(ready)
        try:
            row = sbs.insert_mempack(
                owner_id=owner_id,
                name=clean_name,
                pattern_count=2,
                size_bytes=len(starter["blob_bytes"]),
                briefing=starter["briefing"],
                pattern_i_text=starter["pattern_i_text"],
                manifest=starter["manifest"],
                cart_type=CART_TYPE_AGENT_MEMORY,
                status="pending",
            )
            mempack_id = row["id"]

            sbs.upload_blob(owner_id, clean_name, starter["blob_bytes"])
            sbs.insert_pattern_rows(mempack_id, starter["hippocampus_bytes"], starter["texts"])
            sbs.mark_mempack_ready(mempack_id)
            sbs.log_provision(owner_id, mempack_id, "manual", "created")
            # Activity log: 'create' event so the dashboard's first feed entry
            # is the Mempack's birthday. Unconditional — pre-dates any user
            # toggle change. Best-effort.
            try:
                sbs.append_activity(
                    mempack_id=mempack_id,
                    event_type="create",
                    summary=f"Mempack '{clean_name}' provisioned",
                    metadata={
                        "blob_bytes":    len(starter["blob_bytes"]),
                        "pattern_count": 2,
                        "trigger":       "manual",
                    },
                )
            except Exception as e:
                log.warning(f"Mempack create activity-log append failed (non-fatal): {e}")
        except Exception as e:
            log.error(f"REST /api/mempack/create provision flow failed: {e}")
            try:
                sbs.log_provision(owner_id, None, "manual", "failed", error=str(e))
            except Exception:
                pass
            return JSONResponse({"status": "error", "error": f"provision failed: {e}"},
                                status_code=500, headers=_cors_headers())

        log.info(f"Mempack created via Supabase: mempack_id={mempack_id} owner={owner_id} name={clean_name}")
        return JSONResponse({
            "status": "ok",
            "name": clean_name,
            "owner_id": owner_id,
            "mempack_id": mempack_id,
            "storage_bucket": sbs.BUCKET,
            "storage_path": sbs.storage_path(owner_id, clean_name),
            "size_bytes": len(starter["blob_bytes"]),
            "cart_type": CART_TYPE_AGENT_MEMORY,
            "briefing": starter["briefing"],
            "pattern_i": starter["pattern_i_text"],
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/create error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/whoami", methods=["GET", "OPTIONS"])
async def rest_whoami(request: Request) -> JSONResponse:
    """Return the caller's identity, derived from the Supabase JWT cookie.

    Cookies set by Supabase JS are HttpOnly and not readable from JS, so the
    /app dashboard can't detect them client-side. This endpoint reads the
    cookie server-side and tells the dashboard whether the caller is signed
    in and what their user UUID is. Anonymous callers get {signed_in: false,
    user_id: null}; signed-in callers get {signed_in: true, user_id: "<uuid>"}.

    Always returns 200 — auth state is data, not an error.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    claims = _get_user_claims_from_request(request)
    user_id = claims.get("sub") if claims else None

    # Surface the display fields the dashboard auth chip needs. The Supabase
    # JWT carries `email` as a top-level claim; OAuth providers populate
    # `user_metadata.{avatar_url, full_name, name, picture}` on first sign-in.
    # Password sign-ups have an email but no avatar/name unless the user adds
    # them via a profile-edit flow (TBD).
    email = None
    full_name = None
    avatar_url = None
    if claims:
        email = claims.get("email")
        meta = claims.get("user_metadata") or {}
        full_name = meta.get("full_name") or meta.get("name")
        avatar_url = meta.get("avatar_url") or meta.get("picture")

    # Diagnostic block — exposes cookie NAMES (not values) and whether the
    # JWT secret is configured. Helps debug nginx-strips-cookies / wrong-
    # cookie-name / missing-secret scenarios. Values are never returned.
    cookie_header = request.headers.get("cookie") or request.headers.get("Cookie") or ""
    cookie_names = []
    for chunk in cookie_header.split(";"):
        n = chunk.strip().split("=", 1)[0]
        if n:
            cookie_names.append(n)
    has_auth_header = bool(request.headers.get("authorization") or request.headers.get("Authorization"))
    sb_cookie_count = sum(1 for n in cookie_names if n.startswith("sb-"))

    return JSONResponse({
        "status":     "ok",
        "signed_in":  bool(user_id),
        "user_id":    user_id,
        "email":      email,
        "full_name":  full_name,
        "avatar_url": avatar_url,
        "debug": {
            "cookie_count":    len(cookie_names),
            "cookie_names":    cookie_names,
            "sb_cookie_count": sb_cookie_count,
            "has_auth_header": has_auth_header,
            "jwt_secret_set":  bool(os.environ.get("SUPABASE_JWT_SECRET")),
        },
    }, headers=_cors_headers())


@mcp.custom_route("/api/auth/signout", methods=["POST", "OPTIONS"])
async def rest_auth_signout(request: Request) -> JSONResponse:
    """Clear all Supabase auth cookies on the .project-you.app domain.

    Supabase JS writes cookies named `sb-<projectref>-auth-token`, optionally
    chunked across `.0`, `.1`, etc. when the session blob exceeds a single
    cookie's size limit. We can't predict the chunk count in advance, so we
    enumerate every sb-* cookie name in the incoming request and expire each
    one via a Set-Cookie response header with Max-Age=0.

    Cookie attributes (must MATCH what the issuer set, or browsers won't
    overwrite):
      - Domain=.project-you.app   (shared across project-you.app, /vps/app,
                                   /membot/app, future Heartbeat — set by
                                   @supabase/ssr's cookieOptions in VPS)
      - Path=/
      - SameSite=Lax
      - Secure                    (HTTPS only — set by @supabase/ssr in prod)

    Always returns 200 — sign-out is idempotent. If the caller wasn't signed
    in to begin with, no cookies get cleared and we return ok anyway.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())

    cookie_header = request.headers.get("cookie") or request.headers.get("Cookie") or ""
    sb_cookie_names: list[str] = []
    for chunk in cookie_header.split(";"):
        name = chunk.strip().split("=", 1)[0]
        if name.startswith("sb-"):
            sb_cookie_names.append(name)

    # Build a JSONResponse and attach a Set-Cookie header per cookie to expire.
    # FastAPI/Starlette's response.delete_cookie() handles the Set-Cookie
    # serialization correctly including all the attributes browsers require
    # for an overwrite-and-expire.
    resp = JSONResponse({
        "status":          "ok",
        "cleared_cookies": sb_cookie_names,
        "count":           len(sb_cookie_names),
    }, headers=_cors_headers())

    # In production this server sits behind nginx terminating HTTPS for
    # project-you.app. The Supabase JS cookies are scoped to .project-you.app
    # with Secure + SameSite=Lax. Match those exactly.
    is_prod = bool(os.environ.get("MEMBOT_PROD", "").strip()) or \
              request.headers.get("x-forwarded-proto") == "https"
    cookie_domain = ".project-you.app" if is_prod else None

    for name in sb_cookie_names:
        resp.delete_cookie(
            key=name,
            path="/",
            domain=cookie_domain,
            secure=is_prod,
            httponly=True,
            samesite="lax",
        )

    log.info(f"signout cleared {len(sb_cookie_names)} sb-* cookies (prod={is_prod})")
    return resp


@mcp.custom_route("/api/mempacks", methods=["GET", "OPTIONS"])
async def rest_mempacks_list(request: Request) -> JSONResponse:
    """List Mempacks for a given owner_id, with optional lazy auto-provision.

    Query params:
        owner_id        Supabase user UUID (required for auto-provision; if omitted,
                        returns all Mempacks across users — admin/debug use).
        auto_provision  "true" (default) | "false". When true AND owner_id is given
                        AND the user has zero Mempacks, automatically create a
                        starter 'primary' Mempack and return it. Idempotent.

    Path C lazy-provision: this is the user-facing entry point for getting a
    new user their first Mempack. No separate signup-trigger required.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    # Step 3: require auth + caller can only list their OWN Mempacks.
    user_id, err = _require_authenticated(request)
    if err is not None:
        return err
    try:
        # Default owner_id to caller; explicit owner_id must match caller.
        owner_id = request.query_params.get("owner_id", "") or user_id
        auto_provision_param = request.query_params.get("auto_provision", "true").lower()
        auto_provision = auto_provision_param not in ("false", "0", "no", "off")

        if not _UUID_RE.match(owner_id):
            return JSONResponse(
                {"status": "error", "error": "owner_id must be a UUID"},
                status_code=400, headers=_cors_headers(),
            )
        if owner_id != user_id:
            log.warning(f"OWNERSHIP DENIED: user={user_id[:8]} tried to list mempacks of {owner_id[:8]}")
            return JSONResponse(
                {"status": "error", "error": "Forbidden — you can only list your own Mempacks"},
                status_code=403, headers=_cors_headers(),
            )

        mempacks = find_mempacks(owner_id=owner_id)

        # Path C lazy-provision: empty list for a specific owner -> create primary
        provisioned = False
        if owner_id and not mempacks and auto_provision and _supabase_available():
            try:
                import supabase_storage as sbs
                # Lift email + full_name off the caller's JWT so the auto-
                # provisioned briefing + Pattern I greet by name/email.
                _claims_p = _get_user_claims_from_request(request) or {}
                _meta_p = _claims_p.get("user_metadata") or {}
                starter = _build_starter_mempack_bytes(
                    owner_id=owner_id, name="primary",
                    email=_claims_p.get("email"),
                    full_name=_meta_p.get("full_name") or _meta_p.get("name"),
                )
                sbs.auto_provision_primary(
                    owner_id=owner_id,
                    starter_blob_bytes=starter["blob_bytes"],
                    starter_hippocampus=starter["hippocampus_bytes"],
                    starter_texts=starter["texts"],
                    pattern_count=2,
                    briefing=starter["briefing"],
                    pattern_i_text=starter["pattern_i_text"],
                    manifest=starter["manifest"],
                    trigger_source="lazy_list",
                )
                mempacks = find_mempacks(owner_id=owner_id)
                provisioned = True
                log.info(f"Path C lazy-provisioned primary Mempack for owner={owner_id}")
            except Exception as e:
                # Auto-provision failure is non-fatal; return the empty list.
                # The audit log records the failure for reconciliation.
                log.error(f"Path C lazy-provision failed for {owner_id}: {e}")

        return JSONResponse({
            "status": "ok",
            "count": len(mempacks),
            "auto_provisioned": provisioned,
            "mempacks": mempacks,
        }, headers=_cors_headers())
    except Exception as e:
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


# ---------------------------------------------------------------------------
# Session-less Mempack mutation helpers
#
# Counterparts to _mempack_persist_new_pattern / _mempack_persist_pattern_update
# that DON'T require a mounted-session state dict. Used by REST endpoints
# called from the /app dashboard (Pattern I edits, Housecleaning copy-in)
# where there is no agent session to attach to.
#
# Both: load blob from cache (download if cold) → mutate → repack → upload
# → row update → metadata bump → cache refresh → activity log.
# ---------------------------------------------------------------------------

def _mempack_load_for_mutation(mempack_id: str) -> tuple[dict, str, np.ndarray, list[str]]:
    """Resolve a Mempack by id and load its current blob into memory.

    Returns: (mempack_row, cache_path, embeddings, texts)
    Raises:  ValueError if Mempack missing or status != 'ready'.
    """
    import supabase_storage as sbs
    mp = sbs.get_mempack_by_id(mempack_id)
    if not mp:
        raise ValueError(f"Mempack {mempack_id} not found")
    if mp.get("storage_status") != "ready":
        raise ValueError(
            f"Mempack {mempack_id} not ready (status={mp.get('storage_status')})"
        )
    cache_path = os.path.join(MEMPACK_CACHE_DIR, f"{mempack_id}.cart.npz")
    pseudo = {
        "owner_id":   mp["user_id"],
        "name":       mp["name"],
        "mempack_id": mempack_id,
        "path":       cache_path,
    }
    _ensure_mempack_cached(pseudo)
    data = load_cartridge_safe(cache_path)
    return mp, cache_path, data["embeddings"], list(data["texts"])


def _mempack_repack_and_persist(
    mp: dict,
    cache_path: str,
    embeddings: np.ndarray,
    texts: list[str],
    mutated_idx: int,
    new_text: str,
    is_replace: bool,
    activity_event_type: str,
    activity_summary: str,
    activity_metadata: dict | None = None,
    agent_label: str | None = None,
) -> int:
    """Repack blob → upload → mempack_patterns row write → metadata bump →
    cache refresh → activity log. Shared tail of the append + replace paths.

    Args:
        mp:               row dict from _mempack_load_for_mutation
        cache_path:       local cache path to refresh
        embeddings, texts: post-mutation full arrays
        mutated_idx:      pattern_idx that was appended-or-replaced
        new_text:         the new text body at mutated_idx
        is_replace:       True for replace (UPDATE row), False for append (INSERT row)
        activity_*:       fields for the activity-log row
        agent_label:      optional agent identifier

    Returns: blob_bytes length.
    """
    import io
    import struct as _struct
    import supabase_storage as sbs
    from cartridge_builder import (
        HIPPO_FORMAT, FORMAT_VERSION_CANONICAL, PERM_DEFAULT,
    )

    mempack_id = mp["id"]
    owner_id   = mp["user_id"]
    name       = mp["name"]

    # 1. Re-pack blob (preserve pattern_0_owner — v2 ownership flag)
    compressed_texts = [
        np.void(zlib.compress(t.encode("utf-8"), level=9)) for t in texts
    ]
    buf = io.BytesIO()
    np.savez_compressed(
        buf,
        embeddings=embeddings,
        passages=np.array(texts, dtype=object),
        compressed_texts=np.array(compressed_texts, dtype=object),
        version="mcp-v3",
        pattern_0_owner=_owner_id_to_uuid_bytes(owner_id),
    )
    blob_bytes = buf.getvalue()

    # 2. Upload (upsert)
    sbs.upload_blob(owner_id, name, blob_bytes)

    # 3. Synthesize H-block
    now_ts = int(time.time())
    h_block = _struct.pack(
        HIPPO_FORMAT,
        mutated_idx + 1,                 # pattern_id (1-based)
        FORMAT_VERSION_CANONICAL,
        1,                               # cartridge_type = agent-memory
        0, 0, 0,                         # parent/child/sibling
        0,                               # source_hash
        mutated_idx,                     # sequence_num
        now_ts,
        0,                               # flags
        PERM_DEFAULT,                    # perms_byte
        b'\x00' * 34,                    # reserved
    )

    # 4. INSERT or UPDATE pattern row
    if is_replace:
        updated = sbs.update_pattern_row(
            mempack_id=mempack_id,
            pattern_idx=mutated_idx,
            h_block_bytes=h_block,
            text=new_text,
        )
        if not updated:
            sbs.append_pattern_row(
                mempack_id=mempack_id,
                pattern_idx=mutated_idx,
                h_block_bytes=h_block,
                text=new_text,
            )
    else:
        sbs.append_pattern_row(
            mempack_id=mempack_id,
            pattern_idx=mutated_idx,
            h_block_bytes=h_block,
            text=new_text,
        )

    # 5. Metadata bump (size_bytes always; pattern_count on append; pattern_i_text on idx 1)
    metadata_kwargs: dict = {
        "mempack_id": mempack_id,
        "size_bytes": len(blob_bytes),
    }
    if not is_replace:
        metadata_kwargs["pattern_count"] = len(texts)
    if mutated_idx == PATTERN_I_IDX:
        metadata_kwargs["pattern_i_text"] = new_text
    sbs.update_mempack_metadata(**metadata_kwargs)

    # 6. Cache refresh
    try:
        with open(cache_path, "wb") as f:
            f.write(blob_bytes)
    except OSError as e:
        log.warning(f"Mempack cache write failed (non-fatal): {e}")

    # 7. Activity log
    try:
        if sbs.is_logging_enabled(mempack_id):
            sbs.append_activity(
                mempack_id=mempack_id,
                event_type=activity_event_type,
                summary=activity_summary,
                pattern_idx=mutated_idx,
                metadata=activity_metadata,
                agent_label=agent_label,
            )
    except Exception as e:
        log.warning(f"Mempack activity-log append failed (non-fatal): {e}")

    return len(blob_bytes)


def _mempack_append_blobless(
    mempack_id: str,
    new_text: str,
    new_embedding: "np.ndarray | None" = None,
    activity_event_type: str = "imprint",
    activity_summary: str | None = None,
    activity_metadata: dict | None = None,
    agent_label: str | None = None,
) -> dict:
    """Append a single pattern to a Mempack without a mounted session."""
    mp, cache_path, embeddings, texts = _mempack_load_for_mutation(mempack_id)
    if new_embedding is None:
        new_embedding = embed_text(new_text, prefix="search_document").astype(np.float32)
    texts.append(new_text)
    new_embeddings = np.vstack([embeddings, new_embedding[None, :]])
    new_idx = len(texts) - 1
    summary = activity_summary or f"appended pattern at idx {new_idx} ({len(new_text)} chars)"
    blob_size = _mempack_repack_and_persist(
        mp, cache_path, new_embeddings, texts,
        mutated_idx=new_idx,
        new_text=new_text,
        is_replace=False,
        activity_event_type=activity_event_type,
        activity_summary=summary,
        activity_metadata=activity_metadata or {
            "blob_bytes":  None,  # filled below
            "text_length": len(new_text),
            "preview":     (new_text or "")[:120],
        },
        agent_label=agent_label,
    )
    return {
        "mempack_id":    mempack_id,
        "new_idx":       new_idx,
        "blob_bytes":    blob_size,
        "pattern_count": len(texts),
        "preview":       (new_text or "")[:200],
    }


def _mempack_replace_blobless(
    mempack_id: str,
    idx: int,
    new_text: str,
    new_embedding: "np.ndarray | None" = None,
    activity_event_type: str | None = None,
    activity_summary: str | None = None,
    activity_metadata: dict | None = None,
    agent_label: str | None = None,
) -> dict:
    """Replace pattern at idx in a Mempack without a mounted session.

    For Pattern I rewrites from /app dashboard. Pads if idx >= len(texts).

    Captures previous_text from the existing slot BEFORE mutation and stuffs
    it into the activity_metadata so the activity feed can offer a recovery
    surface (Fix A — no schema change, no tombstone semantics, just a usable
    audit trail).
    """
    mp, cache_path, embeddings, texts = _mempack_load_for_mutation(mempack_id)
    if idx >= len(texts):
        raise ValueError(
            f"Mempack has {len(texts)} patterns; idx={idx} out of range"
        )
    # Capture BEFORE mutation. Cap at 8 KB to keep the activity row JSONB
    # bounded; the full length is recorded separately so the UI can warn
    # "previous body truncated — full version was N chars."
    _PREV_CAP = 8000
    previous_text = texts[idx] if isinstance(texts[idx], str) else ""
    previous_text_truncated = len(previous_text) > _PREV_CAP
    previous_text_capped = previous_text[:_PREV_CAP] if previous_text_truncated else previous_text
    if new_embedding is None:
        new_embedding = embed_text(new_text, prefix="search_document").astype(np.float32)
    texts[idx] = new_text
    new_embeddings = np.array(embeddings, copy=True)
    new_embeddings[idx] = new_embedding
    if activity_event_type is None:
        activity_event_type = "pattern_i_update" if idx == PATTERN_I_IDX else "pattern_update"
    if activity_summary is None:
        verb = "rewrote Pattern I" if idx == PATTERN_I_IDX else f"updated pattern at idx {idx}"
        activity_summary = f"{verb} ({len(new_text)} chars)"
    # Default metadata includes previous_text for recoverability. If a caller
    # supplied custom metadata, respect it but ensure the prev-text fields
    # are present so the UI's expander always has something to show.
    default_metadata = {
        "text_length":              len(new_text),
        "preview":                  (new_text or "")[:120],
        "previous_text":            previous_text_capped,
        "previous_text_length":     len(previous_text),
        "previous_text_truncated":  previous_text_truncated,
    }
    if activity_metadata is not None:
        # Layer caller-provided keys on top, then ensure prev-text fields exist
        merged_metadata = {**default_metadata, **activity_metadata}
        for k, v in default_metadata.items():
            merged_metadata.setdefault(k, v)
        activity_metadata = merged_metadata
    else:
        activity_metadata = default_metadata
    blob_size = _mempack_repack_and_persist(
        mp, cache_path, new_embeddings, texts,
        mutated_idx=idx,
        new_text=new_text,
        is_replace=True,
        activity_event_type=activity_event_type,
        activity_summary=activity_summary,
        activity_metadata=activity_metadata,
        agent_label=agent_label,
    )
    return {
        "mempack_id":    mempack_id,
        "idx":           idx,
        "blob_bytes":    blob_size,
        "pattern_count": len(texts),
        "preview":       (new_text or "")[:200],
    }


@mcp.custom_route("/api/copy", methods=["POST", "OPTIONS"])
async def rest_copy(request: Request) -> JSONResponse:
    """Copy a passage from any mounted cart into a destination Mempack.

    Body (Supabase Mempack dst — preferred, post-2026-05-13):
        src_cart        (str, required)  source cart name (must be findable)
        src_idx         (int, required)  passage index in src_cart
        dst_mempack_id  (str, required)  destination Mempack UUID
        note            (str, optional)  free-text reason ("why I kept this")

    Body (legacy filesystem-Mempack dst — pre-cold-cut, kept for compat):
        src_cart, src_idx, dst_path, note

    Embeds the copied passage in the destination with provenance metadata
    appended to the text body so future searches preserve attribution.
    Logs a 'copy_in' activity event on the destination Mempack.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    # NOTE: VPS_READ_ONLY is enforced inside the legacy dst_path branch only.
    # Mempack-dst copies are auth+ownership-gated and intentionally allowed
    # through the read-only public service.
    # Step 3: require auth at the top — src resolution can transiently load
    # other users' Mempack contents into RAM if we wait, even though dst auth
    # later refuses. Fail-fast on anonymous callers.
    user_id, err = _require_authenticated(request)
    if err is not None:
        return err
    try:
        data = await request.json()
        src_cart_name = data.get("src_cart", "")
        src_idx = int(data.get("src_idx", -1))
        dst_mempack_id = data.get("dst_mempack_id", "")
        dst_path = data.get("dst_path", "")  # legacy
        note = data.get("note", "")

        if not src_cart_name or src_idx < 0:
            return JSONResponse(
                {"status": "error", "error": "src_cart + src_idx required"},
                status_code=400, headers=_cors_headers(),
            )
        if not dst_mempack_id and not dst_path:
            return JSONResponse(
                {"status": "error", "error": "dst_mempack_id or dst_path required"},
                status_code=400, headers=_cors_headers(),
            )

        # Resolve src — scope find_mempacks to caller's own Mempacks so a
        # signed-in user can't load another user's Mempack content even
        # transiently. Knowledge carts (find_cartridges) remain unscoped.
        carts = find_cartridges() + find_mempacks(owner_id=user_id)
        src_match = [c for c in carts if c["name"] == src_cart_name]
        if not src_match:
            return JSONResponse(
                {"status": "error", "error": f"src_cart '{src_cart_name}' not found"},
                status_code=404, headers=_cors_headers(),
            )
        src_cart = src_match[0]
        # If src is a Mempack, ensure its blob is cached locally first
        if src_cart.get("mempack_id"):
            try:
                _ensure_mempack_cached(src_cart)
            except Exception as e:
                return JSONResponse(
                    {"status": "error", "error": f"failed to fetch src Mempack: {e}"},
                    status_code=500, headers=_cors_headers(),
                )
        src_data = load_cartridge_safe(src_cart["path"])
        src_texts = src_data["texts"]
        if src_idx >= len(src_texts):
            return JSONResponse(
                {"status": "error", "error":
                    f"src_idx {src_idx} out of range (cart has {len(src_texts)} patterns)"},
                status_code=400, headers=_cors_headers(),
            )
        src_text = src_texts[src_idx]

        timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ")
        provenance_footer = (
            f"\n\n---\n[provenance] from {src_cart_name}#{src_idx} on {timestamp}"
            + (f" — {note}" if note else "")
        )
        new_text = src_text + provenance_footer

        # ----------------------- Supabase Mempack dst -----------------------
        if dst_mempack_id:
            # Step 3: require auth + ownership of dst Mempack before any write.
            user_id, dst_mp, err = _require_mempack_owner(request, dst_mempack_id)
            if err is not None:
                return err
            try:
                result = _mempack_append_blobless(
                    mempack_id=dst_mempack_id,
                    new_text=new_text,
                    activity_event_type="copy_in",
                    activity_summary=f"copied from {src_cart_name}#{src_idx}",
                    activity_metadata={
                        "src_cart":    src_cart_name,
                        "src_idx":     src_idx,
                        "src_mempack": src_cart.get("mempack_id"),
                        "note":        note,
                        "preview":     (new_text or "")[:120],
                    },
                    agent_label="browser",
                )
            except ValueError as e:
                return JSONResponse({"status": "error", "error": str(e)},
                                    status_code=404, headers=_cors_headers())
            log.info(
                f"Copy: {src_cart_name}#{src_idx} -> mempack {dst_mempack_id[:8]} "
                f"(idx {result['new_idx']})"
            )
            return JSONResponse({
                "status":         "ok",
                "src_cart":       src_cart_name,
                "src_idx":        src_idx,
                "dst_mempack_id": dst_mempack_id,
                "dst_new_idx":    result["new_idx"],
                "blob_bytes":     result["blob_bytes"],
                "pattern_count":  result["pattern_count"],
                "preview":        result["preview"],
            }, headers=_cors_headers())

        # ----------------------- Legacy filesystem dst ----------------------
        # Restricted to MEMPACK_BASE_DIR (defense-in-depth against arbitrary
        # writes). Any new Mempack work should use dst_mempack_id.
        if _server_config.get("read_only"):
            return JSONResponse(
                {"status": "error", "error": "Server is read-only; legacy filesystem copies disabled."},
                status_code=403, headers=_cors_headers(),
            )
        dst_real = os.path.realpath(dst_path)
        base_real = os.path.realpath(MEMPACK_BASE_DIR)
        if not dst_real.startswith(base_real + os.sep):
            return JSONResponse(
                {"status": "error", "error":
                    "dst_path must be under MEMPACK_BASE_DIR (or pass dst_mempack_id)"},
                status_code=403, headers=_cors_headers(),
            )
        if not os.path.exists(dst_path):
            return JSONResponse(
                {"status": "error", "error": f"dst Mempack not found at {dst_path}"},
                status_code=404, headers=_cors_headers(),
            )

        dst_data = load_cartridge_safe(dst_path)
        dst_texts = list(dst_data["texts"])
        dst_embeddings = dst_data["embeddings"]
        new_embedding = embed_text(new_text, prefix="search_document").astype(np.float32)

        dst_texts.append(new_text)
        new_embeddings = np.vstack([dst_embeddings, new_embedding[None, :]])
        save_as_npz(dst_path, new_embeddings, dst_texts)

        dst_manifest = load_manifest(dst_path) or {}
        save_manifest(
            dst_path, new_embeddings, len(dst_texts),
            briefing=dst_manifest.get("briefing"),
            owner_id=dst_manifest.get("owner_id"),
            owner_perms=dst_manifest.get("owner_perms"),
            group_perms=dst_manifest.get("group_perms"),
            world_perms=dst_manifest.get("world_perms"),
            group_id=dst_manifest.get("group_id"),
            max_patterns=dst_manifest.get("max_patterns"),
            cart_type=dst_manifest.get("cart_type"),
        )

        log.info(f"Copy (legacy fs): {src_cart_name}#{src_idx} -> {dst_path}")
        return JSONResponse({
            "status":      "ok",
            "src_cart":    src_cart_name,
            "src_idx":     src_idx,
            "dst_path":    dst_path,
            "dst_new_idx": len(dst_texts) - 1,
            "preview":     new_text[:200],
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/copy error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mempack/{mempack_id}/activity", methods=["GET", "OPTIONS"])
async def rest_mempack_activity(request: Request) -> JSONResponse:
    """Read activity events for a Mempack, newest first.

    Path param:
        mempack_id  (str, UUID)

    Query params:
        since       (ISO timestamp, optional)  return only events strictly
                                               newer than this (delta polling)
        limit       (int, default 100, max 500)
        types       (CSV string, optional)     filter to specific event types
                                               (e.g. "imprint,copy_in")

    Returns: {status, count, server_time, activity: [...]} where server_time
    is the as-of timestamp the caller should pass back as `since` next poll.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        # Step 3: require auth + ownership before reading activity history.
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err
        since   = request.query_params.get("since")
        limit_s = request.query_params.get("limit", "100")
        types_s = request.query_params.get("types", "")
        try:
            limit = max(1, min(500, int(limit_s)))
        except ValueError:
            limit = 100
        event_types = [t.strip() for t in types_s.split(",") if t.strip()] or None

        import supabase_storage as sbs
        rows = sbs.list_activity(
            mempack_id=mempack_id,
            since_ts=since,
            limit=limit,
            event_types=event_types,
        )
        from datetime import datetime, timezone
        return JSONResponse({
            "status":      "ok",
            "mempack_id":  mempack_id,
            "count":       len(rows),
            "server_time": datetime.now(timezone.utc).isoformat(),
            "activity":    rows,
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id>/activity error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mempack/{mempack_id}/patterns", methods=["GET", "OPTIONS"])
async def rest_mempack_patterns(request: Request) -> JSONResponse:
    """List patterns in a Mempack so the dashboard can render a content browser.

    Path param:
        mempack_id      (str, UUID)

    Query params:
        tag             (str, optional)   filter to patterns whose prefix tag matches
                                          (case-insensitive); e.g. 'FINDING', 'SUMMARY'
        q               (str, optional)   case-insensitive substring filter on body text
        offset          (int, default 0)  pagination offset over the FILTERED, sorted list
        limit           (int, default 50, max 200)
        skip_reserved   (bool, default true) skip Pattern 0 (header) + Pattern I (idx=1)
                                          since those have their own dashboard surfaces

    Response:
        {
          "status":     "ok",
          "mempack_id": "...",
          "total":      <int — count AFTER filters, BEFORE pagination>,
          "offset":     <int>,
          "limit":      <int>,
          "patterns": [
            {"idx": <int>, "text": "...", "length": <int>, "tags": ["FINDING", ...], "preview": "..."},
            ...
          ]
        }

    Patterns are returned newest-first (idx descending), so the most recent
    findings appear at the top of the browser. Pagination is offset/limit
    over the filtered, sorted list.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err

        # Query param parsing with defensive caps.
        tag = (request.query_params.get("tag") or "").strip().upper()
        q = (request.query_params.get("q") or "").strip()
        try:
            offset = max(0, int(request.query_params.get("offset", "0")))
        except (TypeError, ValueError):
            offset = 0
        try:
            limit = int(request.query_params.get("limit", "50"))
        except (TypeError, ValueError):
            limit = 50
        limit = max(1, min(limit, 200))
        skip_reserved_param = (request.query_params.get("skip_reserved", "true") or "").lower()
        skip_reserved = skip_reserved_param not in ("false", "0", "no", "off")

        # Load the cart via the existing read+cache helper.
        # _mempack_load_for_mutation also serves read-only callers — it doesn't
        # mutate anything on its own; the name reflects its typical use.
        _mp, _cache, _embeddings, texts = _mempack_load_for_mutation(mempack_id)

        # Fetch per-pattern created_at timestamps from mempack_patterns so the
        # Patterns Browser can show "when did this land" inline. The texts
        # array from the blob is 0-based and matches the pattern_idx column.
        idx_to_created: dict[int, str] = {}
        try:
            import supabase_storage as sbs
            sb = sbs.get_client()
            ts_res = (
                sb.table("mempack_patterns")
                  .select("pattern_idx,created_at")
                  .eq("mempack_id", mempack_id)
                  .execute()
            )
            for row in (ts_res.data or []):
                pi = row.get("pattern_idx")
                ca = row.get("created_at")
                if pi is not None and ca:
                    idx_to_created[int(pi)] = ca
        except Exception as e:
            # Timestamp lookup is enrichment; never block the patterns list
            # on it. Worst case the frontend shows no timestamp.
            log.warning(f"patterns endpoint: created_at lookup failed (non-fatal): {e}")

        # Build the filtered + sorted list.
        results: list[dict] = []
        for idx, text in enumerate(texts):
            if skip_reserved and idx <= PATTERN_I_IDX:
                continue
            if not isinstance(text, str):
                continue
            tag_list = _extract_tags_from_text(text)
            tag_upper = [t.upper() for t in tag_list]
            if tag and tag not in tag_upper:
                continue
            if q and q.lower() not in text.lower():
                continue
            results.append({
                "idx":        idx,
                "text":       text,
                "length":     len(text),
                "tags":       tag_upper,
                "preview":    text[:200] + ("…" if len(text) > 200 else ""),
                "created_at": idx_to_created.get(idx),  # ISO timestamp or null
            })

        # Newest first (highest idx at top — matches the activity-feed convention)
        results.sort(key=lambda r: r["idx"], reverse=True)
        total = len(results)
        page = results[offset:offset + limit]

        return JSONResponse({
            "status":     "ok",
            "mempack_id": mempack_id,
            "total":      total,
            "offset":     offset,
            "limit":      limit,
            "patterns":   page,
        }, headers=_cors_headers())
    except ValueError as e:
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=404, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id>/patterns error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


def _render_patterns_as_markdown(patterns: list[dict], mempack_name: str,
                                  tag_filter: str = "", q_filter: str = "") -> str:
    """Build a single markdown document from a filtered pattern list.

    Each pattern becomes an `## idx:N · TAGS` section followed by the body.
    The `[TAG]` prefix is stripped from the body since we already render the
    tags in the section header — avoids "FINDING — FINDING — ..." duplication.
    """
    from datetime import datetime, timezone as _tz
    parts: list[str] = []
    title = f"# Mempack: {mempack_name}"
    if tag_filter:
        title += f" — Filter: `{tag_filter}`"
    if q_filter:
        title += f" — Search: `\"{q_filter}\"`"
    parts.append(title)
    parts.append("")
    parts.append(
        f"_Exported {datetime.now(_tz.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}_  "
        f"&nbsp;·&nbsp; **{len(patterns)} pattern(s)**"
    )
    parts.append("")
    parts.append("---")
    parts.append("")
    for p in patterns:
        tags = p.get("tags") or []
        tags_str = ", ".join(tags) if tags else "(no tags)"
        parts.append(f"## idx:{p['idx']} · `{tags_str}`")
        parts.append("")
        body = p.get("text") or ""
        # Strip leading [TAG] prefix to avoid double-rendering the tags.
        if body.startswith("[") and "]" in body[:200]:
            body = body[body.find("]") + 1:].lstrip()
        parts.append(body)
        parts.append("")
        parts.append("---")
        parts.append("")
    return "\n".join(parts)


def _render_single_pattern_as_markdown(pattern: dict, mempack_name: str) -> str:
    """Build a single-pattern markdown doc. Lighter than the bulk render:
    one H1 header (idx + tags), no top-of-doc summary, no separators.
    """
    tags = pattern.get("tags") or []
    tags_str = ", ".join(tags) if tags else "(no tags)"
    body = pattern.get("text") or ""
    if body.startswith("[") and "]" in body[:200]:
        body = body[body.find("]") + 1:].lstrip()
    return f"# {mempack_name} · idx:{pattern['idx']} · `{tags_str}`\n\n{body}\n"


def _safe_filename_component(name: str) -> str:
    """Normalize a string into a safe filename slug — alphanumerics, dash, underscore, dot."""
    import re
    safe = re.sub(r"[^A-Za-z0-9_\-.]", "_", name or "")
    safe = safe.strip("_") or "mempack"
    return safe[:64]


@mcp.custom_route("/api/mempack/{mempack_id}/patterns/export", methods=["GET", "OPTIONS"])
async def rest_mempack_patterns_export(request: Request):
    """Download the filtered Patterns Browser state as a single document.

    Same filtering/sorting semantics as GET /api/mempack/{id}/patterns
    (tag + q + skip_reserved). Renders the filtered list to a single
    markdown document, then optionally pipes that markdown through Pandoc
    to produce docx or PDF.

    Query params:
        format          'md' (default) | 'txt' | 'docx' | 'pdf'
        tag             tag filter (uppercase, exact); same as list endpoint
        q               substring filter on body text
        skip_reserved   default true (skip Pattern 0 + Pattern I)

    Auth: same as Patterns Browser — owner-only.

    Pandoc is required for docx + pdf. The droplet has pandoc 2.2.1 +
    wkhtmltopdf 0.12.5 installed (apt). PDF is rendered via wkhtmltopdf
    (HTML-rendering engine) — smaller dependency than LaTeX and good
    enough output for our research-doc use case.
    """
    from starlette.responses import Response
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err

        format_param = (request.query_params.get("format") or "md").lower()
        if format_param not in ("md", "txt", "docx", "pdf"):
            return JSONResponse(
                {"status": "error",
                 "error": f"format must be one of md|txt|docx|pdf; got: {format_param!r}"},
                status_code=400, headers=_cors_headers(),
            )

        tag = (request.query_params.get("tag") or "").strip().upper()
        q = (request.query_params.get("q") or "").strip()
        skip_reserved_param = (request.query_params.get("skip_reserved", "true") or "").lower()
        skip_reserved = skip_reserved_param not in ("false", "0", "no", "off")

        # Single-pattern short-circuit: when ?idx=N is supplied, ignore filters
        # and just emit that one pattern. Used by the per-row download buttons
        # in the Patterns Browser — different unit of work than the filtered-set
        # bulk download.
        single_idx_param = (request.query_params.get("idx") or "").strip()
        single_idx: int | None = None
        if single_idx_param != "":
            try:
                single_idx = int(single_idx_param)
            except ValueError:
                return JSONResponse(
                    {"status": "error", "error": f"idx must be an integer; got: {single_idx_param!r}"},
                    status_code=400, headers=_cors_headers(),
                )
            if single_idx < 0:
                return JSONResponse(
                    {"status": "error", "error": "idx must be >= 0"},
                    status_code=400, headers=_cors_headers(),
                )

        # Load cart once whether we're doing single or bulk.
        _mp, _cache, _embeddings, texts = _mempack_load_for_mutation(mempack_id)
        mempack_name = mp.get("name") or "primary"

        if single_idx is not None:
            # ── Single-pattern path ──
            if single_idx >= len(texts):
                return JSONResponse(
                    {"status": "error",
                     "error": f"idx={single_idx} out of range (mempack has {len(texts)} patterns)"},
                    status_code=404, headers=_cors_headers(),
                )
            text = texts[single_idx]
            if not isinstance(text, str):
                return JSONResponse(
                    {"status": "error", "error": f"pattern at idx={single_idx} has no text body"},
                    status_code=404, headers=_cors_headers(),
                )
            tag_list = _extract_tags_from_text(text)
            tag_upper = [t.upper() for t in tag_list]
            single_pattern = {"idx": single_idx, "text": text, "tags": tag_upper}
            md_content = _render_single_pattern_as_markdown(single_pattern, mempack_name)
        else:
            # ── Filtered-set (bulk) path: same logic as the list endpoint ──
            patterns: list[dict] = []
            for idx, text in enumerate(texts):
                if skip_reserved and idx <= PATTERN_I_IDX:
                    continue
                if not isinstance(text, str):
                    continue
                tag_list = _extract_tags_from_text(text)
                tag_upper = [t.upper() for t in tag_list]
                if tag and tag not in tag_upper:
                    continue
                if q and q.lower() not in text.lower():
                    continue
                patterns.append({"idx": idx, "text": text, "tags": tag_upper})
            patterns.sort(key=lambda r: r["idx"], reverse=True)
            md_content = _render_patterns_as_markdown(
                patterns, mempack_name, tag_filter=tag, q_filter=q,
            )

        # Filename: per-pattern uses idx + first tag; bulk uses filter tag (if any).
        from datetime import datetime, timezone as _tz
        date_str = datetime.now(_tz.utc).strftime("%Y-%m-%d")
        if single_idx is not None:
            tag_part = ""
            if tag_upper:
                tag_part = f"_{_safe_filename_component(tag_upper[0])}"
            base_filename = (
                f"{_safe_filename_component(mempack_name)}_idx{single_idx}"
                f"{tag_part}_{date_str}"
            )
        else:
            tag_part = f"_{_safe_filename_component(tag)}" if tag else ""
            base_filename = f"{_safe_filename_component(mempack_name)}{tag_part}_{date_str}"

        # Plain-text formats: serve the markdown directly.
        if format_param == "md":
            return Response(
                content=md_content.encode("utf-8"),
                media_type="text/markdown; charset=utf-8",
                headers={
                    "Content-Disposition": f'attachment; filename="{base_filename}.md"',
                    **_cors_headers(),
                },
            )
        if format_param == "txt":
            return Response(
                content=md_content.encode("utf-8"),
                media_type="text/plain; charset=utf-8",
                headers={
                    "Content-Disposition": f'attachment; filename="{base_filename}.txt"',
                    **_cors_headers(),
                },
            )

        # Pandoc-piped formats. docx writes to stdout cleanly; PDF needs a
        # temp file because pandoc 2.x's PDF backend doesn't reliably emit
        # binary to stdout when paired with wkhtmltopdf.
        import subprocess
        import tempfile
        if format_param == "docx":
            cmd = ["pandoc", "-f", "markdown", "-t", "docx", "-o", "-"]
            try:
                result = subprocess.run(
                    cmd, input=md_content.encode("utf-8"),
                    capture_output=True, timeout=30,
                )
            except subprocess.TimeoutExpired:
                return JSONResponse(
                    {"status": "error", "error": "pandoc docx timeout (30s)"},
                    status_code=504, headers=_cors_headers(),
                )
            if result.returncode != 0 or not result.stdout:
                log.error(
                    f"pandoc docx export failed (rc={result.returncode}): "
                    f"{result.stderr.decode('utf-8', errors='replace')[:500]}"
                )
                return JSONResponse(
                    {"status": "error", "error": "pandoc docx export failed"},
                    status_code=500, headers=_cors_headers(),
                )
            return Response(
                content=result.stdout,
                media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
                headers={
                    "Content-Disposition": f'attachment; filename="{base_filename}.docx"',
                    **_cors_headers(),
                },
            )

        # format_param == "pdf"
        with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
            tmp_pdf_path = tmp.name
        try:
            cmd = [
                "pandoc", "-f", "markdown",
                "--pdf-engine=wkhtmltopdf",
                "-V", "margin-top=18mm", "-V", "margin-bottom=18mm",
                "-V", "margin-left=18mm", "-V", "margin-right=18mm",
                "-o", tmp_pdf_path,
            ]
            try:
                result = subprocess.run(
                    cmd, input=md_content.encode("utf-8"),
                    capture_output=True, timeout=60,
                )
            except subprocess.TimeoutExpired:
                return JSONResponse(
                    {"status": "error", "error": "pandoc PDF timeout (60s)"},
                    status_code=504, headers=_cors_headers(),
                )
            if result.returncode != 0:
                log.error(
                    f"pandoc pdf export failed (rc={result.returncode}): "
                    f"{result.stderr.decode('utf-8', errors='replace')[:500]}"
                )
                return JSONResponse(
                    {"status": "error", "error": "pandoc PDF export failed"},
                    status_code=500, headers=_cors_headers(),
                )
            with open(tmp_pdf_path, "rb") as fh:
                pdf_bytes = fh.read()
            return Response(
                content=pdf_bytes,
                media_type="application/pdf",
                headers={
                    "Content-Disposition": f'attachment; filename="{base_filename}.pdf"',
                    **_cors_headers(),
                },
            )
        finally:
            try:
                os.unlink(tmp_pdf_path)
            except OSError:
                pass
    except ValueError as e:
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=404, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id>/patterns/export error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mempack/{mempack_id}/pattern-i", methods=["POST", "OPTIONS"])
async def rest_mempack_pattern_i_update(request: Request) -> JSONResponse:
    """Rewrite a Mempack's Pattern I (idx=1) from the dashboard.

    Path param:
        mempack_id  (str, UUID)

    Body:
        text        (str, required)  the new Pattern I body

    REST counterpart to the mempack_update_pattern_i MCP tool. The MCP tool
    requires an agent session with the Mempack mounted; this endpoint is for
    the /app dashboard which doesn't have one.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    # NOTE: VPS_READ_ONLY guards public-demo writes to bundled knowledge carts.
    # Mempack writes are per-user, auth-gated, ownership-checked — they're
    # categorically different and should always be allowed when the caller
    # owns the Mempack. We deliberately do NOT check read_only here.
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        # Step 3: require auth + ownership before any write.
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err
        body = await request.json()
        text = body.get("text", "")
        if not isinstance(text, str) or not text.strip():
            return JSONResponse(
                {"status": "error", "error": "text required (non-empty string)"},
                status_code=400, headers=_cors_headers(),
            )
        if len(text) > MAX_TEXT_LENGTH:
            return JSONResponse(
                {"status": "error", "error":
                    f"text too long ({len(text)} chars; max {MAX_TEXT_LENGTH})"},
                status_code=400, headers=_cors_headers(),
            )

        try:
            result = _mempack_replace_blobless(
                mempack_id=mempack_id,
                idx=PATTERN_I_IDX,
                new_text=text,
                agent_label="browser",
            )
        except ValueError as e:
            return JSONResponse({"status": "error", "error": str(e)},
                                status_code=404, headers=_cors_headers())

        log.info(f"Pattern I updated via REST: mempack_id={mempack_id[:8]} ({len(text)} chars)")
        return JSONResponse({
            "status":     "ok",
            "mempack_id": mempack_id,
            "idx":        PATTERN_I_IDX,
            "blob_bytes": result["blob_bytes"],
            "preview":    result["preview"],
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id>/pattern-i error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mempack/templates", methods=["GET", "OPTIONS"])
async def rest_mempack_templates(request: Request) -> JSONResponse:
    """List available Pattern I templates for the dashboard picker.

    Response:
        {
          "status":    "ok",
          "templates": [
            {"id": "default",    "label": "Default",      "description": "...", "body": "..."},
            {"id": "researcher", "label": "Researcher",   "description": "...", "body": "..."},
            {"id": "_coming_soon", "label": "More coming soon…", "disabled": true, "body": null}
          ]
        }

    Bodies are returned with template placeholders intact ({owner_id}, etc.) —
    the dashboard substitutes them client-side before populating Pattern I.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    items = []
    for tid, meta in _PATTERN_I_TEMPLATES.items():
        items.append({
            "id":          tid,
            "label":       meta.get("label", tid),
            "description": meta.get("description", ""),
            "body":        meta.get("body"),
            "disabled":    bool(meta.get("disabled", False)),
        })
    return JSONResponse({"status": "ok", "templates": items}, headers=_cors_headers())


@mcp.custom_route("/api/mempack/{mempack_id}/dispatch", methods=["POST", "OPTIONS"])
async def rest_mempack_dispatch(request: Request) -> JSONResponse:
    """Queue a user-authored task into the Mempack as a DISPATCH-tagged pattern.

    The agent reads DISPATCH-tagged patterns on mount (see the default + Researcher
    Pattern I templates) and acts on them. From the substrate's perspective this
    is just another imprint — the DISPATCH tag is what makes it semantically a
    "task" rather than a "stored finding."

    Path param:
        mempack_id  (str, UUID)

    Body:
        text        (str, required)  the dispatch body (what the user typed)

    Response:
        {"status":"ok", "mempack_id":..., "new_idx":..., "pattern_count":..., "blob_bytes":..., "preview":...}
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    # Mirror the Pattern I write: Mempack writes are auth+ownership-gated and
    # categorically bypass VPS_READ_ONLY (which governs knowledge-cart writes).
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err
        body = await request.json()
        text = body.get("text", "")
        if not isinstance(text, str) or not text.strip():
            return JSONResponse(
                {"status": "error", "error": "text required (non-empty string)"},
                status_code=400, headers=_cors_headers(),
            )
        if len(text) > MAX_TEXT_LENGTH:
            return JSONResponse(
                {"status": "error", "error":
                    f"text too long ({len(text)} chars; max {MAX_TEXT_LENGTH})"},
                status_code=400, headers=_cors_headers(),
            )

        # Match the memory_store tag convention: prefix the stored body with
        # "[TAG] " so future memory_search("DISPATCH") surfaces it. The agent
        # discovers the tag by reading the stored text, NOT by querying a
        # separate metadata column.
        #
        # Server-stamp the imprint time at the end of the body so agents
        # don't have to guess "is today's date 2024 or 2026?" — local models
        # in particular are trained to hedge on temporal grounding and need
        # an explicit anchor. Format: ISO 8601 UTC with seconds precision.
        from datetime import datetime as _dt, timezone as _tz
        dispatched_iso = _dt.now(_tz.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
        body_text = text.strip()
        stored_text = f"[DISPATCH] {body_text}\n\n[dispatched: {dispatched_iso}]"
        preview = body_text[:120]

        try:
            result = _mempack_append_blobless(
                mempack_id=mempack_id,
                new_text=stored_text,
                activity_event_type="dispatch",
                activity_summary=f"dispatched task ({len(text)} chars)",
                activity_metadata={
                    "tag":            "DISPATCH",
                    "text_length":    len(text),
                    "preview":        preview,
                    "dispatched_at":  dispatched_iso,
                },
                agent_label="browser",
            )
        except ValueError as e:
            return JSONResponse({"status": "error", "error": str(e)},
                                status_code=404, headers=_cors_headers())

        log.info(f"Dispatch queued via REST: mempack_id={mempack_id[:8]} ({len(text)} chars)")
        return JSONResponse({
            "status":        "ok",
            "mempack_id":    mempack_id,
            "new_idx":       result["new_idx"],
            "pattern_count": result["pattern_count"],
            "blob_bytes":    result["blob_bytes"],
            "preview":       preview,
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id>/dispatch error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


@mcp.custom_route("/api/mempack/{mempack_id}", methods=["PATCH", "OPTIONS"])
async def rest_mempack_settings(request: Request) -> JSONResponse:
    """Update Mempack settings from the dashboard.

    Path param:
        mempack_id  (str, UUID)

    Body (any subset):
        activity_logging_enabled  (bool)
        briefing                  (str)

    Returns the updated mempack row.
    """
    if request.method == "OPTIONS":
        return JSONResponse({}, headers=_cors_headers())
    # See rest_mempack_pattern_i_update — Mempack settings writes intentionally
    # bypass VPS_READ_ONLY (which is about knowledge-cart write protection).
    try:
        mempack_id = request.path_params.get("mempack_id", "")
        # Step 3: require auth + ownership before any write.
        user_id, mp, err = _require_mempack_owner(request, mempack_id)
        if err is not None:
            return err
        body = await request.json()

        import supabase_storage as sbs

        patch: dict = {}
        if "activity_logging_enabled" in body:
            patch["activity_logging_enabled"] = bool(body["activity_logging_enabled"])
        if "briefing" in body:
            briefing = body["briefing"]
            if briefing is not None and not isinstance(briefing, str):
                return JSONResponse(
                    {"status": "error", "error": "briefing must be string or null"},
                    status_code=400, headers=_cors_headers(),
                )
            patch["briefing"] = briefing
        if not patch:
            return JSONResponse(
                {"status": "error", "error": "no recognized fields in body"},
                status_code=400, headers=_cors_headers(),
            )
        from datetime import datetime, timezone
        patch["updated_at"] = datetime.now(timezone.utc).isoformat()

        sb = sbs.get_client()
        res = sb.table("mempacks").update(patch).eq("id", mempack_id).execute()
        updated = res.data[0] if res.data else mp

        # Activity log entry for settings change (visible audit trail)
        try:
            change_summary = ", ".join(f"{k}={v}" for k, v in patch.items() if k != "updated_at")
            sbs.append_activity(
                mempack_id=mempack_id,
                event_type="settings_update",
                summary=f"settings updated ({change_summary})",
                metadata={"changes": {k: v for k, v in patch.items() if k != "updated_at"}},
                agent_label="browser",
            )
        except Exception as e:
            log.warning(f"Settings activity-log append failed (non-fatal): {e}")

        log.info(f"Mempack settings updated: mempack_id={mempack_id[:8]} fields={list(patch.keys())}")
        return JSONResponse({
            "status":  "ok",
            "mempack": updated,
        }, headers=_cors_headers())
    except Exception as e:
        log.error(f"REST /api/mempack/<id> PATCH error: {e}")
        return JSONResponse({"status": "error", "error": str(e)},
                            status_code=500, headers=_cors_headers())


# ============================================================
# ENTRY POINT
# ============================================================

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Membot — Brain Cartridge Server")
    parser.add_argument("--transport", default="stdio", choices=["stdio", "http", "sse"],
                        help="Transport mode (default: stdio)")
    parser.add_argument("--host", default="0.0.0.0",
                        help="HTTP bind address (default: 0.0.0.0)")
    parser.add_argument("--port", type=int, default=8000,
                        help="HTTP port (default: 8000)")
    parser.add_argument("--read-only", action="store_true", default=True,
                        help="Disable memory_store and save_cartridge (default: on)")
    parser.add_argument("--writable", action="store_true",
                        help="Enable memory_store and save_cartridge (override read-only)")
    parser.add_argument("--mount", type=str, default=None,
                        help="Auto-mount a cartridge on startup (creates empty cart if not found)")
    args = parser.parse_args()

    _server_config["read_only"] = not args.writable

    log.info("Starting Membot — Brain Cartridge Server...")
    log.info(f"Transport: {args.transport}")
    if _server_config["read_only"]:
        log.info("READ-ONLY mode (default): memory_store and save_cartridge disabled. Use --writable to enable.")
    else:
        log.info("WRITABLE mode: memory_store and save_cartridge enabled")
    log.info(f"Cartridge dirs: {CARTRIDGE_DIRS}")
    log.info(f"Security: max_entries={MAX_ENTRIES}, max_text={MAX_TEXT_LENGTH}, pkl=trusted-dirs-only")

    # Try GPU at startup (non-fatal)
    init_gpu()

    # Count available cartridges
    carts = find_cartridges()
    log.info(f"Found {len(carts)} cartridges")

    # Auto-mount cartridge if requested
    if args.mount:
        cart_name = args.mount
        # Check if cartridge exists; if not, create empty one
        matching = [c for c in carts if cart_name.lower() in c["name"].lower()]
        if not matching:
            log.info(f"Cartridge '{cart_name}' not found — creating empty cartridge")
            save_dir = os.path.join(BASE_DIR, "cartridges")
            os.makedirs(save_dir, exist_ok=True)
            empty_path = os.path.join(save_dir, f"{cart_name}.cart.npz")
            empty_emb = np.zeros((0, 768), dtype=np.float32)
            save_as_npz(empty_path, empty_emb, [])
            save_manifest(empty_path, empty_emb, 0)
            log.info(f"Created empty cartridge: {empty_path}")

        # Mount into default session (bypass @mcp.tool decorator)
        sid = _resolve_session_id("")
        state = _get_session(sid)
        mount_carts = find_cartridges()
        mount_match = [c for c in mount_carts if cart_name.lower() in c["name"].lower()]
        if mount_match:
            cart = mount_match[0]
            data = load_cartridge_safe(cart["path"])
            state["embeddings"] = data["embeddings"]
            state["texts"] = data["texts"]
            state["cartridge_name"] = cart["name"]
            state["cartridge_path"] = cart["path"]
            state["signatures"] = None
            state["modified"] = False
            if len(data["embeddings"]) > 0:
                state["binary_corpus"] = (data["embeddings"] > 0).astype(np.uint8)
            else:
                state["binary_corpus"] = None
            ok, verify_msg = verify_manifest(cart["path"], data["embeddings"], len(data["texts"]))
            log.info(f"Auto-mounted '{cart['name']}': {len(data['texts'])} memories, integrity={verify_msg}")
        else:
            log.error(f"Auto-mount failed: '{cart_name}' still not found after creation")

    # Setup HTTP middleware if not in stdio mode
    if args.transport != "stdio":
        api_key = os.environ.get("MEMBOT_API_KEY")
        _setup_http_middleware(api_key)
        log.info(f"HTTP server starting on {args.host}:{args.port}")
        if _server_config["read_only"]:
            log.info("Public server mode: write operations blocked")

    if args.transport == "stdio":
        mcp.run(transport="stdio")
    else:
        mcp.run(transport=args.transport, host=args.host, port=args.port)
