"""
Novyx SDK — Agent memory infrastructure.

Store, recall, rollback, eval, and cryptographic audit for AI agents.
Sync client. For async, use AsyncNovyx: from novyx import AsyncNovyx
"""

import requests
from dataclasses import dataclass, field
from typing import Any, Dict, Iterator, List, Optional

from .exceptions import (
    NovyxError,
    NovyxAuthError,
    NovyxForbiddenError,
    NovyxRateLimitError,
    NovyxNotFoundError,
    NovyxSecurityError,
)


# ============================================================================
# Rich Return Types
# ============================================================================

@dataclass
class Memory:
    """Memory object with attribute access."""
    uuid: str
    observation: str
    context: Optional[str] = None
    agent_id: Optional[str] = None
    tags: List[str] = field(default_factory=list)
    importance: int = 5
    confidence: float = 1.0
    created_at: str = ""
    score: Optional[float] = None
    similarity: Optional[float] = None
    vector_clock: Optional[Dict[str, int]] = None
    conflict_metadata: Optional[Dict] = None
    expires_at: Optional[str] = None
    superseded_by: Optional[str] = None
    match_confidence: Optional[float] = None

    @property
    def id(self) -> str:
        """Alias for uuid."""
        return self.uuid

    @staticmethod
    def from_dict(data: Dict[str, Any]) -> "Memory":
        """Create Memory from API response dict."""
        return Memory(
            uuid=data.get("uuid", ""),
            observation=data.get("observation", ""),
            context=data.get("context"),
            agent_id=data.get("agent_id"),
            tags=data.get("tags", []),
            importance=data.get("importance", 5),
            confidence=data.get("confidence", 1.0),
            created_at=data.get("created_at", ""),
            score=data.get("score"),
            similarity=data.get("similarity"),
            vector_clock=data.get("vector_clock"),
            conflict_metadata=data.get("conflict_metadata"),
            expires_at=data.get("expires_at"),
            superseded_by=data.get("superseded_by"),
            match_confidence=data.get("match_confidence"),
        )


@dataclass
class SearchResult:
    """Result of searching memories."""
    query: str
    total_results: int
    memories: List[Memory]

    def __iter__(self) -> Iterator[Memory]:
        return iter(self.memories)

    def __len__(self) -> int:
        return len(self.memories)


@dataclass
class ListResult:
    """Result of listing memories."""
    total_count: int
    memories: List[Memory]
    filters: Dict[str, Any] = field(default_factory=dict)

    def __iter__(self) -> Iterator[Memory]:
        return iter(self.memories)

    def __len__(self) -> int:
        return len(self.memories)


class Novyx:
    """
    Novyx SDK client - Unified memory, rollback, and audit API.

    Core features:
    - **Memory**: remember(), recall(), memories(), memory(), forget()
    - **Rollback**: rollback(), rollback_preview(), rollback_history()
    - **Audit**: audit(), audit_export(), audit_verify()
    - **Eval**: eval_run(), eval_gate(), eval_history(), eval_drift()
    - **Traces**: trace_create(), trace_step(), trace_complete(), trace_verify()
    - **Usage**: usage(), plans()

    Example:
        >>> from novyx import Novyx
        >>> nx = Novyx(api_key="nram_your_key_here")
        >>>
        >>> # Store memories
        >>> nx.remember("User prefers dark mode", tags=["ui"])
        >>>
        >>> # Search semantically
        >>> memories = nx.recall("user preferences")
        >>>
        >>> # Check audit trail
        >>> audit = nx.audit(limit=10)
        >>>
        >>> # Rollback if needed
        >>> preview = nx.rollback_preview("2 hours ago")
        >>> nx.rollback("2 hours ago")
        >>>
        >>> # Trace agent actions (Pro+)
        >>> trace = nx.trace_create("my-agent")
        >>> nx.trace_step(trace["trace_id"], "thought", {"content": "Planning..."})
    """

    def __init__(
        self,
        api_key: str,
        api_url: str = "https://novyx-ram-api.fly.dev",
        timeout: int = 30,
        agent_id: Optional[str] = None,
        source: Optional[str] = None,
        control_url: Optional[str] = None,
        control_api_key: Optional[str] = None,
    ):
        """
        Initialize Novyx client.

        Args:
            api_key: Your Novyx API key (format: nram_tenant_signature)
            api_url: Base URL for the API (default: production)
            timeout: Request timeout in seconds
            agent_id: Default agent identifier for memory operations
            source: Source identifier sent via X-Novyx-Source header (e.g. "mcp", "vault")
            control_url: Base URL for Novyx Control (optional)
            control_api_key: API key for Novyx Control (optional, defaults to strata_ key)
        """
        self.api_key = api_key
        self.api_url = api_url.rstrip("/")
        self.timeout = timeout
        self.agent_id = agent_id
        self._source = source
        self._control_url = control_url.rstrip("/") if control_url else None
        self._control_api_key = control_api_key
        self._validate_key()

    def _headers(self) -> Dict[str, str]:
        """Get request headers with auth."""
        h = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
            "User-Agent": f"novyx-sdk/3.0.0",
        }
        if self._source:
            h["X-Novyx-Source"] = self._source
        return h

    def _validate_key(self) -> None:
        """Validate API key format."""
        if not self.api_key or not self.api_key.startswith("nram_"):
            raise NovyxAuthError("Invalid API key format. Expected: nram_<tenant>_<signature>")

    def _request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict] = None,
        json_data: Optional[Dict] = None,
        raw_response: bool = False,
    ) -> Any:
        """
        Make an API request with error handling.

        Args:
            method: HTTP method
            endpoint: API endpoint path
            params: Query parameters
            json_data: JSON request body
            raw_response: If True, return raw response object (for binary data)

        Returns:
            Parsed JSON response or raw response object

        Raises:
            NovyxAuthError: 401 - Invalid/expired API key
            NovyxForbiddenError: 403 - Feature not available on current plan
            NovyxNotFoundError: 404 - Resource not found
            NovyxRateLimitError: 429 - Rate/usage limit exceeded
            NovyxError: Other errors
        """
        url = f"{self.api_url}{endpoint}"

        try:
            response = requests.request(
                method=method,
                url=url,
                headers=self._headers(),
                params=params,
                json=json_data,
                timeout=self.timeout,
            )

            # Handle specific error codes
            if response.status_code == 401:
                raise NovyxAuthError("Invalid API key or unauthorized")

            if response.status_code == 403:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Forbidden")
                raise NovyxForbiddenError(error_msg, data)

            if response.status_code == 404:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Not found")
                raise NovyxNotFoundError(error_msg)

            if response.status_code == 429:
                data = response.json() if response.text else {}
                error_msg = data.get("error", "Rate limit exceeded")
                raise NovyxRateLimitError(error_msg, data)

            # Handle empty responses
            if response.status_code == 204 or not response.text:
                return {} if not raw_response else response

            # Return raw response for binary data
            if raw_response:
                response.raise_for_status()
                return response

            # Try to parse JSON response
            try:
                response_data = response.json()
            except ValueError:
                # Invalid JSON - for error status codes, raise with text
                if response.status_code >= 400:
                    raise NovyxError(f"HTTP {response.status_code}: {response.text[:200]}")
                # For success codes with invalid JSON, raise parse error
                raise NovyxError(f"Invalid JSON response: {response.text[:200]}")

            # Now check status and raise for errors
            try:
                response.raise_for_status()
            except requests.exceptions.HTTPError as e:
                # Convert HTTP errors to NovyxError
                raise NovyxError(f"HTTP {response.status_code}: {str(e)}")

            return response_data

        except requests.exceptions.Timeout:
            raise NovyxError(f"Request timed out after {self.timeout}s")
        except requests.exceptions.ConnectionError:
            raise NovyxError(f"Failed to connect to {self.api_url}")
        except requests.exceptions.RequestException as e:
            raise NovyxError(f"Request failed: {e}")

    # ========================================================================
    # Memory Methods
    # ========================================================================

    def remember(
        self,
        observation: str,
        *,
        tags: Optional[List[str]] = None,
        context: Optional[str] = None,
        importance: int = 5,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
        ttl_seconds: Optional[int] = None,
        auto_link: bool = False,
        conflict_strategy: Optional[str] = None,
        **kwargs,
    ) -> Dict[str, Any]:
        """
        Store a memory observation.

        Args:
            observation: The memory content to store
            tags: Optional list of tags for categorization
            context: Optional context string
            importance: Importance score 1-10 (default: 5)
            agent_id: Agent identifier (falls back to constructor agent_id)
            space_id: Context space to store the memory in
            metadata: Optional metadata dict (stored in context if provided)
            ttl_seconds: Optional time-to-live in seconds (60 to 7776000). Memory auto-expires after this duration.
            auto_link: Auto-link to similar recent memories (default: False)
            conflict_strategy: How to handle conflicts: 'reject' (default), 'lww' (last-write-wins), 'manual'

        Returns:
            Dict with uuid, message, conflict info, and auto_links (if auto_link=True)

        Raises:
            NovyxRateLimitError: If memory limit exceeded

        Example:
            >>> result = nx.remember("User prefers async communication", tags=["prefs"], importance=8)
            >>> print(result["uuid"])
            >>> # Store with auto-linking
            >>> result = nx.remember("User likes Python", auto_link=True)
        """
        resolved_agent_id = agent_id or self.agent_id

        payload = {
            "observation": observation,
            "tags": tags or [],
            "importance": importance,
        }

        if resolved_agent_id:
            payload["agent_id"] = resolved_agent_id
        if space_id:
            payload["space_id"] = space_id
        if ttl_seconds is not None:
            payload["ttl_seconds"] = ttl_seconds
        if auto_link:
            payload["auto_link"] = True

        if context:
            payload["context"] = context
        if metadata:
            payload["metadata"] = metadata

        params = {}
        if conflict_strategy:
            params["conflict_strategy"] = conflict_strategy

        return self._request(
            "POST",
            "/v1/memories",
            params=params or None,
            json_data=payload,
        )

    def recall(
        self,
        query: str,
        *,
        limit: int = 5,
        tags: Optional[List[str]] = None,
        min_score: float = 0.0,
        scope: str = "own",
        agents: Optional[List[str]] = None,
        include_shared: bool = False,
        space_id: Optional[str] = None,
        recency_weight: float = 0.0,
        include_superseded: bool = False,
        **kwargs,
    ) -> SearchResult:
        """
        Search memories semantically.

        Args:
            query: Natural language search query
            limit: Maximum results to return (default: 5, max: 100)
            tags: Optional tag filter
            min_score: Minimum similarity score 0-1 (default: 0)
            scope: Search scope (default: "own")
            agents: Filter by agent IDs
            include_shared: Include shared space memories
            space_id: Filter by context space
            recency_weight: Blend recency into scoring (0=disabled, 1=fully recency-based)
            include_superseded: Include memories that have been superseded

        Returns:
            SearchResult with Memory objects (iterable)

        Example:
            >>> result = nx.recall("communication preferences", limit=3)
            >>> for mem in result:
            ...     print(f"{mem.observation} (score: {mem.score:.2f})")
        """
        params = {"q": query, "limit": limit, "min_score": min_score}
        if tags:
            params["tags"] = ",".join(tags)
        if agents:
            params["agents"] = ",".join(agents)
        if space_id:
            params["space_id"] = space_id
        if recency_weight > 0:
            params["recency_weight"] = recency_weight
        if include_superseded:
            params["include_superseded"] = "true"

        result = self._request("GET", "/v1/memories/search", params=params)
        raw = result if isinstance(result, list) else result.get("memories", [])
        memories = [Memory.from_dict(m) for m in raw]
        return SearchResult(
            query=query,
            total_results=len(memories),
            memories=memories,
        )

    def draft_memory(
        self,
        observation: str,
        *,
        tags: Optional[List[str]] = None,
        context: Optional[str] = None,
        importance: int = 5,
        confidence: float = 1.0,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        ttl_seconds: Optional[int] = None,
        branch_id: Optional[str] = None,
        **kwargs,
    ) -> Dict[str, Any]:
        """Create a reviewable memory draft without writing to canonical memory."""
        resolved_agent_id = agent_id or self.agent_id
        payload: Dict[str, Any] = {
            "observation": observation,
            "tags": tags or [],
            "importance": importance,
            "confidence": confidence,
        }
        if resolved_agent_id:
            payload["agent_id"] = resolved_agent_id
        if context:
            payload["context"] = context
        if space_id:
            payload["space_id"] = space_id
        if ttl_seconds is not None:
            payload["ttl_seconds"] = ttl_seconds
        if branch_id:
            payload["branch_id"] = branch_id
        return self._request("POST", "/v1/memory-drafts", json_data=payload)

    def memory_drafts(self, *, status: Optional[str] = None, branch_id: Optional[str] = None) -> Dict[str, Any]:
        """List memory drafts for the current tenant."""
        params: Dict[str, Any] = {}
        if status:
            params["status"] = status
        if branch_id:
            params["branch_id"] = branch_id
        return self._request("GET", "/v1/memory-drafts", params=params)

    def memory_draft(self, draft_id: str) -> Dict[str, Any]:
        """Fetch a single memory draft."""
        return self._request("GET", f"/v1/memory-drafts/{draft_id}")

    def draft_diff(self, draft_id: str, *, compare_to: Optional[str] = None) -> Dict[str, Any]:
        """Get a field-level diff between a draft and existing memory."""
        params = {"compare_to": compare_to} if compare_to else None
        return self._request("GET", f"/v1/memory-drafts/{draft_id}/diff", params=params)

    def merge_draft(
        self,
        draft_id: str,
        *,
        supersede_memory_id: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Merge a reviewed draft into canonical memory."""
        payload = {"supersede_memory_id": supersede_memory_id} if supersede_memory_id else {}
        return self._request("POST", f"/v1/memory-drafts/{draft_id}/merge", json_data=payload)

    def reject_draft(self, draft_id: str, *, reason: Optional[str] = None) -> Dict[str, Any]:
        """Reject a memory draft."""
        payload = {"reason": reason} if reason else {}
        return self._request("POST", f"/v1/memory-drafts/{draft_id}/reject", json_data=payload)

    def memory_branch(self, branch_id: str) -> Dict[str, Any]:
        """Get grouped review information for a draft branch/session."""
        return self._request("GET", f"/v1/memory-branches/{branch_id}")

    def merge_branch(self, branch_id: str) -> Dict[str, Any]:
        """Merge all open drafts in a branch/session."""
        return self._request("POST", f"/v1/memory-branches/{branch_id}/merge", json_data={})

    def reject_branch(self, branch_id: str, *, reason: Optional[str] = None) -> Dict[str, Any]:
        """Reject all open drafts in a branch/session."""
        payload = {"reason": reason} if reason else {}
        return self._request("POST", f"/v1/memory-branches/{branch_id}/reject", json_data=payload)

    def memories(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        tags: Optional[List[str]] = None,
        min_importance: Optional[int] = None,
        include_shared: bool = False,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        **kwargs,
    ) -> List[Dict[str, Any]]:
        """
        List all memories with optional filtering.

        Args:
            limit: Maximum results per page (default: 100, max: 1000)
            offset: Pagination offset
            tags: Optional tag filter
            min_importance: Filter by minimum importance (1-10)
            include_shared: Include shared space memories
            agent_id: Filter by agent ID
            space_id: Filter by context space

        Returns:
            List of memory dictionaries

        Example:
            >>> all_memories = nx.memories(limit=50, min_importance=7)
            >>> print(f"Found {len(all_memories)} high-importance memories")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if tags:
            params["tags"] = ",".join(tags)
        if min_importance is not None:
            params["min_importance"] = min_importance
        if include_shared:
            params["include_shared"] = "true"
        if agent_id:
            params["agent_id"] = agent_id
        if space_id:
            params["space_id"] = space_id

        result = self._request("GET", "/v1/memories", params=params)
        return result.get("memories", []) if isinstance(result, dict) else result

    def memory(self, memory_id: str) -> Dict[str, Any]:
        """
        Get a specific memory by ID.

        Args:
            memory_id: The memory UUID

        Returns:
            Memory details dictionary

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> mem = nx.memory("urn:uuid:abc123...")
            >>> print(mem["observation"])
        """
        return self._request("GET", f"/v1/memories/{memory_id}")

    def forget(self, memory_id: str) -> bool:
        """
        Delete a specific memory.

        Args:
            memory_id: The memory UUID

        Returns:
            True if deleted successfully

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> nx.forget("urn:uuid:abc123...")
            True
        """
        result = self._request("DELETE", f"/v1/memories/{memory_id}")
        return result.get("success", True)

    def supersede(self, old_memory_id: str, new_memory_id: str) -> Dict[str, Any]:
        """
        Mark a memory as superseded by another.

        The old memory will be excluded from recall results by default
        but remains accessible via direct lookup and audit trail.

        Args:
            old_memory_id: UUID of the memory being superseded
            new_memory_id: UUID of the memory that supersedes it

        Returns:
            Updated memory details

        Example:
            >>> nx.supersede("urn:uuid:old...", "urn:uuid:new...")
        """
        return self._request(
            "PATCH",
            f"/v1/memories/{old_memory_id}",
            json_data={"superseded_by": new_memory_id},
        )

    def list(
        self,
        *,
        limit: int = 100,
        offset: int = 0,
        tags: Optional[List[str]] = None,
        min_importance: Optional[int] = None,
        include_shared: bool = False,
        agent_id: Optional[str] = None,
        space_id: Optional[str] = None,
        **kwargs,
    ) -> ListResult:
        """
        List memories with optional filtering. Returns a ListResult with Memory objects.

        Alias for memories() with rich return type.

        Args:
            limit: Maximum results per page (default: 100, max: 1000)
            offset: Pagination offset
            tags: Optional tag filter
            min_importance: Filter by minimum importance (1-10)
            include_shared: Include shared space memories
            agent_id: Filter by agent ID
            space_id: Filter by context space

        Returns:
            ListResult with Memory objects (iterable)

        Example:
            >>> result = nx.list(min_importance=7)
            >>> for mem in result:
            ...     print(mem.observation)
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if tags:
            params["tags"] = ",".join(tags)
        if min_importance is not None:
            params["min_importance"] = min_importance
        if include_shared:
            params["include_shared"] = "true"
        if agent_id:
            params["agent_id"] = agent_id
        if space_id:
            params["space_id"] = space_id

        result = self._request("GET", "/v1/memories", params=params)
        raw_memories = result.get("memories", []) if isinstance(result, dict) else result

        return ListResult(
            total_count=result.get("total_count", len(raw_memories)) if isinstance(result, dict) else len(raw_memories),
            memories=[Memory.from_dict(m) for m in raw_memories],
            filters=result.get("filters", {}) if isinstance(result, dict) else {},
        )

    def delete(self, memory_id: str) -> bool:
        """
        Delete a specific memory. Alias for forget().

        Args:
            memory_id: The memory UUID

        Returns:
            True if deleted successfully

        Raises:
            NovyxNotFoundError: If memory not found

        Example:
            >>> nx.delete("urn:uuid:abc123...")
            True
        """
        return self.forget(memory_id)

    def stats(self) -> Dict[str, Any]:
        """
        Get memory statistics for your account.

        Returns:
            Dict with total_memories, avg_importance, tag distribution, etc.

        Example:
            >>> stats = nx.stats()
            >>> print(f"Total memories: {stats['total_memories']}")
        """
        return self._request("GET", "/v1/memories/stats")

    # ========================================================================
    # Context Space & Sharing Methods
    # ========================================================================

    def create_space(
        self,
        name: str,
        *,
        description: Optional[str] = None,
        allowed_agent_ids: Optional[List[str]] = None,
        allowed_tenant_ids: Optional[List[str]] = None,
        tags: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Create a context space for multi-agent collaboration.

        Args:
            name: Name for the space
            description: Optional description
            allowed_agent_ids: Agent IDs allowed to access this space
            allowed_tenant_ids: Tenant IDs allowed to access this space
            tags: Optional tags for the space

        Returns:
            Dict with space_id, name, description, owner, created_at, etc.

        Example:
            >>> space = nx.create_space("research", description="Shared research notes")
            >>> print(space["space_id"])
        """
        body: Dict[str, Any] = {"name": name}
        if description is not None:
            body["description"] = description
        if allowed_agent_ids is not None:
            body["allowed_agent_ids"] = allowed_agent_ids
        if allowed_tenant_ids is not None:
            body["allowed_tenant_ids"] = allowed_tenant_ids
        if tags is not None:
            body["tags"] = tags
        return self._request("POST", "/v1/context-spaces", json_data=body)

    def list_spaces(self) -> Dict[str, Any]:
        """
        List all context spaces accessible to the current tenant.

        Returns:
            Dict with spaces list and total_count

        Example:
            >>> result = nx.list_spaces()
            >>> for s in result["spaces"]:
            ...     print(f"{s['name']} ({s['memory_count']} memories)")
        """
        return self._request("GET", "/v1/context-spaces")

    def get_space(self, space_id: str) -> Dict[str, Any]:
        """
        Get a context space by ID.

        Args:
            space_id: The space ID

        Returns:
            Dict with space details and memory_count

        Example:
            >>> space = nx.get_space("space_abc123")
            >>> print(space["name"])
        """
        return self._request("GET", f"/v1/context-spaces/{space_id}")

    def update_space(
        self,
        space_id: str,
        *,
        name: Optional[str] = None,
        description: Optional[str] = None,
        allowed_agent_ids: Optional[List[str]] = None,
        tags: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Update a context space (owner only).

        Args:
            space_id: The space ID to update
            name: New name
            description: New description
            allowed_agent_ids: Updated allowed agent IDs
            tags: Updated tags

        Returns:
            Dict with updated space details

        Example:
            >>> nx.update_space("space_abc", name="Updated Name")
        """
        body: Dict[str, Any] = {}
        if name is not None:
            body["name"] = name
        if description is not None:
            body["description"] = description
        if allowed_agent_ids is not None:
            body["allowed_agent_ids"] = allowed_agent_ids
        if tags is not None:
            body["tags"] = tags
        return self._request("PUT", f"/v1/context-spaces/{space_id}", json_data=body)

    def delete_space(self, space_id: str) -> Dict[str, Any]:
        """
        Delete a context space (owner only).

        Args:
            space_id: The space ID to delete

        Returns:
            Empty dict on success (204 response)

        Example:
            >>> nx.delete_space("space_abc123")
        """
        return self._request("DELETE", f"/v1/context-spaces/{space_id}")

    def space_memories(
        self,
        space_id: str,
        *,
        query: Optional[str] = None,
        tags: Optional[List[str]] = None,
        agent_id: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List or search memories within a context space.

        Args:
            space_id: The space to query
            query: Optional semantic search query
            tags: Optional tag filter
            agent_id: Optional agent ID filter
            limit: Max results (default 100)
            offset: Pagination offset

        Returns:
            Dict with memories list, total_count, filters

        Example:
            >>> result = nx.space_memories("space_abc", query="user preferences")
            >>> for m in result["memories"]:
            ...     print(m["observation"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if query is not None:
            params["q"] = query
        if tags is not None:
            params["tags"] = ",".join(tags)
        if agent_id is not None:
            params["agent_id"] = agent_id
        return self._request("GET", f"/v1/context-spaces/{space_id}/memories", params=params)

    def share_context(
        self,
        tag: str,
        to_email: str,
        permission: str = "read",
    ) -> Dict[str, Any]:
        """
        Share a tag/space with another user via email.

        Args:
            tag: Tag or space name to share
            to_email: Recipient's email address
            permission: "read" or "write" (default: "read")

        Returns:
            Dict with token, share_url, tag, permission info

        Example:
            >>> result = nx.share_context("project-notes", "colleague@example.com")
            >>> print(result["share_url"])
        """
        return self._request(
            "POST",
            "/v1/spaces/share",
            json_data={
                "tag": tag,
                "email": to_email,
                "permission": permission,
            },
        )

    def accept_shared_context(self, token: str) -> Dict[str, Any]:
        """
        Accept a share invitation and join a shared space.

        Args:
            token: Share token from the invitation

        Returns:
            Dict with success, tag, owner_tenant_id, permission

        Example:
            >>> result = nx.accept_shared_context("share_abc123...")
            >>> print(f"Joined space '{result['tag']}'")
        """
        return self._request(
            "POST",
            "/v1/spaces/join",
            json_data={"token": token},
        )

    def shared_contexts(self) -> Dict[str, Any]:
        """
        List spaces shared by and with the current user.

        Returns:
            Dict with shared_by_me and shared_with_me lists

        Example:
            >>> ctx = nx.shared_contexts()
            >>> for s in ctx["shared_by_me"]:
            ...     print(f"Shared '{s['tag']}' with {s.get('shared_with_email')}")
        """
        return self._request("GET", "/v1/spaces/shared")

    def revoke_shared_context(self, token: str) -> Dict[str, Any]:
        """
        Revoke a share invitation (owner only).

        Args:
            token: The share token to revoke

        Returns:
            Empty dict on success (204 response)

        Example:
            >>> nx.revoke_shared_context("share_abc123...")
        """
        return self._request("DELETE", f"/v1/spaces/share/{token}")

    def graph(
        self,
        *,
        include_shared: bool = False,
        agents: Optional[List[str]] = None,
    ) -> Dict[str, Any]:
        """
        Get memory graph with nodes and edges.

        Args:
            include_shared: Include shared space memories in graph
            agents: Filter by agent IDs

        Returns:
            Dict with nodes, edges, agents, total_nodes, total_edges

        Example:
            >>> g = nx.graph(include_shared=True)
            >>> print(f"{g['total_nodes']} nodes, {g['total_edges']} edges")
        """
        params: Dict[str, Any] = {}
        if include_shared:
            params["include_shared"] = "true"
        if agents:
            params["agents"] = ",".join(agents)
        return self._request("GET", "/v1/memories/graph", params=params)

    # ========================================================================
    # Rollback Methods (Pro+ only)
    # ========================================================================

    def rollback(
        self,
        target: str,
        dry_run: bool = False,
        preserve_evidence: bool = True,
    ) -> Dict[str, Any]:
        """
        Rollback memory to a point in time.

        Supports timestamps and relative time expressions.

        Args:
            target: ISO timestamp (e.g., "2026-01-15T10:00:00Z") or relative time (e.g., "2 hours ago")
            dry_run: If True, preview changes without applying (default: False)
            preserve_evidence: If True, quarantine rolled-back data (default: True)

        Returns:
            Dict with rollback results:
                - success: bool
                - rolled_back_to: timestamp
                - artifacts_restored: int
                - operations_undone: int
                - quarantine_path: str (if preserve_evidence=True)

        Raises:
            NovyxForbiddenError: If on Free tier (rollback requires Pro+)
            NovyxRateLimitError: If rollback limit exceeded (Free: 10/month)

        Example:
            >>> # Preview rollback first
            >>> preview = nx.rollback("2 hours ago", dry_run=True)
            >>> print(f"Will restore {preview['artifacts_restored']} artifacts")
            >>>
            >>> # Execute rollback
            >>> result = nx.rollback("2 hours ago")
            >>> print(f"Rolled back to {result['rolled_back_to']}")
        """
        return self._request(
            "POST",
            "/v1/rollback",
            json_data={
                "target": target,
                "dry_run": dry_run,
                "preserve_evidence": preserve_evidence,
            },
        )

    def rollback_preview(self, target: str) -> Dict[str, Any]:
        """
        Preview what will change before executing rollback.

        Args:
            target: ISO timestamp or relative time (e.g., "2 hours ago")

        Returns:
            Dict with preview:
                - target_timestamp: str
                - artifacts_modified: int
                - artifacts_deleted: int
                - size_bytes: int
                - safe_rollback: bool
                - warnings: List[str]

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> preview = nx.rollback_preview("2 hours ago")
            >>> if preview["safe_rollback"]:
            ...     nx.rollback("2 hours ago")
        """
        return self._request("GET", "/v1/rollback/preview", params={"target": target})

    def rollback_history(self, limit: int = 50) -> List[Dict[str, Any]]:
        """
        List past rollbacks for this tenant.

        Args:
            limit: Maximum results to return (default: 50, max: 200)

        Returns:
            List of rollback records with:
                - rollback_id: str
                - executed_at: timestamp
                - target_timestamp: timestamp
                - artifacts_restored: int
                - operations_undone: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> history = nx.rollback_history(limit=10)
            >>> for rb in history:
            ...     print(f"Rollback to {rb['target_timestamp']} at {rb['executed_at']}")
        """
        result = self._request("GET", "/v1/rollback/history", params={"limit": limit})
        return result if isinstance(result, list) else []

    # ========================================================================
    # Audit Methods
    # ========================================================================

    def audit(
        self,
        limit: int = 50,
        since: Optional[str] = None,
        until: Optional[str] = None,
        operation: Optional[str] = None,
        artifact_id: Optional[str] = None,
    ) -> List[Dict[str, Any]]:
        """
        Get audit trail entries with filtering.

        Args:
            limit: Maximum entries to return (default: 50, max: 1000)
            since: Start timestamp (ISO format)
            until: End timestamp (ISO format)
            operation: Filter by operation type (CREATE, UPDATE, DELETE, ROLLBACK)
            artifact_id: Filter by specific artifact ID

        Returns:
            List of audit entries with:
                - timestamp: str
                - operation: str
                - agent_id: str
                - artifact_id: str
                - content_hash: str

        Example:
            >>> # Get recent audit entries
            >>> audit = nx.audit(limit=10)
            >>>
            >>> # Filter by time range
            >>> from datetime import datetime, timedelta
            >>> since = (datetime.now() - timedelta(hours=24)).isoformat()
            >>> audit = nx.audit(since=since, operation="CREATE")
        """
        params = {"limit": limit}
        if since:
            params["start_time"] = since
        if until:
            params["end_time"] = until
        if operation:
            params["operation"] = operation
        if artifact_id:
            params["artifact_id"] = artifact_id

        result = self._request("GET", "/v1/audit", params=params)
        return result.get("entries", []) if isinstance(result, dict) else result

    def audit_export(self, format: str = "csv") -> bytes:
        """
        Export audit log in CSV or JSON format.

        Args:
            format: Export format - "csv", "json", or "jsonl" (default: "csv")

        Returns:
            Raw bytes of exported audit log

        Raises:
            NovyxForbiddenError: If on Free tier (export requires Pro+)

        Example:
            >>> # Export as CSV
            >>> data = nx.audit_export(format="csv")
            >>> with open("audit.csv", "wb") as f:
            ...     f.write(data)
            >>>
            >>> # Export as JSON
            >>> data = nx.audit_export(format="json")
            >>> with open("audit.json", "wb") as f:
            ...     f.write(data)
        """
        response = self._request(
            "GET",
            "/v1/audit/export",
            params={"format": format},
            raw_response=True,
        )
        return response.content

    def audit_verify(self) -> Dict[str, Any]:
        """
        Verify audit trail integrity chain.

        Returns:
            Dict with verification results:
                - valid: bool
                - errors: List[str]
                - total_entries: int
                - chain_head: str

        Example:
            >>> verification = nx.audit_verify()
            >>> if verification["valid"]:
            ...     print("Audit trail integrity verified!")
            >>> else:
            ...     print(f"Errors: {verification['errors']}")
        """
        return self._request("GET", "/v1/audit/verify")

    # ========================================================================
    # Trace Methods (Pro+ only)
    # ========================================================================

    def trace_create(
        self,
        agent_id: str,
        session_id: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Create a new trace session for agent action tracking.

        Args:
            agent_id: Identifier for the agent
            session_id: Optional session identifier
            metadata: Optional metadata dictionary

        Returns:
            Dict with trace information:
                - trace_id: str
                - agent_id: str
                - session_id: str
                - created_at: timestamp

        Raises:
            NovyxForbiddenError: If on Free tier (traces require Pro+)

        Example:
            >>> trace = nx.trace_create("my-agent", session_id="session-123")
            >>> trace_id = trace["trace_id"]
        """
        return self._request(
            "POST",
            "/v1/traces",
            json_data={
                "agent_id": agent_id,
                "session_id": session_id,
                "metadata": metadata or {},
            },
        )

    def trace_step(
        self,
        trace_id: str,
        step_type: str,
        content: str,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """
        Add a step to an existing trace with real-time policy evaluation.

        Args:
            trace_id: The trace ID from trace_create()
            step_type: Type of step (e.g., "thought", "action", "observation")
            content: Step content
            metadata: Optional metadata dictionary

        Returns:
            Dict with step information and policy status:
                - step_id: str
                - circuit_breaker_triggered: bool
                - action: str (allow/block)

        Raises:
            NovyxForbiddenError: If on Free tier
            NovyxSecurityError: If circuit breaker triggered

        Example:
            >>> nx.trace_step(trace_id, "thought", "User prefers async communication")
            >>> nx.trace_step(trace_id, "action", "Sending email", metadata={"to": "user@example.com"})
        """
        data = {
            "type": step_type,
            "content": content,
        }
        if metadata:
            data["metadata"] = metadata
        return self._request(
            "POST",
            f"/v1/traces/{trace_id}/steps",
            json_data=data,
        )

    def trace_complete(self, trace_id: str) -> Dict[str, Any]:
        """
        Finalize a trace with RSA signature for integrity.

        Args:
            trace_id: The trace ID to complete

        Returns:
            Dict with completion information:
                - trace_id: str
                - status: str
                - signature: str (RSA signature)
                - public_key_id: str
                - signed_at: timestamp
                - integrity_chain_valid: bool
                - total_steps: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> result = nx.trace_complete(trace_id)
            >>> print(f"Trace signed with {result['signature'][:16]}...")
        """
        return self._request("POST", f"/v1/traces/{trace_id}/complete")

    def trace_verify(self, trace_id: str) -> Dict[str, Any]:
        """
        Verify trace integrity chain and signature.

        Args:
            trace_id: The trace ID to verify

        Returns:
            Dict with verification results:
                - trace_id: str
                - valid: bool
                - errors: List[str]
                - steps_verified: int

        Raises:
            NovyxForbiddenError: If on Free tier

        Example:
            >>> verification = nx.trace_verify(trace_id)
            >>> if verification["valid"]:
            ...     print("Trace integrity verified!")
        """
        return self._request("POST", f"/v1/traces/{trace_id}/verify")

    # ========================================================================
    # Usage & Plans
    # ========================================================================

    def usage(self) -> Dict[str, Any]:
        """
        Get current usage stats vs limits for your account.

        Returns:
            Dict with usage information:
                - tier: str (free/starter/pro/enterprise)
                - api_calls: Dict with current, limit, unlimited
                - rollbacks: Dict with current, limit, unlimited
                - memories: Dict with current, limit, unlimited
                - audit_retention_days: int
                - features: Dict of enabled features
                - period: str (YYYY-MM)

        Example:
            >>> usage = nx.usage()
            >>> print(f"Tier: {usage['tier']}")
            >>> print(f"API calls: {usage['api_calls']['current']}/{usage['api_calls']['limit']}")
            >>> print(f"Memories: {usage['memories']['current']}")
            >>> if usage['api_calls']['unlimited']:
            ...     print("Unlimited API calls!")
        """
        return self._request("GET", "/v1/usage")

    def dashboard(self) -> Dict[str, Any]:
        """
        Aggregated dashboard stats in a single call.

        Returns:
            Dict with:
                - memory_count: int
                - tier: str
                - features: Dict of enabled features
                - usage_percent: Dict with api_calls, memories, rollbacks percentages
                - pressure: str (low/medium/high/critical)
                - api_calls_today: int
                - limits: Dict with api_calls, memories, rollbacks limits
                - period: str (YYYY-MM)

        Example:
            >>> dash = nx.dashboard()
            >>> print(f"{dash['memory_count']} memories, {dash['pressure']} pressure")
        """
        return self._request("GET", "/v1/dashboard")

    def plans(self) -> List[Dict[str, Any]]:
        """
        Get available pricing plans and limits.

        Returns:
            List of plan dictionaries with:
                - plan_id: str
                - name: str
                - price_cents: int
                - price_display: str
                - memory_limit: int or None
                - api_calls_limit: int or None
                - rollbacks_limit: int or None
                - audit_retention_days: int
                - features: Dict of features

        Example:
            >>> plans = nx.plans()
            >>> for plan in plans:
            ...     print(f"{plan['name']}: {plan['price_display']}")
            ...     print(f"  Memories: {plan['memory_limit'] or 'Unlimited'}")
            ...     print(f"  Features: {', '.join([k for k, v in plan['features'].items() if v])}")
        """
        return self._request("GET", "/v1/plans")

    def health(self) -> Dict[str, Any]:
        """
        Check API health status.

        Returns:
            Dict with status, version, timestamp

        Example:
            >>> health = nx.health()
            >>> print(f"Status: {health['status']}")
        """
        try:
            response = requests.get(
                f"{self.api_url}/health",
                timeout=self.timeout,
            )
            return response.json()
        except Exception as e:
            return {"status": "error", "error": str(e)}

    # ========================================================================
    # Context Methods
    # ========================================================================

    def context_now(self) -> Dict[str, Any]:
        """
        Get temporal context snapshot for agent orientation at session start.

        Returns:
            Dict with:
                - server_time_utc: str (ISO 8601)
                - recent_memories: List of memories from last 24h (max 10)
                - recent_count: int (total in last 24h)
                - upcoming: List of deadline/scheduled memories
                - upcoming_count: int
                - last_session_at: str or None
                - seconds_since_last_session: float or None

        Example:
            >>> ctx = nx.context_now()
            >>> print(f"Server time: {ctx['server_time_utc']}")
            >>> print(f"Recent: {ctx['recent_count']} memories")
            >>> print(f"Last session: {ctx.get('seconds_since_last_session', 'never')}s ago")
        """
        return self._request("GET", "/v1/context/now")

    # ========================================================================
    # Session Methods
    # ========================================================================

    def session(self, session_id: str) -> "NovyxSession":
        """Create a scoped session for grouping related memories."""
        return NovyxSession(self, session_id)

    # ========================================================================
    # Link / Edge Methods
    # ========================================================================

    def link(
        self,
        source_id: str,
        target_id: str,
        *,
        relation: str = "related",
        weight: float = 1.0,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a directed edge between two memories."""
        return self._request(
            "POST", "/v1/memories/link",
            json_data={
                "source_id": source_id, "target_id": target_id,
                "relation": relation, "weight": weight,
                "metadata": metadata or {},
            },
        )

    def unlink(
        self,
        source_id: str,
        target_id: str,
        *,
        relation: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Remove edge(s) between two memories."""
        body: Dict[str, Any] = {"source_id": source_id, "target_id": target_id}
        if relation is not None:
            body["relation"] = relation
        return self._request("DELETE", "/v1/memories/link", json_data=body)

    def links(self, memory_id: str, *, relation: Optional[str] = None) -> Dict[str, Any]:
        """Get all edges connected to a memory."""
        params: Dict[str, Any] = {}
        if relation:
            params["relation"] = relation
        return self._request("GET", f"/v1/memories/{memory_id}/links", params=params)

    def edges(
        self,
        *,
        relation: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """
        List all edges for the current tenant.

        Args:
            relation: Filter by relation type (e.g. "auto_related")
            limit: Maximum results (default: 100, max: 1000)
            offset: Pagination offset

        Returns:
            Dict with edges list and total count

        Example:
            >>> result = nx.edges(relation="auto_related")
            >>> for edge in result["edges"]:
            ...     print(f"{edge['source_id']} -> {edge['target_id']}")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if relation:
            params["relation"] = relation
        return self._request("GET", "/v1/memories/edges", params=params)

    # ========================================================================
    # Knowledge Graph Methods (Pro+)
    # ========================================================================

    def triple(
        self,
        subject: str,
        predicate: str,
        object: str,
        *,
        confidence: float = 1.0,
        source_memory_id: Optional[str] = None,
        subject_type: Optional[str] = None,
        object_type: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a knowledge graph triple (subject -> predicate -> object).

        Entities are auto-created by name if they don't exist.
        Re-stating an existing triple updates confidence and metadata.

        Example:
            >>> nx.triple("blake", "prefers", "dark mode")
            >>> nx.triple("blake", "works_at", "novyx", subject_type="person", object_type="company")
        """
        body: Dict[str, Any] = {
            "subject": subject,
            "predicate": predicate,
            "object": object,
            "confidence": confidence,
        }
        if source_memory_id:
            body["source_memory_id"] = source_memory_id
        if subject_type:
            body["subject_type"] = subject_type
        if object_type:
            body["object_type"] = object_type
        if metadata:
            body["metadata"] = metadata
        return self._request("POST", "/v1/knowledge/triples", json_data=body)

    def triples(
        self,
        *,
        subject: Optional[str] = None,
        predicate: Optional[str] = None,
        object: Optional[str] = None,
        source_memory_id: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List knowledge graph triples with optional filters.

        Example:
            >>> result = nx.triples(subject="blake")
            >>> for t in result["triples"]:
            ...     print(f"{t['subject']['name']} -> {t['predicate']} -> {t['object']['name']}")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if subject:
            params["subject"] = subject
        if predicate:
            params["predicate"] = predicate
        if object:
            params["object"] = object
        if source_memory_id:
            params["source_memory_id"] = source_memory_id
        return self._request("GET", "/v1/knowledge/triples", params=params)

    def delete_triple(self, triple_id: str) -> Dict[str, Any]:
        """Delete a knowledge graph triple by ID."""
        return self._request("DELETE", f"/v1/knowledge/triples/{triple_id}")

    def entities(
        self,
        *,
        entity_type: Optional[str] = None,
        q: Optional[str] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List knowledge graph entities.

        Example:
            >>> result = nx.entities(entity_type="person")
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if entity_type:
            params["entity_type"] = entity_type
        if q:
            params["q"] = q
        return self._request("GET", "/v1/knowledge/entities", params=params)

    def entity(self, entity_id: str) -> Dict[str, Any]:
        """Get an entity and all its connections (traversal).

        Example:
            >>> info = nx.entity("entity-uuid-here")
            >>> print(f"{info['entity']['name']} has {info['total_connections']} connections")
        """
        return self._request("GET", f"/v1/knowledge/entities/{entity_id}")

    def delete_entity(self, entity_id: str) -> Dict[str, Any]:
        """Delete an entity and cascade-delete all its triples."""
        return self._request("DELETE", f"/v1/knowledge/entities/{entity_id}")

    # ========================================================================
    # Agent Methods (Runtime v2)
    # ========================================================================

    def create_agent(
        self,
        name: str,
        *,
        agent_id: Optional[str] = None,
        description: Optional[str] = None,
        model: str = "gpt-4o-mini",
        provider: str = "openai",
        instructions: Optional[str] = None,
        memory_scope: Optional[str] = None,
        capabilities: Optional[List[str]] = None,
        policy_profile: Optional[Dict[str, Any]] = None,
        config: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a first-class agent entity. Returns the agent record.

        Example:
            >>> agent = nx.create_agent("research-bot", model="gpt-4o", instructions="You are a researcher.")
            >>> print(agent["agent_id"])
        """
        payload: Dict[str, Any] = {"name": name, "model": model, "provider": provider}
        if agent_id:
            payload["agent_id"] = agent_id
        if description:
            payload["description"] = description
        if instructions:
            payload["instructions"] = instructions
        if memory_scope:
            payload["memory_scope"] = memory_scope
        if capabilities:
            payload["capabilities"] = capabilities
        if policy_profile:
            payload["policy_profile"] = policy_profile
        if config:
            payload["config"] = config
        return self._request("POST", "/v1/agents", json_data=payload)

    def get_agent(self, agent_id: str) -> Dict[str, Any]:
        """Get an agent by ID.

        Example:
            >>> agent = nx.get_agent("agent-uuid-here")
            >>> print(agent["name"], agent["status"])
        """
        return self._request("GET", f"/v1/agents/{agent_id}")

    def list_agents(
        self,
        *,
        status: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List all agents for this tenant.

        Example:
            >>> result = nx.list_agents(status="active")
            >>> for a in result["agents"]:
            ...     print(a["name"], a["agent_id"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if status:
            params["status"] = status
        return self._request("GET", "/v1/agents", params=params)

    def update_agent(self, agent_id: str, **kwargs) -> Dict[str, Any]:
        """Update agent fields. Pass only the fields to change.

        Example:
            >>> nx.update_agent("agent-uuid", name="new-name", model="gpt-4o")
        """
        return self._request("PATCH", f"/v1/agents/{agent_id}", json_data=kwargs)

    def delete_agent(self, agent_id: str) -> Dict[str, Any]:
        """Delete an agent.

        Example:
            >>> nx.delete_agent("agent-uuid-here")
        """
        return self._request("DELETE", f"/v1/agents/{agent_id}")

    # ========================================================================
    # Mission Methods (Runtime v2)
    # ========================================================================

    def create_mission(
        self,
        agent_id: str,
        goal: str,
        *,
        constraints: Optional[List[str]] = None,
        success_criteria: Optional[List[str]] = None,
        allowed_capabilities: Optional[List[str]] = None,
        escalation_rules: Optional[List[Dict[str, Any]]] = None,
        stop_conditions: Optional[List[str]] = None,
        config: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a mission for an agent. Returns the mission record.

        Example:
            >>> mission = nx.create_mission("agent-uuid", "Research competitor pricing",
            ...     constraints=["no external APIs"], success_criteria=["report generated"])
            >>> print(mission["mission_id"])
        """
        payload: Dict[str, Any] = {"agent_id": agent_id, "goal": goal}
        if constraints is not None:
            payload["constraints"] = constraints
        if success_criteria is not None:
            payload["success_criteria"] = success_criteria
        if allowed_capabilities is not None:
            payload["allowed_capabilities"] = allowed_capabilities
        if escalation_rules is not None:
            payload["escalation_rules"] = escalation_rules
        if stop_conditions is not None:
            payload["stop_conditions"] = stop_conditions
        if config is not None:
            payload["config"] = config
        return self._request("POST", "/v1/missions", json_data=payload)

    def get_mission(self, mission_id: str) -> Dict[str, Any]:
        """Get a mission by ID.

        Example:
            >>> mission = nx.get_mission("mission-uuid-here")
            >>> print(mission["goal"], mission["status"])
        """
        return self._request("GET", f"/v1/missions/{mission_id}")

    def list_missions(
        self,
        *,
        agent_id: Optional[str] = None,
        status: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List missions, optionally filtered by agent or status.

        Example:
            >>> result = nx.list_missions(agent_id="agent-uuid", status="active")
            >>> for m in result["missions"]:
            ...     print(m["goal"], m["status"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if agent_id:
            params["agent_id"] = agent_id
        if status:
            params["status"] = status
        return self._request("GET", "/v1/missions", params=params)

    def update_mission(self, mission_id: str, **kwargs) -> Dict[str, Any]:
        """Update mission fields. Pass only the fields to change.

        Example:
            >>> nx.update_mission("mission-uuid", goal="Updated goal", constraints=["budget < $100"])
        """
        return self._request("PATCH", f"/v1/missions/{mission_id}", json_data=kwargs)

    def delete_mission(self, mission_id: str) -> Dict[str, Any]:
        """Delete a mission.

        Example:
            >>> nx.delete_mission("mission-uuid-here")
        """
        return self._request("DELETE", f"/v1/missions/{mission_id}")

    def pause_mission(self, mission_id: str) -> Dict[str, Any]:
        """Pause an active mission.

        Example:
            >>> nx.pause_mission("mission-uuid-here")
        """
        return self._request("POST", f"/v1/missions/{mission_id}/pause")

    def resume_mission(self, mission_id: str) -> Dict[str, Any]:
        """Resume a paused mission.

        Example:
            >>> nx.resume_mission("mission-uuid-here")
        """
        return self._request("POST", f"/v1/missions/{mission_id}/resume")

    def cancel_mission(self, mission_id: str) -> Dict[str, Any]:
        """Cancel a mission.

        Example:
            >>> nx.cancel_mission("mission-uuid-here")
        """
        return self._request("POST", f"/v1/missions/{mission_id}/cancel")

    # ========================================================================
    # Capability Methods (Runtime v2)
    # ========================================================================

    def create_capability(
        self,
        name: str,
        *,
        capability_id: Optional[str] = None,
        description: Optional[str] = None,
        tools: Optional[List[Dict[str, Any]]] = None,
        risk_levels: Optional[Dict[str, Any]] = None,
        approval_requirements: Optional[Dict[str, Any]] = None,
        memory_behavior: Optional[Dict[str, Any]] = None,
        eval_rules: Optional[Dict[str, Any]] = None,
        config: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a capability pack. Returns the capability record.

        Example:
            >>> cap = nx.create_capability("web-search",
            ...     description="Search the web", tools=[{"name": "google"}])
            >>> print(cap["capability_id"])
        """
        payload: Dict[str, Any] = {"name": name}
        if capability_id is not None:
            payload["capability_id"] = capability_id
        if description is not None:
            payload["description"] = description
        if tools is not None:
            payload["tools"] = tools
        if risk_levels is not None:
            payload["risk_levels"] = risk_levels
        if approval_requirements is not None:
            payload["approval_requirements"] = approval_requirements
        if memory_behavior is not None:
            payload["memory_behavior"] = memory_behavior
        if eval_rules is not None:
            payload["eval_rules"] = eval_rules
        if config is not None:
            payload["config"] = config
        return self._request("POST", "/v1/capabilities", json_data=payload)

    def get_capability(self, capability_id: str) -> Dict[str, Any]:
        """Get a capability by ID.

        Example:
            >>> cap = nx.get_capability("cap-uuid-here")
            >>> print(cap["name"], cap["status"])
        """
        return self._request("GET", f"/v1/capabilities/{capability_id}")

    def list_capabilities(
        self,
        *,
        status: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List capabilities, optionally filtered by status.

        Example:
            >>> result = nx.list_capabilities(status="active")
            >>> for c in result["capabilities"]:
            ...     print(c["name"], c["status"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if status:
            params["status"] = status
        return self._request("GET", "/v1/capabilities", params=params)

    def update_capability(self, capability_id: str, **kwargs) -> Dict[str, Any]:
        """Update capability fields. Pass only the fields to change.

        Example:
            >>> nx.update_capability("cap-uuid", description="Updated desc")
        """
        return self._request("PATCH", f"/v1/capabilities/{capability_id}", json_data=kwargs)

    def delete_capability(self, capability_id: str) -> Dict[str, Any]:
        """Delete a capability.

        Example:
            >>> nx.delete_capability("cap-uuid-here")
        """
        return self._request("DELETE", f"/v1/capabilities/{capability_id}")

    # ========================================================================
    # Checkpoint Methods (Runtime v2)
    # ========================================================================

    def create_checkpoint(
        self,
        mission_id: str,
        *,
        label: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Create a checkpoint for a mission.

        Example:
            >>> ckpt = nx.create_checkpoint("msn_abc123", label="before-deploy")
            >>> print(ckpt["checkpoint_id"])
        """
        payload: Dict[str, Any] = {}
        if label is not None:
            payload["label"] = label
        if metadata is not None:
            payload["metadata"] = metadata
        return self._request("POST", f"/v1/missions/{mission_id}/checkpoints", json_data=payload)

    def list_checkpoints(
        self,
        mission_id: str,
        *,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List checkpoints for a mission.

        Example:
            >>> result = nx.list_checkpoints("msn_abc123")
            >>> for c in result["checkpoints"]:
            ...     print(c["checkpoint_id"], c["label"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        return self._request("GET", f"/v1/missions/{mission_id}/checkpoints", params=params)

    def get_checkpoint(self, checkpoint_id: str) -> Dict[str, Any]:
        """Get a single checkpoint by ID.

        Example:
            >>> ckpt = nx.get_checkpoint("ckpt_abc123")
            >>> print(ckpt["mission_status"])
        """
        return self._request("GET", f"/v1/checkpoints/{checkpoint_id}")

    def rollback_to_checkpoint(
        self,
        mission_id: str,
        checkpoint_id: str,
        *,
        reason: Optional[str] = None,
    ) -> Dict[str, Any]:
        """Rollback a mission to a specific checkpoint.

        Example:
            >>> result = nx.rollback_to_checkpoint("msn_abc123", "ckpt_abc123",
            ...     reason="bad deploy")
        """
        payload: Dict[str, Any] = {"checkpoint_id": checkpoint_id}
        if reason is not None:
            payload["reason"] = reason
        return self._request("POST", f"/v1/missions/{mission_id}/rollback", json_data=payload)

    # ========================================================================
    # Intervention Methods (Runtime v2)
    # ========================================================================

    def create_intervention(
        self,
        intervention_type: str,
        *,
        mission_id: Optional[str] = None,
        action_id: Optional[str] = None,
        agent_id: Optional[str] = None,
        rationale: Optional[str] = None,
        metadata: Optional[Dict[str, Any]] = None,
    ) -> Dict[str, Any]:
        """Record a supervisor intervention.

        Example:
            >>> intv = nx.create_intervention("pause",
            ...     mission_id="mis_abc", rationale="Needs review")
            >>> print(intv["intervention_id"])
        """
        payload: Dict[str, Any] = {"intervention_type": intervention_type}
        if mission_id is not None:
            payload["mission_id"] = mission_id
        if action_id is not None:
            payload["action_id"] = action_id
        if agent_id is not None:
            payload["agent_id"] = agent_id
        if rationale is not None:
            payload["rationale"] = rationale
        if metadata is not None:
            payload["metadata"] = metadata
        return self._request("POST", "/v1/interventions", json_data=payload)

    def get_intervention(self, intervention_id: str) -> Dict[str, Any]:
        """Get an intervention by ID.

        Example:
            >>> intv = nx.get_intervention("intv_abc123")
            >>> print(intv["intervention_type"], intv["actor"])
        """
        return self._request("GET", f"/v1/interventions/{intervention_id}")

    def list_interventions(
        self,
        *,
        mission_id: Optional[str] = None,
        agent_id: Optional[str] = None,
        intervention_type: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """List interventions, optionally filtered.

        Example:
            >>> result = nx.list_interventions(mission_id="mis_abc")
            >>> for i in result["interventions"]:
            ...     print(i["intervention_type"], i["actor"])
        """
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if mission_id:
            params["mission_id"] = mission_id
        if agent_id:
            params["agent_id"] = agent_id
        if intervention_type:
            params["type"] = intervention_type
        return self._request("GET", "/v1/interventions", params=params)

    # ========================================================================
    # Replay Methods (Pro+)
    # ========================================================================

    def replay_timeline(
        self,
        *,
        since: Optional[str] = None,
        until: Optional[str] = None,
        operations: Optional[List[str]] = None,
        agent_id: Optional[str] = None,
        limit: int = 100,
        offset: int = 0,
    ) -> Dict[str, Any]:
        """Get timeline of memory operations (Pro+)."""
        params: Dict[str, Any] = {"limit": limit, "offset": offset}
        if since:
            params["since"] = since
        if until:
            params["until"] = until
        if operations:
            params["operations"] = ",".join(operations)
        if agent_id:
            params["agent_id"] = agent_id
        return self._request("GET", "/v1/replay/timeline", params=params)

    def replay_snapshot(self, at: str, *, limit: int = 500, offset: int = 0) -> Dict[str, Any]:
        """Reconstruct memory state at a specific timestamp (Pro+)."""
        return self._request("GET", "/v1/replay/snapshot", params={"at": at, "limit": limit, "offset": offset})

    def replay_memory(self, memory_id: str) -> Dict[str, Any]:
        """Get full lifecycle of a single memory (Pro+)."""
        return self._request("GET", f"/v1/replay/memory/{memory_id}")

    def replay_recall(self, query: str, at: str, *, limit: int = 5) -> Dict[str, Any]:
        """Counterfactual recall: what would the agent have recalled at time T? (Enterprise)."""
        return self._request("POST", "/v1/replay/recall", json_data={"query": query, "at": at, "limit": limit})

    def replay_diff(self, from_ts: str, to_ts: str) -> Dict[str, Any]:
        """Memory diff between two timestamps (Pro+)."""
        return self._request("GET", "/v1/replay/diff", params={"from": from_ts, "to": to_ts})

    def replay_drift(self, from_ts: str, to_ts: str) -> Dict[str, Any]:
        """Analyze memory composition drift between two timestamps (Enterprise)."""
        return self._request("GET", "/v1/replay/drift", params={"from": from_ts, "to": to_ts})

    # ========================================================================
    # Cortex Methods (Pro+)
    # ========================================================================

    def cortex_status(self) -> Dict[str, Any]:
        """Get Cortex status: last run, stats, and config (Pro+)."""
        return self._request("GET", "/v1/cortex/status")

    def cortex_config(self) -> Dict[str, Any]:
        """Get current Cortex configuration (Pro+)."""
        return self._request("GET", "/v1/cortex/config")

    def cortex_update_config(self, **kwargs) -> Dict[str, Any]:
        """Update Cortex configuration (Pro+). Pass fields as keyword args."""
        return self._request("PATCH", "/v1/cortex/config", json_data=kwargs)

    def cortex_run(self) -> Dict[str, Any]:
        """Trigger a manual Cortex cycle (Pro+)."""
        return self._request("POST", "/v1/cortex/run")

    def cortex_insights(self, *, limit: int = 20, offset: int = 0) -> Dict[str, Any]:
        """Get Cortex-generated insights (Enterprise)."""
        return self._request("GET", "/v1/cortex/insights", params={"limit": limit, "offset": offset})

    # ── Eval ──────────────────────────────────────────────────────────

    def eval_run(self, *, min_score: Optional[float] = None) -> Dict[str, Any]:
        """
        Run a memory health evaluation.

        Returns a health_score (0-100) with breakdown:
        recall_consistency, drift_score, conflict_score, staleness_score.

        Free: 3/day, Starter: 30/day, Pro: unlimited.
        """
        payload = {}
        if min_score is not None:
            payload["min_score"] = min_score
        return self._request("POST", "/v1/eval/run", json_data=payload or None)

    def eval_gate(self, min_score: float) -> Dict[str, Any]:
        """
        CI/CD quality gate. Runs eval and returns the result if health_score >= min_score.
        Raises NovyxError (HTTP 422) if the score is below threshold.

        Pro tier required.

        Args:
            min_score: Minimum health score to pass (0-100)
        """
        return self._request("POST", "/v1/eval/gate", json_data={"min_score": min_score})

    def eval_history(self, *, limit: int = 50, offset: int = 0) -> Dict[str, Any]:
        """
        Get eval score history.

        Free: 7 days, Starter: 30 days, Pro: 90 days.
        """
        return self._request("GET", "/v1/eval/history", params={"limit": limit, "offset": offset})

    def eval_drift(self, *, days: int = 7) -> Dict[str, Any]:
        """
        Get memory drift analysis over a time period.

        Starter: basic score only. Pro: full per-memory breakdown.

        Args:
            days: Number of days to analyze (1-90, default 7)
        """
        return self._request("GET", "/v1/eval/drift", params={"days": days})

    def eval_baseline_create(self, query: str, expected_observation: str) -> Dict[str, Any]:
        """
        Save a recall baseline for regression testing.

        Future eval runs will check if this query still returns the expected result.

        Free: 1 baseline, Starter: 5, Pro: unlimited.

        Args:
            query: The recall query to baseline
            expected_observation: Expected top result observation
        """
        return self._request("POST", "/v1/eval/baselines", json_data={
            "query": query,
            "expected_observation": expected_observation,
        })

    def eval_baselines(self) -> Dict[str, Any]:
        """List all saved eval baselines."""
        return self._request("GET", "/v1/eval/baselines")

    def eval_baseline_delete(self, baseline_id: str) -> bool:
        """Delete an eval baseline."""
        self._request("DELETE", f"/v1/eval/baselines/{baseline_id}")
        return True

    # =========================================================================
    # Sentinel Intel — Threat Intelligence, Auto-Defense & Correlation (Pro+)
    # =========================================================================

    # -- Threat Intel --

    def threat_feed(self, *, hours: int = 24, min_severity: str = "medium") -> Dict[str, Any]:
        """Get recent threat events from the intel feed."""
        return self._request("GET", "/v1/sentinel-intel/threats/feed", params={"hours": hours, "min_severity": min_severity})

    def threat_stats(self) -> Dict[str, Any]:
        """Get aggregate threat statistics for this tenant."""
        return self._request("GET", "/v1/sentinel-intel/threats/stats")

    def threat_record(self, threat_event: Dict[str, Any]) -> Dict[str, Any]:
        """Record a new threat event."""
        return self._request("POST", "/v1/sentinel-intel/threats/record", json_data={"threat_event": threat_event})

    def threat_trending(self, *, hours: int = 24, min_occurrences: int = 2) -> Dict[str, Any]:
        """Get trending threat patterns over a time window."""
        return self._request("GET", "/v1/sentinel-intel/threats/trending", params={"hours": hours, "min_occurrences": min_occurrences})

    def threat_match(self, threat_event: Dict[str, Any], *, min_similarity: float = 0.8) -> Dict[str, Any]:
        """Match a threat event against known signatures."""
        return self._request("POST", "/v1/sentinel-intel/threats/match", json_data={"threat_event": threat_event, "min_similarity": min_similarity})

    def threat_signature(self, signature_id: str) -> Dict[str, Any]:
        """Get details for a specific threat signature."""
        return self._request("GET", f"/v1/sentinel-intel/threats/{signature_id}")

    def threat_mitigate(self, signature_id: str) -> Dict[str, Any]:
        """Trigger mitigation for a threat signature."""
        return self._request("POST", f"/v1/sentinel-intel/threats/{signature_id}/mitigate")

    # -- Auto Defense --

    def defense_list(self, *, rule_type: Optional[str] = None) -> Dict[str, Any]:
        """List active defense rules, optionally filtered by type."""
        params: Dict[str, Any] = {}
        if rule_type is not None:
            params["rule_type"] = rule_type
        return self._request("GET", "/v1/sentinel-intel/defenses", params=params or None)

    def defense_deploy(self, signature_id: str, rule_type: str, rule_config: Optional[Dict] = None) -> Dict[str, Any]:
        """Deploy a new defense rule for a threat signature."""
        data: Dict[str, Any] = {"signature_id": signature_id, "rule_type": rule_type}
        if rule_config is not None:
            data["rule_config"] = rule_config
        return self._request("POST", "/v1/sentinel-intel/defenses/deploy", json_data=data)

    def defense_remove(self, defense_id: str) -> Dict[str, Any]:
        """Remove a deployed defense rule."""
        return self._request("DELETE", f"/v1/sentinel-intel/defenses/{defense_id}")

    def defense_effectiveness(self, defense_id: str) -> Dict[str, Any]:
        """Get effectiveness metrics for a defense rule."""
        return self._request("GET", f"/v1/sentinel-intel/defenses/{defense_id}/effectiveness")

    def defense_record_block(self, defense_id: str, *, is_false_positive: bool = False) -> Dict[str, Any]:
        """Record a block event for a defense rule."""
        return self._request("POST", f"/v1/sentinel-intel/defenses/{defense_id}/block", json_data={"is_false_positive": is_false_positive})

    def defense_stats(self) -> Dict[str, Any]:
        """Get aggregate defense statistics."""
        return self._request("GET", "/v1/sentinel-intel/defenses/stats")

    def defense_recommend(self, signature_id: str) -> Dict[str, Any]:
        """Get recommended defense rules for a threat signature."""
        return self._request("GET", f"/v1/sentinel-intel/defenses/recommend/{signature_id}")

    # -- Correlation --

    def correlate_threat(self, threat_event: Dict[str, Any]) -> Dict[str, Any]:
        """Correlate a threat event against historical data."""
        return self._request("POST", "/v1/sentinel-intel/correlation/correlate", json_data={"threat_event": threat_event})

    def detect_campaign(self, *, hours: int = 24) -> Dict[str, Any]:
        """Detect coordinated threat campaigns over a time window."""
        return self._request("GET", "/v1/sentinel-intel/correlation/campaign", params={"hours": hours})

    def coordinated_attack_check(self, threat_events: list, *, time_window_hours: Optional[int] = None) -> Dict[str, Any]:
        """Check if a set of threat events form a coordinated attack."""
        data: Dict[str, Any] = {"threat_events": threat_events}
        if time_window_hours is not None:
            data["time_window_hours"] = time_window_hours
        return self._request("POST", "/v1/sentinel-intel/correlation/coordinated", json_data=data)

    def related_signatures(self, signature_id: str, *, similarity_threshold: float = 0.7) -> Dict[str, Any]:
        """Find signatures related to a given threat signature."""
        return self._request("GET", f"/v1/sentinel-intel/correlation/related/{signature_id}", params={"similarity_threshold": similarity_threshold})

    # =========================================================================
    # Control — Governed agent actions (requires control_url)
    # =========================================================================

    def _control_request(
        self,
        method: str,
        endpoint: str,
        params: Optional[Dict] = None,
        json_data: Optional[Dict] = None,
    ) -> Dict[str, Any]:
        """Make a request to Novyx Control."""
        if not self._control_url:
            raise NovyxError("Control not configured. Pass control_url to Novyx().")
        url = f"{self._control_url}{endpoint}"
        headers = {"Authorization": f"Bearer {self._control_api_key or ''}"}
        if json_data:
            headers["Content-Type"] = "application/json"
        resp = requests.request(
            method, url, headers=headers, params=params,
            json=json_data, timeout=self.timeout,
        )
        self._handle_error(resp)
        return resp.json() if resp.text else {}

    def action_submit(
        self,
        connector: str,
        operation: str,
        payload: Dict[str, Any],
    ) -> Dict[str, Any]:
        """
        Submit an action to Novyx Control for governed execution.

        The action will be evaluated against the tenant's policy profile.
        If approval is required, it will be queued; otherwise executed immediately.

        Args:
            connector: Target connector (github, slack, linear, pagerduty, http)
            operation: Operation name (e.g. issues/create, messages/send)
            payload: Full strata.action.v0 envelope
        """
        return self._control_request("POST", f"/v1/actions/{connector}/{operation}", json_data=payload)

    def action_status(self, action_id: str) -> Dict[str, Any]:
        """
        Get the status of a Control action.

        Returns full action details including policy decision, approval status,
        execution result, and evidence (Novyx trace, certificate).

        Args:
            action_id: The action ID (e.g. act_xxx)
        """
        return self._control_request("GET", f"/v1/actions/{action_id}")

    def action_list(self, status: Optional[str] = None) -> Dict[str, Any]:
        """
        List recent Control actions.

        Args:
            status: Optional filter by status (submitted, pending_approval, approved, denied, executed, failed)
        """
        params = {}
        if status:
            params["status"] = status
        return self._control_request("GET", "/v1/actions", params=params or None)

    def policy_check(
        self,
        agent_id: Optional[str] = None,
        connector: Optional[str] = None,
        operation: Optional[str] = None,
    ) -> Dict[str, Any]:
        """
        Check the current Control policy profile.

        Returns policy rules including which connectors require approval,
        risk tier settings, and auto-approve HTTP methods.

        Args:
            agent_id: Optional agent to check permissions for (future)
            connector: Optional connector to check
            operation: Optional operation to check
        """
        return self._control_request("GET", "/v1/control/policies")

    # ------------------------------------------------------------------
    # Streams (real-time SSE)
    # ------------------------------------------------------------------

    def stream(
        self,
        space_id: Optional[str] = None,
        types: Optional[List[str]] = None,
    ) -> Iterator[Dict[str, Any]]:
        """
        Subscribe to real-time memory events via Server-Sent Events.

        Yields event dicts as they arrive. Each event has:
        - event_type: str (e.g. "memory.created")
        - data: dict with event details

        Args:
            space_id: Filter events to a specific context space
            types: List of event types to filter (e.g. ["memory.created", "memory.updated"])

        Example:
            >>> for event in nx.stream(space_id="my-project"):
            ...     print(f"{event['event_type']}: {event['data']}")
        """
        import json as _json

        params = {}
        if space_id:
            params["space_id"] = space_id
        if types:
            params["types"] = ",".join(types)

        response = self._session.get(
            f"{self.api_url}/v1/streams/subscribe",
            params=params,
            headers=self._headers(),
            stream=True,
            timeout=None,  # SSE is long-lived
        )
        if response.status_code != 200:
            raise NovyxError(f"Stream connection failed: {response.status_code}")

        event_type = None
        for line in response.iter_lines(decode_unicode=True):
            if line is None:
                continue
            if line.startswith("event: "):
                event_type = line[7:]
            elif line.startswith("data: "):
                try:
                    data = _json.loads(line[6:])
                    yield {"event_type": event_type or "unknown", "data": data}
                except _json.JSONDecodeError:
                    pass
                event_type = None
            elif line.startswith(":"):
                # Comment / keepalive — ignore
                pass

    def stream_status(self) -> Dict[str, Any]:
        """Get stream connection status for this tenant."""
        return self._request("GET", "/v1/streams/status")

    # ------------------------------------------------------------------
    # Inspectability
    # ------------------------------------------------------------------

    def explain_action(self, action_id: str) -> Dict[str, Any]:
        """
        Get the full causal chain for a Control action.

        Returns what the agent recalled, what policy fired, what memory state
        existed at that moment, and the audit trail — all in one call.

        Args:
            action_id: The action UUID to explain.

        Example:
            >>> explanation = nx.explain_action("act_12345")
            >>> print(explanation["summary"])
        """
        return self._request("GET", f"/v1/actions/{action_id}/explain")

    def list_approvals(self, *, limit: int = 50, status_filter: Optional[str] = None) -> Dict[str, Any]:
        """
        List pending action approvals.

        Args:
            limit: Maximum number of approvals to return.
            status_filter: Filter by status (e.g. "pending_review").

        Example:
            >>> approvals = nx.list_approvals()
            >>> for a in approvals["approvals"]:
            ...     print(f"{a['action']} — {a['status']}")
        """
        params: Dict[str, Any] = {"limit": limit}
        if status_filter:
            params["status_filter"] = status_filter
        return self._request("GET", "/v1/approvals", params=params)

    def approve_action(self, approval_id: str, *, decision: str = "approve",
                       reason: Optional[str] = None, approver_id: Optional[str] = None) -> Dict[str, Any]:
        """
        Approve or deny a pending action.

        Args:
            approval_id: The action/approval UUID.
            decision: "approve" or "deny".
            reason: Optional reason for the decision.
            approver_id: ID of the person making the decision.

        Example:
            >>> result = nx.approve_action("act_12345", decision="approve", reason="Looks safe")
        """
        params: Dict[str, Any] = {"decision": decision}
        if reason:
            params["reason"] = reason
        if approver_id:
            params["approver_id"] = approver_id
        return self._request("POST", f"/v1/approvals/{approval_id}/decision", params=params)

    def list_policies(self) -> Dict[str, Any]:
        """
        List active Control policies.

        Returns policy names, enabled status, connectors, and approval modes.

        Example:
            >>> policies = nx.list_policies()
            >>> for p in policies["policies"]:
            ...     print(f"{p['name']} — {'active' if p['enabled'] else 'disabled'}")
        """
        return self._request("GET", "/v1/control/policies")

    def memory_health(self) -> Dict[str, Any]:
        """
        Check the health of your agent's memory.

        Returns a health score (0-100), stale/conflict/contradiction counts,
        and a scoring breakdown.

        Example:
            >>> health = nx.memory_health()
            >>> print(f"Score: {health['score']}/100")
        """
        stats = self._request("GET", "/v1/memories/stats")
        return stats


class NovyxSession:
    """Scoped session that automatically tags and filters memories by session ID."""

    def __init__(self, client: Novyx, session_id: str):
        self._client = client
        self.session_id = session_id
        self._tag = f"session:{session_id}"

    def remember(self, observation: str, *, tags: Optional[List[str]] = None, **kwargs) -> Dict[str, Any]:
        all_tags = list(tags or [])
        if self._tag not in all_tags:
            all_tags.append(self._tag)
        return self._client.remember(observation, tags=all_tags, **kwargs)

    def recall(self, query: str, *, tags: Optional[List[str]] = None, **kwargs) -> "SearchResult":
        all_tags = list(tags or [])
        if self._tag not in all_tags:
            all_tags.append(self._tag)
        return self._client.recall(query, tags=all_tags, **kwargs)

    def memories(self, *, tags: Optional[List[str]] = None, **kwargs) -> List[Dict[str, Any]]:
        all_tags = list(tags or [])
        if self._tag not in all_tags:
            all_tags.append(self._tag)
        return self._client.memories(tags=all_tags, **kwargs)

    def list(self, *, tags: Optional[List[str]] = None, **kwargs) -> "ListResult":
        all_tags = list(tags or [])
        if self._tag not in all_tags:
            all_tags.append(self._tag)
        return self._client.list(tags=all_tags, **kwargs)
