"""
MCAL: Memory-Context Alignment Layer

Main interface providing a standalone memory architecture for AI agents.

Architecture:
    ┌─────────────────────────────────────────────┐
    │                   MCAL                       │
    │  ┌─────────────────────────────────────┐    │
    │  │  UnifiedExtractor (Single-pass)     │    │
    │  │  - Entities, Goals, Decisions       │    │
    │  │  - Relationships, Embeddings        │    │
    │  └─────────────────────────────────────┘    │
    │                    │                         │
    │                    ▼                         │
    │  ┌─────────────────────────────────────┐    │
    │  │      UnifiedGraph + VectorIndex     │    │
    │  │  (Graph storage + semantic search)  │    │
    │  └─────────────────────────────────────┘    │
    └─────────────────────────────────────────────┘

Usage:
    from mcal import MCAL
    
    # Initialize (no external dependencies!)
    mcal = MCAL(
        openai_api_key="..."  # or anthropic_api_key, or use bedrock
    )
    
    # Add conversation (extracts facts + intents + reasoning)
    result = await mcal.add(
        messages=[
            {"role": "user", "content": "Let's build a fraud detection system"},
            {"role": "assistant", "content": "Great! What's your data source?"}
        ],
        user_id="user_123"
    )
    
    # Search with goal-awareness
    context = await mcal.search(
        query="What database should we use?",
        user_id="user_123"
    )
    
    # Get assembled context for LLM
    prompt_context = mcal.get_context(
        query="What's our next step?",
        user_id="user_123",
        max_tokens=4000
    )
"""

from __future__ import annotations

import asyncio
import logging
import os
import threading
import time
from typing import Any, Optional
from dataclasses import dataclass, field

from anthropic import AsyncAnthropic
from openai import AsyncOpenAI, RateLimitError as OpenAIRateLimitError, APIError as OpenAIAPIError, APITimeoutError as OpenAITimeoutError, APIConnectionError as OpenAIConnectionError

from pathlib import Path

from .backends import MemoryEntry  # MemoryEntry still needed for compatibility
from .core.models import (
    IntentGraph,
    IntentNode,
    IntentType,
    IntentStatus,
    DecisionTrail,
    Session,
    RetrievalConfig,
)
from .core.goal_retriever import ContextAssembler
from .core.storage import MCALStorage, SQLiteStorage
from .core.extraction_cache import ExtractionCache
from .core.unified_extractor import (
    UnifiedExtractor,
    UnifiedGraph,
    NodeType,
    EdgeType,
    CompactionPolicy,
    CompactionEvent,
    graph_to_memories,
    memories_to_context_string,
)
from .core.retry import (
    llm_retry,
    classify_http_error,
    LLMRateLimitError,
    LLMServerError,
    LLMTimeoutError,
)
from .core.grammar_validator import GrammarValidator
from .core.token_budget import TokenBudget, count_tokens
from .core.tiered_routing import TieredLLMClient, RoutingMetrics, DEFAULT_TIERED_MODELS
from .core.complexity_classifier import TurnComplexityClassifier, ComplexityLevel
from .core.chunk_store import ChunkStore, TextChunk
from .providers.bedrock import BedrockProvider

logger = logging.getLogger(__name__)


# =============================================================================
# LLM Client Wrapper
# =============================================================================

class AnthropicClient:
    """Wrapper around Anthropic async client for MCAL components."""
    
    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
        self.client = AsyncAnthropic(api_key=api_key)
        self.model = model
    
    @llm_retry(max_attempts=3, min_wait=1.0, max_wait=10.0)
    async def complete(self, prompt: str, system: Optional[str] = None) -> str:
        """
        Generate completion with automatic retry on transient failures.
        
        Uses native async Anthropic SDK — does not block the event loop.
        Retries on rate limits and server errors with exponential backoff.
        """
        messages = [{"role": "user", "content": prompt}]
        
        try:
            response = await self.client.messages.create(
                model=self.model,
                max_tokens=4096,
                system=system or "You are a helpful assistant.",
                messages=messages
            )
            return response.content[0].text
        except Exception as e:
            # Anthropic SDK raises specific exceptions we can classify
            error_str = str(e).lower()
            if 'rate' in error_str or '429' in error_str:
                raise LLMRateLimitError(f"Anthropic rate limit: {e}")
            elif 'timeout' in error_str or 'timed out' in error_str:
                raise LLMTimeoutError(f"Anthropic timeout: {e}")
            elif 'connection' in error_str or 'connect' in error_str:
                raise LLMTimeoutError(f"Anthropic connection error: {e}")
            elif '500' in error_str or '502' in error_str or '503' in error_str or '504' in error_str:
                raise LLMServerError(f"Anthropic server error: {e}")
            raise


class OpenAIClient:
    """Wrapper around OpenAI async client for MCAL components."""
    
    def __init__(self, api_key: str, model: str = "gpt-4o"):
        self.client = AsyncOpenAI(api_key=api_key)
        self.model = model
        # Token tracking
        self.total_prompt_tokens = 0
        self.total_completion_tokens = 0
    
    @llm_retry(max_attempts=3, min_wait=1.0, max_wait=10.0)
    async def complete(self, prompt: str, system: Optional[str] = None) -> str:
        """
        Generate completion with automatic retry on transient failures.
        
        Uses native async OpenAI SDK — does not block the event loop.
        Retries on rate limits (429) and server errors (5xx) with exponential backoff.
        """
        messages = [{"role": "user", "content": prompt}]
        if system:
            messages.insert(0, {"role": "system", "content": system})
        
        try:
            response = await self.client.chat.completions.create(
                model=self.model,
                max_tokens=4096,
                messages=messages
            )
            
            # Track token usage
            if response.usage:
                self.total_prompt_tokens += response.usage.prompt_tokens
                self.total_completion_tokens += response.usage.completion_tokens
            
            return response.choices[0].message.content
        except OpenAIRateLimitError as e:
            raise LLMRateLimitError(f"OpenAI rate limit: {e}")
        except OpenAITimeoutError as e:
            raise LLMTimeoutError(f"OpenAI timeout: {e}")
        except OpenAIConnectionError as e:
            raise LLMTimeoutError(f"OpenAI connection error: {e}")
        except OpenAIAPIError as e:
            # Check for server errors
            if hasattr(e, 'status_code') and e.status_code in (500, 502, 503, 504):
                raise LLMServerError(f"OpenAI server error ({e.status_code}): {e}")
            raise
    
    def get_token_usage(self) -> dict:
        """Get cumulative token usage."""
        return {
            "prompt_tokens": self.total_prompt_tokens,
            "completion_tokens": self.total_completion_tokens,
            "total_tokens": self.total_prompt_tokens + self.total_completion_tokens
        }
    
    def reset_token_usage(self):
        """Reset token counters."""
        self.total_prompt_tokens = 0
        self.total_completion_tokens = 0

class BedrockProviderWrapper:
    """Wrapper around Bedrock provider for MCAL components."""
    
    def __init__(self, model: str = "llama-3.3-70b", region: str = "us-east-1"):
        self.provider = BedrockProvider(model=model, region=region)
        self.model = model
    
    async def complete(self, prompt: str, system: Optional[str] = None) -> str:
        """Generate completion."""
        messages = []
        
        # Bedrock Converse API doesn't support system role - merge with user message
        if system:
            combined_prompt = f"{system}\n\n{prompt}"
            messages = [{"role": "user", "content": combined_prompt}]
        else:
            messages = [{"role": "user", "content": prompt}]
        
        return await self.provider.generate(messages)


class TieredBedrockProvider:
    """
    Tiered Bedrock provider that routes to fast/smart models based on task complexity.
    
    Fast model (8B): Simple tasks like intent detection, graph updates
    Smart model (70B): Complex tasks like decision extraction, reasoning
    """
    
    def __init__(
        self, 
        fast_model: str = "llama-3.1-8b",
        smart_model: str = "llama-3.3-70b",
        region: str = "us-east-1"
    ):
        self.fast_provider = BedrockProvider(model=fast_model, region=region)
        self.smart_provider = BedrockProvider(model=smart_model, region=region)
        self.fast_model = fast_model
        self.smart_model = smart_model
        
        # Track usage for metrics
        self.fast_calls = 0
        self.smart_calls = 0
    
    async def complete(
        self, 
        prompt: str, 
        system: Optional[str] = None,
        use_smart: bool = False
    ) -> str:
        """
        Generate completion using appropriate model tier.
        
        Args:
            prompt: The prompt to send
            system: System prompt (merged with user prompt)
            use_smart: Force use of smart model (for complex tasks)
        """
        # Build messages
        if system:
            combined_prompt = f"{system}\n\n{prompt}"
            messages = [{"role": "user", "content": combined_prompt}]
        else:
            messages = [{"role": "user", "content": prompt}]
        
        # Route to appropriate model
        if use_smart:
            self.smart_calls += 1
            logger.debug(f"Using SMART model ({self.smart_model})")
            return await self.smart_provider.generate(messages)
        else:
            self.fast_calls += 1
            logger.debug(f"Using FAST model ({self.fast_model})")
            return await self.fast_provider.generate(messages)
    
    def get_usage_stats(self) -> dict:
        """Get model usage statistics."""
        total = self.fast_calls + self.smart_calls
        return {
            "fast_calls": self.fast_calls,
            "smart_calls": self.smart_calls,
            "total_calls": total,
            "fast_ratio": self.fast_calls / total if total > 0 else 0,
        }
    
    def get_token_usage(self) -> dict:
        """Get total token usage from both providers."""
        fast_usage = self.fast_provider.get_token_usage()
        smart_usage = self.smart_provider.get_token_usage()
        return {
            "prompt_tokens": fast_usage["prompt_tokens"] + smart_usage["prompt_tokens"],
            "completion_tokens": fast_usage["completion_tokens"] + smart_usage["completion_tokens"],
            "total_tokens": fast_usage["total_tokens"] + smart_usage["total_tokens"],
            "fast_tokens": fast_usage["total_tokens"],
            "smart_tokens": smart_usage["total_tokens"],
        }
    
    def reset_token_usage(self):
        """Reset token counters on both providers."""
        self.fast_provider.reset_token_usage()
        self.smart_provider.reset_token_usage()


# =============================================================================
# MCAL Result Types
# =============================================================================

@dataclass
class TimingMetrics:
    """Timing metrics for performance monitoring."""
    total_ms: int = 0
    facts_ms: int = 0
    intents_ms: int = 0
    decisions_ms: int = 0
    parallel_savings_ms: int = 0  # Time saved by parallelization


@dataclass
class AddResult:
    """Result from adding messages to MCAL."""
    # From Mem0
    facts: list[MemoryEntry] = field(default_factory=list)
    
    # From MCAL (novel)
    intent_graph: Optional[IntentGraph] = None
    decisions: list[DecisionTrail] = field(default_factory=list)
    
    # From Unified Extractor (Issue #19)
    unified_graph: Optional[UnifiedGraph] = None
    
    # Performance metrics
    timing: Optional[TimingMetrics] = None
    
    @property
    def summary(self) -> dict:
        """Quick summary of what was extracted."""
        summary = {
            "facts_count": len(self.facts),
            "intents_count": len(self.intent_graph.nodes) if self.intent_graph else 0,
            "decisions_count": len(self.decisions),
        }
        # Add unified graph stats if present
        if self.unified_graph:
            graph_summary = self.unified_graph.summary()
            summary["unified_graph"] = {
                "nodes": graph_summary["total_nodes"],
                "edges": graph_summary["total_edges"],
                "decisions": graph_summary["decisions"],
                "goals": graph_summary["goals"],
                "actions": graph_summary["actions"],
                "facts": graph_summary.get("facts", 0),  # Issue #107
            }
        if self.timing:
            summary["timing_ms"] = {
                "total": self.timing.total_ms,
                "facts": self.timing.facts_ms,
                "intents": self.timing.intents_ms,
                "decisions": self.timing.decisions_ms,
                "parallel_savings": self.timing.parallel_savings_ms,
            }
        return summary


# Issue #113: Context coverage metrics for hallucination prevention
@dataclass
class ContextCoverage:
    """Coverage metrics indicating how much of the graph is represented in context.
    
    Used to detect sparse context conditions where LLMs may hallucinate.
    When coverage is low, agents should acknowledge uncertainty rather than
    fabricating details.
    """
    # How much of the graph is covered (0.0 - 1.0)
    coverage_ratio: float = 0.0
    
    # Node counts
    nodes_retrieved: int = 0
    nodes_total: int = 0
    
    # Category tracking: which semantic categories have data vs were retrieved
    categories_in_graph: list[str] = field(default_factory=list)
    categories_retrieved: list[str] = field(default_factory=list)
    categories_missing: list[str] = field(default_factory=list)
    
    # Sparsity flag — True when context is likely insufficient
    is_sparse: bool = False
    
    # Per-category node counts in graph
    category_counts: dict[str, int] = field(default_factory=dict)
    
    @property
    def sufficiency_label(self) -> str:
        """Human-readable sufficiency label."""
        if self.coverage_ratio >= 0.6:
            return "high"
        elif self.coverage_ratio >= 0.3:
            return "moderate"
        else:
            return "low"


@dataclass
class ContextResult:
    """Issue #113: Context with coverage metadata.
    
    Returned by get_context_with_metadata() to give agents both the
    context text and coverage metrics for hallucination prevention.
    """
    # The assembled context string (same as get_context() return value)
    text: str = ""
    
    # Coverage metrics
    coverage: Optional[ContextCoverage] = None
    
    @property
    def is_sparse(self) -> bool:
        """Whether the context is sparse (high hallucination risk)."""
        return self.coverage.is_sparse if self.coverage else True
    
    @property
    def coverage_ratio(self) -> float:
        """Shortcut to coverage ratio."""
        return self.coverage.coverage_ratio if self.coverage else 0.0


# Issue #113: Anti-hallucination system prompt guidance
ANTI_HALLUCINATION_PROMPT = """IMPORTANT: You are provided with memory context from a knowledge graph.
When the memory context is sparse or incomplete:
- State clearly what information IS available in the context
- Explicitly flag what information is NOT available or missing
- NEVER fabricate specific technology names, version numbers, database choices,
  team compositions, or configuration details not present in the context
- Say "Based on available context, I don't have information about X" rather
  than guessing
- If the context covers some aspects but not others, clearly distinguish between
  what you know from context and what would require additional information"""


@dataclass 
class SearchResult:
    """Result from searching MCAL."""
    # From Mem0 (re-ranked)
    memories: list[MemoryEntry] = field(default_factory=list)
    
    # From MCAL (enriched)
    active_goals: list[IntentNode] = field(default_factory=list)
    completed_goals: list[IntentNode] = field(default_factory=list)  # Issue #110
    relevant_decisions: list[DecisionTrail] = field(default_factory=list)
    structural_edges: list[dict] = field(default_factory=list)  # Issue #111: depends_on, blocks, conflicts
    background_entities: list[MemoryEntry] = field(default_factory=list)  # Issue #117: PERSON/THING/CONCEPT nodes
    key_facts: list[MemoryEntry] = field(default_factory=list)  # Issue #107: FACT nodes (quantitative data)
    
    # Issue #113: Coverage metrics for hallucination prevention
    coverage: Optional[ContextCoverage] = None
    
    # Assembled context
    context: Optional[str] = None

# =============================================================================
# Main MCAL Class
# =============================================================================

class MCAL:
    """
    Memory-Context Alignment Layer.
    
    Adds reasoning preservation and goal-aware retrieval on top of Mem0.
    
    Three Pillars:
    1. Intent Graph Preservation - Track goal hierarchies
    2. Reasoning Chain Storage - Store WHY decisions were made
    3. Goal-Aware Retrieval - Retrieve based on objectives, not just similarity
    """
    
    def __init__(
        self,
        anthropic_api_key: Optional[str] = None,
        openai_api_key: Optional[str] = None,
        model: Optional[str] = None,
        llm_provider: str = "openai",
        bedrock_model: str = "llama-3.3-70b",
        bedrock_region: str = "us-east-1",
        storage_path: Optional[str] = None,
        enable_persistence: bool = True,
        storage_backend: str = "json",
        enable_tiered_models: bool = False,
        bedrock_fast_model: str = "llama-3.1-8b",
        bedrock_smart_model: str = "llama-3.3-70b",
        enable_extraction_cache: bool = True,
        cache_ttl_seconds: int = 86400,
        max_graph_nodes: int = 500,
        enable_grammar_validation: bool = True,
        tokenizer_model: str = "gpt-4o",
        # Issue #124: Generic tiered extraction (all providers)
        enable_tiered_extraction: bool = False,
        fast_model: Optional[str] = None,
        smart_model: Optional[str] = None,
        # Issue #125: Proactive graph compaction with summarization
        compaction_policy: str = "moderate",
        compaction_interval: int = 10,
        # Issue #138: Hybrid retrieval with raw text chunks
        enable_chunk_store: bool = False,
        chunk_size: int = 512,
        chunk_overlap: int = 64,
        max_chunks_per_user: int = 200,
        # Issue #139: Configurable extraction profiles
        extraction_profile: str = "decision",
        # Deprecated parameters (Issue #53 - kept for backward compatibility)
        mem0_config: Optional[dict] = None,
        mem0_api_key: Optional[str] = None,
        use_standalone_backend: bool = False,
        use_unified_extractor: bool = True,
    ):
        """
        Initialize MCAL.
        
        Args:
            anthropic_api_key: Anthropic API key. Auto-detects from ANTHROPIC_API_KEY env var if not provided.
            openai_api_key: OpenAI API key. Auto-detects from OPENAI_API_KEY env var if not provided.
            model: Model to use for extraction (defaults based on provider)
            llm_provider: LLM provider to use ("openai", "anthropic", or "bedrock")
            bedrock_model: Bedrock model to use (default: llama-3.3-70b)
            bedrock_region: AWS region for Bedrock (default: us-east-1)
            storage_path: Path for persistent storage (default: ~/.mcal)
            enable_persistence: Enable cross-session persistence (default: True)
            storage_backend: Storage backend: 'json' (file-per-user) or 'sqlite' (WAL-mode DB) (Issue #127)
            enable_tiered_models: Use fast/smart model routing (bedrock only)
            bedrock_fast_model: Fast model for simple tasks (default: llama-3.1-8b)
            bedrock_smart_model: Smart model for complex tasks (default: llama-3.3-70b)
            enable_extraction_cache: Enable caching of extracted state (Issue #9, #118, default: True)
            cache_ttl_seconds: Time-to-live for cache entries (default: 24h)
            max_graph_nodes: Maximum nodes per user graph before compaction (default: 500)
            enable_grammar_validation: Enable grammar-aware node type validation (Issue #120, default: True)
            tokenizer_model: Model name for tiktoken token counting (Issue #121, default: 'gpt-4o')
            enable_tiered_extraction: Route simple turns to fast model, complex to smart (Issue #124)
            fast_model: Model name for simple turns (default per provider, e.g. gpt-4o-mini)
            smart_model: Model name for complex turns (default per provider, e.g. gpt-4o)
            compaction_policy: Graph compaction aggressiveness: 'conservative', 'moderate', 'aggressive' (Issue #125)
            compaction_interval: Trigger proactive compaction every N turns per user (Issue #125, default: 10)
            enable_chunk_store: Store raw text chunks alongside graph for hybrid retrieval (Issue #138, default: False)
            chunk_size: Max tokens per chunk when chunk_store enabled (Issue #138, default: 512)
            chunk_overlap: Overlapping tokens between chunks (Issue #138, default: 64)
            max_chunks_per_user: Max chunks per user before FIFO eviction (Issue #138, default: 200)
            extraction_profile: Extraction prompt profile — 'decision' (CTO/architecture), 'conversational' (personal/social), 'comprehensive' (both). Default: 'decision' (Issue #139)
            
        Deprecated Args (Issue #53 - will be removed in v1.0):
            mem0_config: Ignored - MCAL is now standalone
            mem0_api_key: Ignored - MCAL is now standalone
            use_standalone_backend: Ignored - always standalone
            use_unified_extractor: Ignored - always uses unified extractor
        """
        self._enable_tiered_models = enable_tiered_models
        self._enable_tiered_extraction = enable_tiered_extraction
        self._enable_extraction_cache = enable_extraction_cache
        self._max_graph_nodes = max_graph_nodes
        self._tokenizer_model = tokenizer_model
        # Issue #125: Proactive compaction config
        self._compaction_policy = CompactionPolicy(compaction_policy)
        self._compaction_interval = compaction_interval
        self._user_turn_counts: dict[str, int] = {}  # Per-user turn counter for periodic trigger
        self._compaction_events: list[CompactionEvent] = []  # Recent compaction events
        
        # Issue #138: Hybrid retrieval config
        self._enable_chunk_store = enable_chunk_store
        self._chunk_size = chunk_size
        self._chunk_overlap = chunk_overlap
        self._max_chunks_per_user = max_chunks_per_user
        self._user_chunk_stores: dict[str, ChunkStore] = {}  # Per-user chunk stores
        
        # Issue #139: Extraction profile
        self._extraction_profile = extraction_profile
        
        # Issue #53: Deprecation warnings for removed parameters
        import warnings
        if mem0_config is not None:
            warnings.warn(
                "mem0_config is deprecated and ignored. MCAL v1.0 is fully standalone.",
                DeprecationWarning,
                stacklevel=2
            )
        if mem0_api_key is not None:
            warnings.warn(
                "mem0_api_key is deprecated and ignored. MCAL v1.0 is fully standalone.",
                DeprecationWarning,
                stacklevel=2
            )
        if use_standalone_backend:
            warnings.warn(
                "use_standalone_backend is deprecated and ignored. MCAL is always standalone.",
                DeprecationWarning,
                stacklevel=2
            )
        if not use_unified_extractor:
            warnings.warn(
                "use_unified_extractor=False is deprecated. Legacy 3-pillar extraction "
                "has been removed. MCAL now always uses unified extraction.",
                DeprecationWarning,
                stacklevel=2
            )
        
        # Initialize extraction cache (Issue #9)
        if enable_extraction_cache:
            cache_path = None
            # Issue #134: Only persist cache to disk when enable_persistence=True
            if enable_persistence:
                if storage_path:
                    cache_path = Path(storage_path) / "extraction_cache.json"
                else:
                    cache_path = Path.home() / ".mcal" / "extraction_cache.json"
            self._extraction_cache = ExtractionCache(
                persist_path=cache_path,
                ttl_seconds=cache_ttl_seconds
            )
            logger.info(f"Extraction cache enabled (TTL: {cache_ttl_seconds}s, persist={cache_path is not None})")
        else:
            self._extraction_cache = None
        
        # Issue #85: Auto-detect API keys from environment variables
        # Explicit params override env vars; env vars are fallback
        openai_api_key = openai_api_key or os.getenv("OPENAI_API_KEY")
        anthropic_api_key = anthropic_api_key or os.getenv("ANTHROPIC_API_KEY")
        
        # Issue #124: Backward compat — enable_tiered_models implies enable_tiered_extraction
        if enable_tiered_models and not enable_tiered_extraction:
            import warnings
            warnings.warn(
                "enable_tiered_models is deprecated. Use enable_tiered_extraction=True "
                "with fast_model/smart_model instead.",
                DeprecationWarning,
                stacklevel=2,
            )
            enable_tiered_extraction = True
            # Map old Bedrock-specific params to new generic params
            fast_model = fast_model or bedrock_fast_model
            smart_model = smart_model or bedrock_smart_model
        
        # Initialize LLM client based on provider
        if llm_provider == "openai":
            if not openai_api_key:
                raise ValueError(
                    "No OpenAI API key found. Either pass openai_api_key= or "
                    "set the OPENAI_API_KEY environment variable."
                )
            model = model or "gpt-4o"
            self._llm = OpenAIClient(api_key=openai_api_key, model=model)
        elif llm_provider == "anthropic":
            if not anthropic_api_key:
                raise ValueError(
                    "No Anthropic API key found. Either pass anthropic_api_key= or "
                    "set the ANTHROPIC_API_KEY environment variable."
                )
            model = model or "claude-sonnet-4-20250514"
            self._llm = AnthropicClient(api_key=anthropic_api_key, model=model)
        elif llm_provider == "bedrock":
            # Bedrock uses AWS credentials from environment
            if enable_tiered_models and not self._enable_tiered_extraction:
                # Legacy path: old TieredBedrockProvider (no classifier)
                self._llm = TieredBedrockProvider(
                    fast_model=bedrock_fast_model,
                    smart_model=bedrock_smart_model,
                    region=bedrock_region
                )
                logger.info(f"Using tiered Bedrock: fast={bedrock_fast_model}, smart={bedrock_smart_model}")
            else:
                self._llm = BedrockProviderWrapper(
                    model=bedrock_model,
                    region=bedrock_region
                )
        else:
            raise ValueError(f"Unknown llm_provider: {llm_provider}. Use 'openai', 'anthropic', or 'bedrock'")
        
        # Issue #124: Wrap LLM in TieredLLMClient for automatic complexity routing
        if enable_tiered_extraction:
            defaults = DEFAULT_TIERED_MODELS.get(llm_provider, {})
            tier_fast = fast_model or defaults.get("fast", model)
            tier_smart = smart_model or defaults.get("smart", model)
            
            # Create a second client for the alternate tier
            # self._llm already uses `model` — we need one for fast and one for smart
            if llm_provider == "openai":
                fast_client = OpenAIClient(api_key=openai_api_key, model=tier_fast)
                smart_client = OpenAIClient(api_key=openai_api_key, model=tier_smart)
            elif llm_provider == "anthropic":
                fast_client = AnthropicClient(api_key=anthropic_api_key, model=tier_fast)
                smart_client = AnthropicClient(api_key=anthropic_api_key, model=tier_smart)
            elif llm_provider == "bedrock":
                fast_client = BedrockProviderWrapper(model=tier_fast, region=bedrock_region)
                smart_client = BedrockProviderWrapper(model=tier_smart, region=bedrock_region)
            else:
                fast_client = self._llm
                smart_client = self._llm
            
            self._llm = TieredLLMClient(
                fast_client=fast_client,
                smart_client=smart_client,
                classifier=TurnComplexityClassifier(),
            )
            logger.info(
                "Tiered extraction enabled: fast=%s, smart=%s (%s)",
                tier_fast, tier_smart, llm_provider,
            )
        
        # Issue #53: No memory backend needed - MCAL is standalone
        # UnifiedGraph handles all storage and search
        
        # Issue #94: Legacy IntentTracker, ReasoningStore removed - unified extractor handles everything
        self._context_assembler = ContextAssembler()
        
        # Issue #120: Grammar-aware validation for extracted node types
        self._grammar_validator = GrammarValidator(enabled=enable_grammar_validation)
        if enable_grammar_validation:
            logger.info("Grammar validation enabled (auto-correct high-confidence mismatches)")
        
        # Initialize unified extractor (Issue #19 - 52x token reduction)
        self._unified_extractor = UnifiedExtractor(
            self._llm,
            grammar_validator=self._grammar_validator,
            extraction_profile=self._extraction_profile,
        )
        self._user_graphs: dict[str, UnifiedGraph] = {}  # Per-user unified graphs
        # Issue #105: Thread safety for _user_graphs
        self._graph_dict_lock = threading.Lock()  # Protects dict mutations from sync methods
        self._user_locks: dict[str, asyncio.Lock] = {}  # Per-user async locks
        logger.info("Unified extractor enabled (single-pass extraction)")
        
        # Initialize persistent storage
        self._enable_persistence = enable_persistence
        self._storage_backend = storage_backend
        if enable_persistence:
            storage_base = Path(storage_path) if storage_path else None
            if storage_backend == "sqlite":
                self._storage = SQLiteStorage(base_path=storage_base)
                logger.info(f"SQLite persistence enabled at {self._storage.base_path}")
            else:
                self._storage = MCALStorage(base_path=storage_base)
                logger.info(f"JSON persistence enabled at {self._storage.base_path}")
        else:
            self._storage = None
            logger.info("Persistence disabled (in-memory only)")
        
        # Session management (in-memory cache)
        self._sessions: dict[str, Session] = {}
    
    def _get_user_lock(self, user_id: str) -> asyncio.Lock:
        """Get or create a per-user asyncio lock for safe concurrent access (Issue #105)."""
        with self._graph_dict_lock:
            if user_id not in self._user_locks:
                self._user_locks[user_id] = asyncio.Lock()
            return self._user_locks[user_id]

    # =========================================================================
    # Lifecycle (Issue #100)
    # =========================================================================
    
    def close(self) -> None:
        """
        Release resources held by this MCAL instance.
        
        Clears the in-memory graph cache and extraction cache.
        Safe to call multiple times.
        """
        with self._graph_dict_lock:
            self._user_graphs.clear()
            self._user_locks.clear()
        self._sessions.clear()
        if self._extraction_cache is not None:
            self._extraction_cache = None
        logger.info("MCAL instance closed")
    
    async def __aenter__(self) -> "MCAL":
        """Async context manager entry."""
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
        """Async context manager exit — calls close()."""
        self.close()
    
    def __enter__(self) -> "MCAL":
        """Sync context manager entry."""
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb) -> None:
        """Sync context manager exit — calls close()."""
        self.close()
    
    def _ensure_graph_loaded(self, user_id: str) -> Optional[UnifiedGraph]:
        """
        Ensure unified graph is loaded for user (lazy loading from storage).
        
        Issue #73 fix: Search and get_context were not loading persisted graphs.
        This method centralizes the lazy loading logic used across add/search/get_context.
        
        Args:
            user_id: User identifier
            
        Returns:
            UnifiedGraph if exists (in memory or on disk), None otherwise
        """
        # Check if already in memory
        if user_id in self._user_graphs:
            return self._user_graphs[user_id]
        
        # Try to load from storage if persistence is enabled
        if self._enable_persistence and self._storage:
            try:
                stored_graph = self._storage.load_unified_graph(user_id)
                if stored_graph:
                    self._user_graphs[user_id] = stored_graph
                    logger.info(f"Loaded unified graph for {user_id} from storage ({len(stored_graph.nodes)} nodes)")
                    return stored_graph
            except Exception as e:
                logger.error(f"Failed to load graph for {user_id} from storage: {e}")
                # Return None — caller will treat as new user
        
        return None
    
    # =========================================================================
    # Issue #138: ChunkStore Management (Hybrid Retrieval)
    # =========================================================================

    def _ensure_chunk_store(self, user_id: str) -> ChunkStore:
        """
        Get or create a per-user ChunkStore, loading from storage if needed.

        Args:
            user_id: User identifier

        Returns:
            ChunkStore for the user
        """
        if user_id in self._user_chunk_stores:
            return self._user_chunk_stores[user_id]

        # Try to load from storage
        if self._enable_persistence and self._storage:
            try:
                loaded = self._load_chunk_store(user_id)
                if loaded:
                    self._user_chunk_stores[user_id] = loaded
                    logger.debug(
                        f"Loaded chunk store for {user_id} from storage "
                        f"({len(loaded)} chunks)"
                    )
                    return loaded
            except Exception as e:
                logger.warning(f"Failed to load chunk store for {user_id}: {e}")

        # Create new
        store = ChunkStore(
            chunk_size=self._chunk_size,
            chunk_overlap=self._chunk_overlap,
            max_chunks=self._max_chunks_per_user,
            tokenizer_model=self._tokenizer_model,
        )
        self._user_chunk_stores[user_id] = store
        return store

    def _save_chunk_store(self, user_id: str, chunk_store: ChunkStore) -> None:
        """Persist chunk store to disk alongside unified graph."""
        if not self._storage:
            return
        user_path = self._storage._get_user_path(user_id)
        chunk_path = user_path / "chunk_store.json"
        data = chunk_store.to_dict()
        self._storage._atomic_write_json(chunk_path, data)
        logger.debug(f"Saved chunk store for {user_id}: {len(chunk_store)} chunks")

    def _load_chunk_store(self, user_id: str) -> Optional[ChunkStore]:
        """Load chunk store from disk."""
        if not self._storage:
            return None
        user_path = self._storage._get_user_path(user_id)
        chunk_path = user_path / "chunk_store.json"
        if not chunk_path.exists():
            return None
        import json
        with open(chunk_path, "r") as f:
            data = json.load(f)
        return ChunkStore.from_dict(data)

    async def add(
        self,
        messages: list[dict],
        user_id: str,
        session_id: Optional[str] = None,
    ) -> AddResult:
        """
        Add messages to memory with unified extraction.
        
        Single-pass extraction (Issue #19) that:
        1. Extracts entities, goals, decisions, and relationships in one LLM call
        2. Merges into per-user UnifiedGraph with deduplication
        3. Persists to disk if persistence enabled
        
        Args:
            messages: List of message dicts [{"role": "user", "content": "..."}]
            user_id: User identifier
            session_id: Optional session identifier
            
        Returns:
            AddResult with facts, unified_graph, and timing metrics
        """
        total_start = time.time()
        timing = TimingMetrics()
        result = AddResult()
        
        # Issue #2: Validate messages before processing
        valid_messages = []
        for i, msg in enumerate(messages):
            if not isinstance(msg, dict):
                logger.warning(f"Skipping message {i}: not a dict")
                continue
            if not msg.get("content"):
                logger.warning(f"Skipping message {i}: missing or empty content")
                continue
            if not msg.get("role"):
                logger.warning(f"Skipping message {i}: missing role")
                continue
            valid_messages.append(msg)
        
        if not valid_messages:
            logger.error("No valid messages to process")
            return result
        
        # =========================================================================
        # UNIFIED EXTRACTION (Issue #19 - single-pass, 52x token reduction)
        # Issue #105: Per-user async lock prevents concurrent add() lost updates
        # Issue #118: ExtractionCache wired to unified pipeline
        # =========================================================================
        user_lock = self._get_user_lock(user_id)
        async with user_lock:
            logger.info("Using unified extractor (single-pass)")
            unified_start = time.time()
            
            # Issue #118: Check extraction cache before LLM call
            cache_hit_type = "miss"  # Track for logging: "full", "partial", "miss"
            cached_state = None
            new_messages = valid_messages  # Default: process all messages
            
            if self._enable_extraction_cache and self._extraction_cache:
                try:
                    cached_state, new_messages = self._extraction_cache.get_state(
                        user_id, valid_messages
                    )
                except Exception as e:
                    logger.warning(f"Extraction cache lookup failed for {user_id}: {e}")
                    cached_state = None
                    new_messages = valid_messages
            
            # Issue #118: Determine extraction strategy based on cache result
            if cached_state and not new_messages:
                # FULL CACHE HIT — skip LLM entirely
                cache_hit_type = "full"
                logger.info(f"Cache FULL HIT for {user_id}: skipping LLM extraction")
                
                if cached_state.unified_graph_data:
                    unified_graph = UnifiedGraph.from_dict(cached_state.unified_graph_data)
                else:
                    # Legacy cache entry without unified_graph_data — fall through to extraction
                    logger.warning(f"Cache hit but no unified_graph_data for {user_id}, extracting")
                    cache_hit_type = "miss"
                    new_messages = valid_messages
                    
            if cache_hit_type != "full":
                # PARTIAL HIT or MISS — need LLM extraction
                # Issue #73: Ensure graph is loaded from storage if exists
                existing_graph = self._ensure_graph_loaded(user_id)
                
                # Issue #118: On partial hit, use cached graph as base
                if cached_state and cached_state.unified_graph_data and new_messages:
                    cache_hit_type = "partial"
                    logger.info(
                        f"Cache PARTIAL HIT for {user_id}: "
                        f"{cached_state.messages_processed} cached, "
                        f"{len(new_messages)} new messages to extract"
                    )
                    # Use cached graph as the base for delta extraction
                    existing_graph = UnifiedGraph.from_dict(cached_state.unified_graph_data)
                
                # Issue #104: Wrap LLM extraction in error handling
                try:
                    if existing_graph:
                        # Delta extraction - only process new information
                        logger.debug(f"Using delta extraction ({len(new_messages)} messages)")
                        unified_graph = await self._unified_extractor.extract_delta(
                            new_messages, existing_graph
                        )
                    else:
                        # Full extraction for new user
                        unified_graph = await self._unified_extractor.extract(new_messages)
                except Exception as e:
                    logger.error(f"LLM extraction failed for user {user_id}: {e}")
                    # Graceful degradation: return existing/cached graph or empty graph
                    if existing_graph:
                        unified_graph = existing_graph
                    elif cached_state and cached_state.unified_graph_data:
                        unified_graph = UnifiedGraph.from_dict(cached_state.unified_graph_data)
                    else:
                        unified_graph = UnifiedGraph()
            
            # Store graph for user (in memory)
            self._user_graphs[user_id] = unified_graph
            result.unified_graph = unified_graph
            
            # Issue #125: Proactive summarize-compact (periodic or over-budget)
            try:
                turn_count = self._user_turn_counts.get(user_id, 0) + len(messages)
                self._user_turn_counts[user_id] = turn_count
                
                do_compact = False
                trigger = "manual"
                
                # Over budget → always compact
                if len(unified_graph.nodes) > self._max_graph_nodes:
                    do_compact = True
                    trigger = "max_nodes"
                # Periodic proactive compaction
                elif (self._compaction_interval > 0
                      and turn_count % self._compaction_interval == 0
                      and len(unified_graph.nodes) > 10):
                    do_compact = True
                    trigger = "periodic"
                
                if do_compact:
                    event = unified_graph.summarize_compact(
                        policy=self._compaction_policy,
                        max_nodes=self._max_graph_nodes,
                        trigger=trigger,
                    )
                    self._compaction_events.append(event)
                    # Cap events list at 50
                    if len(self._compaction_events) > 50:
                        self._compaction_events = self._compaction_events[-50:]
                    if event.nodes_reduced > 0:
                        logger.info(
                            "Compacted graph for %s: %d→%d nodes (%d summaries, trigger=%s)",
                            user_id, event.nodes_before, event.nodes_after,
                            event.summary_nodes_created, trigger,
                        )
            except Exception as e:
                logger.warning(f"Graph compaction failed for {user_id}: {e}")
            
            # Issue #25: Persist to disk
            if self._enable_persistence and self._storage:
                try:
                    self._storage.save_unified_graph(user_id, unified_graph)
                    logger.debug(f"Saved unified graph for {user_id} to storage")
                except Exception as e:
                    logger.error(f"Failed to persist graph for {user_id}: {e}")
            
            # Issue #138: Store raw text chunks alongside graph (hybrid retrieval)
            if self._enable_chunk_store:
                try:
                    chunk_store = self._ensure_chunk_store(user_id)
                    raw_text = "\n".join(
                        f"{m.get('role', 'user')}: {m['content']}"
                        for m in valid_messages if m.get("content")
                    )
                    turn_count = self._user_turn_counts.get(user_id, 0)
                    n_added = chunk_store.add(
                        text=raw_text,
                        turn_ref=f"turn_{turn_count}",
                        metadata={"session_id": session_id or ""},
                    )
                    logger.debug(
                        f"ChunkStore: added {n_added} chunks for {user_id} "
                        f"(total={len(chunk_store)})"
                    )
                    # Persist chunk store
                    if self._enable_persistence and self._storage:
                        try:
                            self._save_chunk_store(user_id, chunk_store)
                        except Exception as e:
                            logger.warning(f"Failed to persist chunk store for {user_id}: {e}")
                except Exception as e:
                    logger.warning(f"ChunkStore add failed for {user_id}: {e}")
            
            # Issue #118: Update extraction cache after successful extraction
            if self._enable_extraction_cache and self._extraction_cache:
                try:
                    extraction_time_ms = int((time.time() - unified_start) * 1000)
                    self._extraction_cache.update_unified_state(
                        user_id=user_id,
                        messages=valid_messages,
                        unified_graph_data=unified_graph.to_dict(),
                        extraction_time_ms=extraction_time_ms
                    )
                    logger.debug(
                        f"Cache updated for {user_id} "
                        f"(hit_type={cache_hit_type}, messages={len(valid_messages)})"
                    )
                except Exception as e:
                    logger.warning(f"Failed to update extraction cache for {user_id}: {e}")
        
        # Map to compatible structures for backward compatibility
        # Convert graph nodes to memory entries (facts)
        result.facts = [
            MemoryEntry(
                id=node.id,
                content=f"{node.type.name}: {node.label}",
                metadata={"type": node.type.value, **node.attrs}
            )
            for node in unified_graph.nodes.values()
        ]
        
        # Extract decisions count from graph
        graph_summary = unified_graph.summary()
        
        timing.total_ms = int((time.time() - unified_start) * 1000)
        timing.facts_ms = timing.total_ms  # All in one pass
        timing.intents_ms = 0
        timing.decisions_ms = 0
        result.timing = timing
        
        logger.info(
            f"Unified extraction complete: {graph_summary['total_nodes']} nodes, "
            f"{graph_summary['total_edges']} edges, {graph_summary['decisions']} decisions "
            f"in {timing.total_ms}ms (cache={cache_hit_type})"
        )
        
        return result

    async def search(
        self,
        query: str,
        user_id: str,
        limit: int = 10,
        include_goals: bool = True,
        include_decisions: bool = True,
        retrieval_config: Optional[RetrievalConfig] = None,
    ) -> SearchResult:
        """
        Search with goal-aware retrieval.
        
        This enhances Mem0's search with:
        1. Goal-aware re-ranking (MCAL Pillar 3)
        2. Relevant decision attachment
        3. Active goal context
        
        When using unified extractor mode, searches the unified graph
        directly instead of the Mem0 backend.
        
        Args:
            query: Search query
            user_id: User identifier
            limit: Maximum results
            include_goals: Include active goals in result
            include_decisions: Include relevant decisions in result
            retrieval_config: Optional retrieval configuration
            
        Returns:
            SearchResult with memories, goals, decisions, and assembled context
        """
        result = SearchResult()
        
        # Issue #104: Input validation
        if not query or not query.strip():
            logger.warning("search() called with empty query")
            return result
        if not user_id or not user_id.strip():
            logger.warning("search() called with empty user_id")
            return result
        
        # Issue #105: Per-user async lock prevents stale reads during concurrent add()
        user_lock = self._get_user_lock(user_id)
        async with user_lock:
            # Issue #73: Ensure graph is loaded from storage if exists
            unified_graph = self._ensure_graph_loaded(user_id)
        
        if unified_graph:
            # Issue #104: Wrap graph search in error handling (embedding/FAISS failures)
            try:
                # Search unified graph directly using vector search (Issue #78 Subtask 1)
                logger.debug("Searching unified graph with semantic search...")
                graph_results = unified_graph.search(query, k=limit * 2)
                
                # Convert graph results to MemoryEntry format
                # semantic_search returns list[tuple[GraphNode, float]]
                # Issue #109: Include key attributes in content for numeric preservation
                result.memories = [
                    MemoryEntry(
                        id=node.id,
                        content=self._format_node_content(node),
                        metadata={"type": node.type.name.lower(), **node.attrs},
                        score=score
                    )
                    for node, score in graph_results
                ]
            except Exception as e:
                logger.error(f"Graph search failed for user {user_id}: {e}")
                result.memories = []
            
            # Issue #138: Hybrid retrieval — search raw text chunks
            if self._enable_chunk_store:
                try:
                    chunk_store = self._ensure_chunk_store(user_id)
                    if len(chunk_store) > 0:
                        chunk_results = chunk_store.search(query, k=min(5, limit))
                        # Deduplicate: skip chunks whose text is already in memories
                        existing_contents = {m.content.lower() for m in result.memories}
                        for chunk, score in chunk_results:
                            if chunk.text.lower()[:80] not in " ".join(existing_contents):
                                result.memories.append(
                                    MemoryEntry(
                                        id=chunk.id,
                                        content=chunk.text,
                                        metadata={"type": "chunk", "turn_ref": chunk.turn_ref},
                                        score=score * 0.9,  # Slight discount vs graph results
                                    )
                                )
                        logger.debug(
                            f"Hybrid search: {len(chunk_results)} chunk results merged"
                        )
                except Exception as e:
                    logger.warning(f"ChunkStore search failed for {user_id}: {e}")
            
            # Get active goals from unified graph
            if include_goals:
                try:
                    active_goal_dicts = unified_graph.get_active_goals()
                    # Convert to IntentNode format for compatibility
                    result.active_goals = [
                        IntentNode(
                            id=g["id"],
                            type=self._goal_type_to_intent_type(g.get("goal_type", "goal")),
                            content=g["content"],
                            status=self._status_to_intent_status(g.get("status", "active"))
                        )
                        for g in active_goal_dicts
                    ]
                except Exception as e:
                    logger.error(f"Failed to get active goals for user {user_id}: {e}")
                    result.active_goals = []
                
                # Issue #110: Also fetch completed goals
                try:
                    completed_goal_dicts = unified_graph.get_completed_goals()
                    result.completed_goals = [
                        IntentNode(
                            id=g["id"],
                            type=self._goal_type_to_intent_type(g.get("goal_type", "goal")),
                            content=g["content"],
                            status=IntentStatus.COMPLETED
                        )
                        for g in completed_goal_dicts
                    ]
                except Exception as e:
                    logger.error(f"Failed to get completed goals for user {user_id}: {e}")
                    result.completed_goals = []
            
            # Get decisions from unified graph
            if include_decisions:
                try:
                    all_decisions = unified_graph.get_all_decisions_with_detail()
                    # Filter to relevant decisions (keyword match - any query word)
                    query_words = set(query.lower().split())
                    
                    def decision_matches(d: dict) -> bool:
                        """Check if decision matches any query word."""
                        decision_lower = d["decision"].lower()
                        # Check if any query word appears in decision
                        if any(w in decision_lower for w in query_words):
                            return True
                        # Check rationale
                        # Ensure rationale is a string before .lower()
                        rationale = d.get("rationale", "") or ""
                        if not isinstance(rationale, str):
                            rationale = str(rationale)
                        if any(w in rationale.lower() for w in query_words):
                            return True
                        # Check reasons
                        for r in d.get("reasons", []):
                            claim = (r.get("claim", "") or "").lower()
                            if any(w in claim for w in query_words):
                                return True
                        return False
                    
                    result.relevant_decisions = [
                        DecisionTrail(
                            id=d["id"],
                            decision=d["decision"],
                            rationale=d.get("rationale", ""),
                            alternatives=[],  # Could expand if needed
                            context=d.get("context", "")
                        )
                        for d in all_decisions
                        if decision_matches(d)
                    ][:5]  # Limit to top 5 relevant decisions
                except Exception as e:
                    logger.error(f"Failed to get decisions for user {user_id}: {e}")
                    result.relevant_decisions = []
            
            # Issue #111: Get structural edges (dependencies, blockers, conflicts)
            try:
                result.structural_edges = unified_graph.get_structural_edges()
            except Exception as e:
                logger.error(f"Failed to get structural edges for user {user_id}: {e}")
                result.structural_edges = []
            
            # Issue #117: Get background entities (PERSON, THING, CONCEPT)
            # These factual nodes form the foundation that goals/decisions reference
            try:
                entities = unified_graph.get_entities()
                if entities:
                    scores = unified_graph.importance_scores()
                    # Sort by importance, pinned entities first
                    sorted_entities = sorted(
                        entities,
                        key=lambda n: (
                            1 if n.attrs.get("pinned") else 0,
                            scores.get(n.id, 0.0)
                        ),
                        reverse=True,
                    )
                    # Include top entities (budget: ~15% of limit)
                    entity_limit = max(3, limit // 2)
                    result.background_entities = [
                        MemoryEntry(
                            id=n.id,
                            content=self._format_node_content(n),
                            metadata={"type": n.type.name.lower(), **n.attrs},
                            score=scores.get(n.id, 0.0),
                        )
                        for n in sorted_entities[:entity_limit]
                    ]
            except Exception as e:
                logger.error(f"Failed to get background entities for user {user_id}: {e}")
                result.background_entities = []

            # Issue #107: Get FACT nodes (quantitative data points)
            try:
                facts = unified_graph.get_facts()
                if facts:
                    scores = unified_graph.importance_scores()
                    sorted_facts = sorted(
                        facts,
                        key=lambda n: scores.get(n.id, 0.0),
                        reverse=True,
                    )
                    fact_limit = max(5, limit)
                    result.key_facts = [
                        MemoryEntry(
                            id=n.id,
                            content=self._format_node_content(n),
                            metadata={"type": "fact", **n.attrs},
                            score=scores.get(n.id, 0.0),
                        )
                        for n in sorted_facts[:fact_limit]
                    ]
            except Exception as e:
                logger.error(f"Failed to get facts for user {user_id}: {e}")
                result.key_facts = []
        else:
            # Issue #53: No fallback needed - MCAL is always standalone
            # If no unified graph, return empty results
            logger.debug("No unified graph found for user")
            result.memories = []
            result.active_goals = []
            result.completed_goals = []
            result.relevant_decisions = []
            result.structural_edges = []
        
        # Issue #113: Compute context coverage metrics
        try:
            result.coverage = self._compute_coverage(
                unified_graph=unified_graph,
                memories=result.memories,
                active_goals=result.active_goals,
                completed_goals=result.completed_goals,
                decisions=result.relevant_decisions,
                structural_edges=result.structural_edges,
                background_entities=result.background_entities,  # Issue #117
                key_facts=result.key_facts,  # Issue #107
            )
        except Exception as e:
            logger.error(f"Coverage computation failed for user {user_id}: {e}")
            result.coverage = ContextCoverage(is_sparse=True)
        
        # Assemble context
        try:
            result.context = self._context_assembler.assemble(
                retrieved=[],  # We'll pass memories differently
                active_goals=result.active_goals,
                decisions=result.relevant_decisions,
                include_goals=include_goals,
                include_decisions=include_decisions
            )
            
            # Add memories to context
            if result.memories:
                memory_lines = ["\n### RELEVANT MEMORIES ###"]
                for mem in result.memories:
                    score_str = f" (relevance: {mem.score:.2f})" if mem.score else ""
                    mem_type = mem.metadata.get("type", "memory")
                    if mem_type == "chunk":
                        memory_lines.append(f"- [context] {mem.content}{score_str}")
                    else:
                        memory_lines.append(f"- {mem.content}{score_str}")
                result.context += "\n".join(memory_lines)
        except Exception as e:
            logger.error(f"Context assembly failed for user {user_id}: {e}")
            result.context = ""
        
        return result
    
    def _compute_coverage(
        self,
        unified_graph: Optional[UnifiedGraph],
        memories: list[MemoryEntry],
        active_goals: list[IntentNode],
        completed_goals: list[IntentNode],
        decisions: list[DecisionTrail],
        structural_edges: list[dict],
        background_entities: Optional[list[MemoryEntry]] = None,  # Issue #117
        key_facts: Optional[list[MemoryEntry]] = None,  # Issue #107
    ) -> ContextCoverage:
        """Issue #113: Compute context coverage metrics for hallucination prevention.
        
        Compares what categories exist in the graph vs. what was retrieved,
        producing a coverage ratio and sparsity flag.
        """
        if not unified_graph:
            return ContextCoverage(is_sparse=True)
        
        # Get graph summary to know what exists
        graph_summary = unified_graph.summary()
        total_nodes = graph_summary.get("total_nodes", 0)
        
        if total_nodes == 0:
            return ContextCoverage(is_sparse=True)
        
        # Per-category counts in graph
        by_type = graph_summary.get("by_type", {})
        category_counts = {
            "goals": by_type.get("G", 0),
            "decisions": by_type.get("D", 0),
            "actions": by_type.get("A", 0),
            "entities": by_type.get("P", 0) + by_type.get("T", 0),
            "concepts": by_type.get("C", 0),
            "facts": by_type.get("F", 0),  # Issue #107
            "relationships": len(structural_edges),
        }
        
        # What categories have data in the graph
        categories_in_graph = [cat for cat, count in category_counts.items() if count > 0]
        
        # What categories were retrieved
        categories_retrieved = []
        if active_goals or completed_goals:
            categories_retrieved.append("goals")
        if decisions:
            categories_retrieved.append("decisions")
        if structural_edges:
            categories_retrieved.append("relationships")
        
        # Check memories for entity/concept/action types
        memory_types = set()
        for mem in memories:
            mem_type = mem.metadata.get("type", "") if mem.metadata else ""
            memory_types.add(mem_type)
        
        if memory_types & {"person", "thing"}:
            categories_retrieved.append("entities")
        if "concept" in memory_types:
            categories_retrieved.append("concepts")
        if "action" in memory_types:
            categories_retrieved.append("actions")
        
        # Issue #117: Background entities always contribute to coverage
        if background_entities:
            bg_types = set()
            for ent in background_entities:
                ent_type = ent.metadata.get("type", "") if ent.metadata else ""
                bg_types.add(ent_type)
            if bg_types & {"person", "thing"} and "entities" not in categories_retrieved:
                categories_retrieved.append("entities")
            if "concept" in bg_types and "concepts" not in categories_retrieved:
                categories_retrieved.append("concepts")

        # Issue #107: Key facts contribute to coverage
        if key_facts and "facts" not in categories_retrieved:
            categories_retrieved.append("facts")
        
        # Categories that exist in graph but weren't retrieved
        categories_missing = [c for c in categories_in_graph if c not in categories_retrieved]
        
        # Compute coverage ratio: retrieved nodes / total nodes
        bg_count = len(background_entities) if background_entities else 0
        facts_count = len(key_facts) if key_facts else 0
        nodes_retrieved = (
            len(memories)
            + len(active_goals)
            + len(completed_goals)
            + len(decisions)
            + bg_count
            + facts_count  # Issue #107
        )
        coverage_ratio = min(nodes_retrieved / total_nodes, 1.0) if total_nodes > 0 else 0.0
        
        # Sparse if coverage < 15% or fewer than 3 nodes retrieved from a graph with 10+
        is_sparse = (
            coverage_ratio < 0.15
            or (nodes_retrieved < 3 and total_nodes >= 10)
            or (len(categories_missing) > len(categories_retrieved) and total_nodes >= 5)
        )
        
        return ContextCoverage(
            coverage_ratio=round(coverage_ratio, 3),
            nodes_retrieved=nodes_retrieved,
            nodes_total=total_nodes,
            categories_in_graph=categories_in_graph,
            categories_retrieved=categories_retrieved,
            categories_missing=categories_missing,
            is_sparse=is_sparse,
            category_counts=category_counts,
        )
    
    def _build_coverage_warning(self, coverage: ContextCoverage) -> str:
        """Issue #113: Build a context sufficiency warning for sparse context.
        
        Returns a warning string to prepend to context when coverage is low,
        helping the LLM avoid hallucination.
        """
        if not coverage or not coverage.is_sparse:
            return ""
        
        parts = ["[CONTEXT NOTE: Limited information available for this query."]
        
        if coverage.categories_retrieved:
            parts.append(f"Categories with data: {', '.join(coverage.categories_retrieved)}.")
        
        if coverage.categories_missing:
            parts.append(f"Categories without data: {', '.join(coverage.categories_missing)}.")
        
        parts.append(
            f"Coverage: {coverage.nodes_retrieved}/{coverage.nodes_total} nodes "
            f"({coverage.coverage_ratio:.0%})."
        )
        parts.append("Do NOT fabricate details not present in the context below.]")
        
        return " ".join(parts)
    
    async def _search_and_assemble(
        self,
        query: str,
        user_id: str,
        max_tokens: int = 4000,
        include_goals: bool = True,
        include_decisions: bool = True,
        include_reasoning: bool = True,
    ) -> tuple[str, "SearchResult | None"]:
        """Issue #122: Single-search context assembly returning both text and SearchResult.
        
        Internal method that performs exactly ONE search, assembles
        the context string with token budget enforcement (Issue #121),
        and returns both the text and the raw SearchResult so callers
        can access coverage metadata without a redundant search.
        
        Returns:
            (context_text, search_result) — search_result is None on error
        """
        # Issue #104: Input validation
        if not query or not query.strip():
            logger.warning("get_context() called with empty query")
            return "", None
        if not user_id or not user_id.strip():
            logger.warning("get_context() called with empty user_id")
            return "", None
        
        # Search with all enhancements — exactly ONE search call
        try:
            search_result = await self.search(
                query=query,
                user_id=user_id,
                include_goals=include_goals,
                include_decisions=include_decisions,
            )
        except Exception as e:
            logger.error(f"get_context() search failed for user {user_id}: {e}")
            return "", None
        
        # Issue #121: Token-budget-enforced context assembly
        # Sections are added in priority order; each section is only included
        # if the token budget has room.  The last fitting section may be
        # truncated to stay within budget.
        budget = TokenBudget(max_tokens=max_tokens, model=self._tokenizer_model)

        # Priority 1: Active goals
        if include_goals and search_result.active_goals:
            goals_text = "### ACTIVE GOALS ###\n"
            for goal in search_result.active_goals:
                goals_text += f"- {goal.content} [ACTIVE]\n"
            budget.add_section(goals_text)
        
        # Priority 2: Completed goals (Issue #110)
        if include_goals and search_result.completed_goals:
            completed_text = "### COMPLETED GOALS ###\n"
            for goal in search_result.completed_goals:
                completed_text += f"- {goal.content} [COMPLETED]\n"
            budget.add_section(completed_text)
        
        # Priority 3: Key decisions with reasoning
        if include_decisions and search_result.relevant_decisions:
            decisions_text = "### KEY DECISIONS ###\n"
            for decision in search_result.relevant_decisions[:5]:
                decisions_text += f"DECISION: {decision.decision}\n"
                if include_reasoning:
                    decisions_text += f"  Rationale: {decision.rationale}\n"
                    if decision.alternatives:
                        alts = ", ".join(a.option for a in decision.alternatives[:3])
                        decisions_text += f"  Alternatives considered: {alts}\n"
                decisions_text += "\n"
            budget.add_section(decisions_text)

        # Priority 4: Key facts (Issue #107 — quantitative data points)
        if search_result.key_facts:
            facts_text = "### KEY FACTS ###\n"
            for fact in search_result.key_facts:
                facts_text += f"- {fact.content}\n"
            budget.add_section(facts_text)
        
        # Priority 5: Background context (Issue #117 — PERSON, THING, CONCEPT)
        if search_result.background_entities:
            bg_text = "### BACKGROUND CONTEXT ###\n"
            for ent in search_result.background_entities:
                ent_type = (ent.metadata or {}).get("type", "entity").upper()
                bg_text += f"- [{ent_type}] {ent.content}\n"
            budget.add_section(bg_text)
        
        # Priority 6: Memories (excluding entities/facts already shown)
        if search_result.memories:
            bg_ids = {e.id for e in search_result.background_entities} if search_result.background_entities else set()
            fact_ids = {f.id for f in search_result.key_facts} if search_result.key_facts else set()
            exclude_ids = bg_ids | fact_ids
            filtered_memories = [m for m in search_result.memories[:10] if m.id not in exclude_ids]
            if filtered_memories:
                memories_text = "### RELEVANT CONTEXT ###\n"
                for mem in filtered_memories:
                    memories_text += f"- {mem.content}\n"
                budget.add_section(memories_text)
        
        # Priority 7: Structural relationships (Issue #111)
        if search_result.structural_edges:
            dep_lines = []
            blocker_lines = []
            conflict_lines = []
            for edge in search_result.structural_edges:
                if edge["rel"] == "depends_on":
                    dep_lines.append(f"- {edge['src_label']} depends on {edge['dst_label']}")
                elif edge["rel"] == "blocks":
                    blocker_lines.append(f"- {edge['src_label']} blocks {edge['dst_label']}")
                elif edge["rel"] == "conflicts":
                    conflict_lines.append(f"- {edge['src_label']} conflicts with {edge['dst_label']}")
                elif edge["rel"] == "supersedes":
                    dep_lines.append(f"- {edge['src_label']} supersedes {edge['dst_label']}")
            
            rel_parts = []
            if dep_lines:
                rel_parts.append("Dependencies:\n" + "\n".join(dep_lines[:10]))
            if blocker_lines:
                rel_parts.append("Blockers:\n" + "\n".join(blocker_lines[:5]))
            if conflict_lines:
                rel_parts.append("Conflicts:\n" + "\n".join(conflict_lines[:5]))
            
            if rel_parts:
                budget.add_section("### DEPENDENCIES & RELATIONSHIPS ###\n" + "\n".join(rel_parts))
        
        context_text = budget.build()
        
        # Issue #113: Prepend coverage warning when context is sparse
        if search_result.coverage and search_result.coverage.is_sparse:
            warning = self._build_coverage_warning(search_result.coverage)
            if warning:
                context_text = warning + "\n\n" + context_text if context_text else warning
        
        logger.debug(
            f"get_context() for user {user_id}: "
            f"{budget.tokens_used}/{max_tokens} tokens used"
        )
        return context_text, search_result

    async def get_context(
        self,
        query: str,
        user_id: str,
        max_tokens: int = 4000,
        include_goals: bool = True,
        include_decisions: bool = True,
        include_reasoning: bool = True,
    ) -> str:
        """
        Get assembled context for LLM prompt.
        
        Convenience method that searches and formats context.
        
        Args:
            query: The query/task at hand
            user_id: User identifier
            max_tokens: Maximum tokens for context
            include_goals: Include active goals
            include_decisions: Include relevant decisions
            include_reasoning: Include decision rationale
            
        Returns:
            Formatted context string ready for LLM prompt
        """
        text, _ = await self._search_and_assemble(
            query=query,
            user_id=user_id,
            max_tokens=max_tokens,
            include_goals=include_goals,
            include_decisions=include_decisions,
            include_reasoning=include_reasoning,
        )
        return text
    
    async def get_context_with_metadata(
        self,
        query: str,
        user_id: str,
        max_tokens: int = 4000,
        include_goals: bool = True,
        include_decisions: bool = True,
        include_reasoning: bool = True,
    ) -> "ContextResult":
        """Issue #113: Get assembled context with coverage metadata.
        
        Like get_context() but returns a ContextResult with both the context
        text and coverage metrics, enabling agents to adapt behavior based
        on context completeness.
        
        Uses a single search call (Issue #122: eliminates redundant double-search).
        
        Args:
            query: The query/task at hand
            user_id: User identifier
            max_tokens: Maximum tokens for context
            include_goals: Include active goals
            include_decisions: Include relevant decisions
            include_reasoning: Include decision rationale
            
        Returns:
            ContextResult with .text (str) and .coverage (ContextCoverage)
        """
        text, search_result = await self._search_and_assemble(
            query=query,
            user_id=user_id,
            max_tokens=max_tokens,
            include_goals=include_goals,
            include_decisions=include_decisions,
            include_reasoning=include_reasoning,
        )
        
        # Coverage is already available from the single search — no re-search needed
        if search_result and search_result.coverage:
            coverage = search_result.coverage
        else:
            coverage = ContextCoverage(is_sparse=True)
        
        return ContextResult(text=text, coverage=coverage)

    # =========================================================================
    # Issue #112: Full Context API for Comprehensive/Summary Queries
    # =========================================================================

    async def get_full_context(
        self,
        user_id: str,
        max_tokens: int = 6000,
        include_completed_goals: bool = True,
        include_dependencies: bool = True,
        include_superseded: bool = False,
        include_actions: bool = True,
    ) -> str:
        """Issue #112: Get a comprehensive context string from the entire graph.

        Unlike ``get_context()`` which performs semantic search for a specific
        query, this method serializes the full knowledge graph in a structured
        format.  It is designed for summary, overview, and comprehensive
        queries where the agent needs access to *all* accumulated knowledge.

        The output is organized into priority-ordered sections:
            1. Active goals (highest priority)
            2. Completed goals (if ``include_completed_goals``)
            3. Key decisions with rationale
            4. Key facts
            5. Entities (persons, things, concepts)
            6. Actions / next steps
            7. Dependencies & relationships

        Args:
            user_id: User identifier
            max_tokens: Token budget enforced via tiktoken (Issue #121)
            include_completed_goals: Include completed/abandoned goals
            include_dependencies: Include structural edges
            include_superseded: Include superseded decisions
            include_actions: Include action/next-step nodes

        Returns:
            Formatted context string covering the full graph, or ``""`` if no
            graph exists for this user.
        """
        if not user_id or not user_id.strip():
            logger.warning("get_full_context() called with empty user_id")
            return ""

        unified_graph = self._ensure_graph_loaded(user_id)
        if not unified_graph or not unified_graph.nodes:
            return ""

        # Issue #121: Use tiktoken-based token budget instead of char estimate
        budget = TokenBudget(max_tokens=max_tokens, model=self._tokenizer_model)

        # --- 1. Active goals ---
        active_goals = unified_graph.get_active_goals()
        if active_goals:
            lines = ["### ACTIVE GOALS ###"]
            for g in active_goals:
                node = unified_graph.nodes.get(g["id"])
                label = self._format_node_content(node) if node else g["content"]
                conf = g.get("confidence", 0.5)
                lines.append(f"- {label} [ACTIVE, confidence={conf:.1f}]")
            budget.add_section("\n".join(lines))

        # --- 2. Completed goals ---
        if include_completed_goals:
            completed_goals = unified_graph.get_completed_goals()
            if completed_goals:
                lines = ["### COMPLETED GOALS ###"]
                for g in completed_goals:
                    node = unified_graph.nodes.get(g["id"])
                    label = self._format_node_content(node) if node else g["content"]
                    lines.append(f"- {label} [COMPLETED]")
                budget.add_section("\n".join(lines))

            # Abandoned / blocked goals
            goals_by_status = unified_graph.get_goals_by_status()
            for status in ("abandoned", "blocked"):
                status_goals = goals_by_status.get(status, [])
                if status_goals and not budget.is_exhausted:
                    lines = [f"### {status.upper()} GOALS ###"]
                    for g in status_goals:
                        node = unified_graph.nodes.get(g["id"])
                        label = self._format_node_content(node) if node else g["content"]
                        reason = ""
                        if node and status == "abandoned":
                            reason = node.attrs.get("abandoned_reason", "")
                        if reason:
                            lines.append(f"- {label} [{status.upper()}] (reason: {reason})")
                        else:
                            lines.append(f"- {label} [{status.upper()}]")
                    budget.add_section("\n".join(lines))

        # --- 3. Key decisions ---
        if not budget.is_exhausted:
            decisions = unified_graph.get_all_decisions_with_detail()
            if decisions:
                # Filter out superseded if requested
                if not include_superseded:
                    superseded_ids = set()
                    for edge in unified_graph.edges:
                        if edge.rel == EdgeType.SUPERSEDES:
                            superseded_ids.add(edge.dst)
                    decisions = [d for d in decisions if d["id"] not in superseded_ids]

                lines = ["### KEY DECISIONS ###"]
                for d in decisions:
                    lines.append(f"DECISION: {d['decision']}")
                    if d.get("rationale"):
                        lines.append(f"  Rationale: {d['rationale']}")
                    if d.get("confidence"):
                        lines.append(f"  Confidence: {d['confidence']}")
                    if d.get("trigger"):
                        lines.append(f"  Trigger: {d['trigger']}")
                    if d.get("evidence"):
                        ev_list = d["evidence"]
                        if isinstance(ev_list, list):
                            lines.append(f"  Evidence: {'; '.join(str(e) for e in ev_list)}")
                        else:
                            lines.append(f"  Evidence: {ev_list}")
                    if d.get("trade_offs") and isinstance(d["trade_offs"], list):
                        for tf in d["trade_offs"][:3]:
                            if isinstance(tf, dict):
                                gained = tf.get("gained", "")
                                sacrificed = tf.get("sacrificed", "")
                                if gained or sacrificed:
                                    lines.append(f"  Trade-off: gained={gained}, sacrificed={sacrificed}")
                            elif isinstance(tf, str) and tf:
                                lines.append(f"  Trade-off: {tf}")
                    if d.get("alternatives") and isinstance(d["alternatives"], list):
                        alt_labels = [
                            a.get("option", a.get("label", "")) if isinstance(a, dict) else str(a)
                            for a in d["alternatives"][:5]
                        ]
                        if any(alt_labels):
                            lines.append(f"  Alternatives considered: {', '.join(a for a in alt_labels if a)}")
                    lines.append("")
                budget.add_section("\n".join(lines))

        # --- 4. Key facts (Issue #107: quantitative data points) ---
        if not budget.is_exhausted:
            facts = unified_graph.get_facts()
            if facts:
                lines = ["### KEY FACTS ###"]
                for fact in facts:
                    content = self._format_node_content(fact)
                    lines.append(f"- {content}")
                budget.add_section("\n".join(lines))

        # --- 5. Entities (persons, things, concepts) ---
        if not budget.is_exhausted:
            entities = unified_graph.get_entities()
            if entities:
                person_lines = []
                thing_lines = []
                concept_lines = []
                for ent in entities:
                    content = self._format_node_content(ent)
                    if ent.type == NodeType.PERSON:
                        person_lines.append(f"- [PERSON] {content}")
                    elif ent.type == NodeType.THING:
                        thing_lines.append(f"- [THING] {content}")
                    elif ent.type == NodeType.CONCEPT:
                        concept_lines.append(f"- [CONCEPT] {content}")

                lines = ["### ENTITIES ###"]
                lines.extend(person_lines)
                lines.extend(thing_lines)
                lines.extend(concept_lines)
                budget.add_section("\n".join(lines))

        # --- 6. Actions / next steps ---
        if include_actions and not budget.is_exhausted:
            actions = unified_graph.get_actions()
            if actions:
                lines = ["### ACTIONS / NEXT STEPS ###"]
                for act in actions:
                    content = self._format_node_content(act)
                    lines.append(f"- {content}")
                budget.add_section("\n".join(lines))

        # --- 7. Dependencies & relationships ---
        if include_dependencies and not budget.is_exhausted:
            structural = unified_graph.get_structural_edges()
            if structural:
                dep_lines = []
                blocker_lines = []
                conflict_lines = []
                supersede_lines = []
                for edge in structural:
                    rel = edge["rel"]
                    src = edge["src_label"]
                    dst = edge["dst_label"]
                    if rel == "depends_on":
                        dep_lines.append(f"- {src} depends on {dst}")
                    elif rel == "blocks":
                        blocker_lines.append(f"- {src} blocks {dst}")
                    elif rel == "conflicts":
                        conflict_lines.append(f"- {src} conflicts with {dst}")
                    elif rel == "supersedes":
                        supersede_lines.append(f"- {src} supersedes {dst}")

                parts = []
                if dep_lines:
                    parts.append("Dependencies:\n" + "\n".join(dep_lines))
                if blocker_lines:
                    parts.append("Blockers:\n" + "\n".join(blocker_lines))
                if conflict_lines:
                    parts.append("Conflicts:\n" + "\n".join(conflict_lines))
                if supersede_lines:
                    parts.append("Superseded:\n" + "\n".join(supersede_lines))
                if parts:
                    budget.add_section("### DEPENDENCIES & RELATIONSHIPS ###\n" + "\n".join(parts))

        logger.debug(
            f"get_full_context() for user {user_id}: "
            f"{budget.tokens_used}/{max_tokens} tokens used"
        )
        return budget.build()

    @staticmethod
    def _is_comprehensive_query(query: str) -> bool:
        """Issue #112: Heuristic to detect summary/overview queries.

        Returns ``True`` when ``query`` looks like a request for comprehensive
        information rather than a narrowly-focused factual question.

        Signals checked:
        - Trigger words: "all", "summary", "everything", "overview",
          "complete", "describe all", "list all", "full", "entire"
        - Question patterns: "what do we know", "what has been decided"
        """
        if not query:
            return False
        q = query.lower().strip()

        _COMPREHENSIVE_PHRASES = [
            "summary", "summarize", "summarise",
            "everything", "overview", "complete picture",
            "describe all", "list all", "show all",
            "full context", "full picture", "entire",
            "all decisions", "all goals", "all services",
            "what do we know", "what has been decided",
            "what happened", "catch me up", "bring me up to speed",
            "recap", "walk me through",
        ]
        for phrase in _COMPREHENSIVE_PHRASES:
            if phrase in q:
                return True

        # Pattern: starts with "give me a" + broad noun
        if q.startswith(("give me a ", "give me the ", "provide a ", "provide the ")):
            for word in ("summary", "overview", "recap", "rundown", "breakdown"):
                if word in q:
                    return True

        return False

    async def get_smart_context(
        self,
        query: str,
        user_id: str,
        max_tokens: int = 4000,
        mode: str = "auto",
        include_goals: bool = True,
        include_decisions: bool = True,
        include_reasoning: bool = True,
    ) -> str:
        """Issue #112: Auto-selects focused or full context based on query type.

        Modes:
        - ``"auto"`` (default): Uses ``_is_comprehensive_query()`` heuristic to
          select between focused and full.  Summary-type queries get full graph
          context; specific questions get semantic-search context.
        - ``"focused"``: Always uses ``get_context()`` (semantic search).
        - ``"full"``: Always uses ``get_full_context()`` (entire graph).
        - ``"hybrid"``: Starts with semantic search results, then fills
          remaining budget with full-graph sections not already covered.

        Args:
            query: The user query
            user_id: User identifier
            max_tokens: Token budget
            mode: One of ``"auto"``, ``"focused"``, ``"full"``, ``"hybrid"``
            include_goals: Include goals (focused/hybrid modes)
            include_decisions: Include decisions (focused/hybrid modes)
            include_reasoning: Include rationale (focused/hybrid modes)

        Returns:
            Formatted context string
        """
        if not query or not query.strip() or not user_id or not user_id.strip():
            return ""

        mode = mode.lower().strip()

        if mode == "focused":
            return await self.get_context(
                query=query, user_id=user_id, max_tokens=max_tokens,
                include_goals=include_goals, include_decisions=include_decisions,
                include_reasoning=include_reasoning,
            )

        if mode == "full":
            return await self.get_full_context(
                user_id=user_id, max_tokens=max_tokens,
            )

        if mode == "auto":
            if self._is_comprehensive_query(query):
                return await self.get_full_context(
                    user_id=user_id, max_tokens=max_tokens,
                )
            return await self.get_context(
                query=query, user_id=user_id, max_tokens=max_tokens,
                include_goals=include_goals, include_decisions=include_decisions,
                include_reasoning=include_reasoning,
            )

        if mode == "hybrid":
            # Step 1: Get focused context from semantic search
            focused = await self.get_context(
                query=query, user_id=user_id,
                max_tokens=max_tokens // 2,
                include_goals=include_goals, include_decisions=include_decisions,
                include_reasoning=include_reasoning,
            )
            # Step 2: Get full graph context with remaining budget
            remaining_budget = max_tokens - (len(focused) // 4) if focused else max_tokens
            remaining_budget = max(remaining_budget, max_tokens // 4)
            full = await self.get_full_context(
                user_id=user_id, max_tokens=remaining_budget,
            )
            # Step 3: Merge — focused first, then non-overlapping full sections
            if not focused:
                return full
            if not full:
                return focused
            # De-duplicate: skip full sections whose headers already appear in focused
            focused_lower = focused.lower()
            extra_sections = []
            for section in full.split("\n\n"):
                header = section.split("\n")[0].strip().lower() if section.strip() else ""
                if header and header not in focused_lower:
                    extra_sections.append(section)
            if extra_sections:
                return focused + "\n\n" + "\n\n".join(extra_sections)
            return focused

        # Unknown mode → fall back to auto
        logger.warning(f"Unknown context mode '{mode}', falling back to auto")
        return await self.get_smart_context(
            query=query, user_id=user_id, max_tokens=max_tokens,
            mode="auto", include_goals=include_goals,
            include_decisions=include_decisions, include_reasoning=include_reasoning,
        )

    def get_active_goals(self, user_id: str) -> list[IntentNode]:
        """Get active goals for a user from the unified graph."""
        unified_graph = self._ensure_graph_loaded(user_id)
        if unified_graph:
            active_goal_dicts = unified_graph.get_active_goals()
            return [
                IntentNode(
                    id=g["id"],
                    type=self._goal_type_to_intent_type(g.get("goal_type", "goal")),
                    content=g["content"],
                    status=self._status_to_intent_status(g.get("status", "active"))
                )
                for g in active_goal_dicts
            ]
        return []
    
    def get_intent_graph(self, user_id: str) -> Optional[UnifiedGraph]:
        """Get unified graph for a user (replaces legacy IntentGraph)."""
        return self._ensure_graph_loaded(user_id)
    
    def get_decisions(
        self,
        user_id: str,
        goal_id: Optional[str] = None,
        include_invalidated: bool = False
    ) -> list[DecisionTrail]:
        """Get decisions from the unified graph, optionally filtered by goal."""
        unified_graph = self._ensure_graph_loaded(user_id)
        if not unified_graph:
            return []
        
        all_decisions = unified_graph.get_all_decisions_with_detail()
        
        results = []
        for d in all_decisions:
            # Filter by goal if specified
            if goal_id:
                related = d.get("related_goals", [])
                if goal_id not in related:
                    continue
            
            results.append(DecisionTrail(
                id=d["id"],
                decision=d["decision"],
                rationale=d.get("rationale", ""),
                alternatives=[],
                context=d.get("context", "")
            ))
        
        return results
    
    # Issue #117: Entity pinning for always-include background facts
    def pin_entity(self, user_id: str, node_id: str) -> bool:
        """Pin an entity node so it always appears in assembled context.
        
        Pinned entities (PERSON/THING/CONCEPT) are included in the
        BACKGROUND CONTEXT section regardless of query relevance.
        
        Args:
            user_id: User identifier
            node_id: Node ID to pin (e.g., "P1", "T3")
            
        Returns:
            True if successfully pinned, False if node not found
        """
        unified_graph = self._ensure_graph_loaded(user_id)
        if not unified_graph:
            return False
        node = unified_graph.nodes.get(node_id)
        if not node:
            return False
        node.attrs["pinned"] = True
        # Persist change
        if self._enable_persistence and self._storage:
            try:
                self._storage.save_unified_graph(user_id, unified_graph)
            except Exception as e:
                logger.error(f"Failed to persist pinned entity {node_id}: {e}")
        return True

    def unpin_entity(self, user_id: str, node_id: str) -> bool:
        """Unpin an entity node.
        
        Args:
            user_id: User identifier
            node_id: Node ID to unpin
            
        Returns:
            True if successfully unpinned, False if node not found
        """
        unified_graph = self._ensure_graph_loaded(user_id)
        if not unified_graph:
            return False
        node = unified_graph.nodes.get(node_id)
        if not node:
            return False
        node.attrs.pop("pinned", None)
        if self._enable_persistence and self._storage:
            try:
                self._storage.save_unified_graph(user_id, unified_graph)
            except Exception as e:
                logger.error(f"Failed to persist unpinned entity {node_id}: {e}")
        return True

    def get_pinned_entities(self, user_id: str) -> list[dict]:
        """Get all pinned entity nodes for a user.
        
        Returns:
            List of dicts with keys: id, type, label, attrs
        """
        unified_graph = self._ensure_graph_loaded(user_id)
        if not unified_graph:
            return []
        return [
            {"id": n.id, "type": n.type.name, "label": n.label, "attrs": n.attrs}
            for n in unified_graph.nodes.values()
            if n.attrs.get("pinned")
        ]

    def clear_user_data(self, user_id: str) -> bool:
        """Clear all data for a user."""
        # Issue #105: Thread-safe dict mutation
        with self._graph_dict_lock:
            if user_id in self._user_graphs:
                del self._user_graphs[user_id]
            if user_id in self._user_locks:
                del self._user_locks[user_id]
        
        # Clear from persistent storage
        # Issue #137: Use clear_user_data() (removes entire user directory)
        # instead of delete_unified_graph() (only removes the JSON file,
        # leaving the user directory on disk — GDPR risk).
        if self._enable_persistence and self._storage:
            self._storage.clear_user_data(user_id)
        
        if user_id in self._sessions:
            del self._sessions[user_id]
        
        # Clear extraction cache (Issue #9)
        if self._enable_extraction_cache and self._extraction_cache:
            self._extraction_cache.invalidate(user_id)
        
        return True
    
    def get_tiered_model_stats(self) -> Optional[dict]:
        """
        Get tiered model usage statistics (Issue #8, #124).
        
        Returns:
            Dict with fast_calls, smart_calls, total, fast_ratio,
            and recent_classifications (if using TieredLLMClient).
            Returns None if tiered models not enabled.
        """
        if isinstance(self._llm, TieredLLMClient):
            return self._llm.get_routing_metrics()
        if self._enable_tiered_models and isinstance(self._llm, TieredBedrockProvider):
            return self._llm.get_usage_stats()
        return None
    
    def get_routing_metrics(self) -> Optional[dict]:
        """
        Get tiered extraction routing metrics (Issue #124).
        
        Returns:
            Dict with fast_calls, smart_calls, total_calls, fast_ratio,
            and recent_classifications.
            Returns None if tiered extraction not enabled.
        """
        if isinstance(self._llm, TieredLLMClient):
            return self._llm.get_routing_metrics()
        return None
    
    def get_cache_stats(self) -> Optional[dict]:
        """
        Get extraction cache statistics (Issue #9).
        
        Returns:
            Dict with hits, misses, partial_hits, hit_rate, etc.
            or None if cache not enabled
        """
        if self._enable_extraction_cache and self._extraction_cache:
            return self._extraction_cache.get_stats().to_dict()
        return None
    
    def get_compaction_events(self) -> list[dict]:
        """
        Get recent graph compaction events (Issue #125).
        
        Returns:
            List of CompactionEvent dicts (most recent last, max 50).
        """
        return [e.to_dict() for e in self._compaction_events]
    
    def invalidate_cache(self, user_id: Optional[str] = None) -> int:
        """
        Invalidate extraction cache.
        
        Args:
            user_id: Specific user to invalidate, or None for all
            
        Returns:
            Number of entries invalidated
        """
        if not self._enable_extraction_cache or not self._extraction_cache:
            return 0
        
        if user_id:
            return 1 if self._extraction_cache.invalidate(user_id) else 0
        else:
            return self._extraction_cache.clear()
    
    def _serialize_decisions(self, decisions: list[DecisionTrail]) -> list[dict]:
        """Serialize decisions for cache storage."""
        result = []
        for d in decisions:
            result.append({
                "id": d.id,
                "decision": d.decision,
                "context": d.context,
                "rationale": d.rationale,
                "confidence": d.confidence,
                "related_goals": d.related_goals,
                "dependencies": d.dependencies,
                "invalidated_by": d.invalidated_by,
                "turn_id": d.turn_id,
                "alternatives": [
                    {"option": a.option, "pros": a.pros, "cons": a.cons, "rejection_reason": a.rejection_reason}
                    for a in (d.alternatives or [])
                ],
                "evidence": [
                    {"claim": e.claim, "source": e.source.value if e.source else "inferred", "turn_id": e.turn_id, "confidence": e.confidence}
                    for e in (d.evidence or [])
                ],
                "trade_offs": [
                    {"gained": t.gained, "sacrificed": t.sacrificed, "justification": t.justification}
                    for t in (d.trade_offs or [])
                ],
            })
        return result
    
    def _deserialize_decisions(self, data: list[dict]) -> list[DecisionTrail]:
        """Deserialize decisions from cache storage."""
        from .core.models import Alternative, Evidence, EvidenceSource, TradeOff
        
        result = []
        for d in data:
            # Ensure sub-lists contain dicts before iterating .get()
            raw_alts = d.get("alternatives", [])
            alternatives = [
                Alternative(
                    option=a.get("option", ""),
                    pros=a.get("pros", []),
                    cons=a.get("cons", []),
                    rejection_reason=a.get("rejection_reason")
                )
                for a in (raw_alts if isinstance(raw_alts, list) else [])
                if isinstance(a, dict)
            ]
            
            evidence = []
            raw_evidence = d.get("evidence", [])
            for e in (raw_evidence if isinstance(raw_evidence, list) else []):
                if not isinstance(e, dict):
                    continue
                try:
                    source = EvidenceSource(e.get("source", "inferred"))
                except ValueError:
                    source = EvidenceSource.INFERRED
                evidence.append(Evidence(
                    claim=e.get("claim", ""),
                    source=source,
                    turn_id=e.get("turn_id"),
                    confidence=e.get("confidence", 0.8)
                ))
            
            raw_tradeoffs = d.get("trade_offs", [])
            trade_offs = [
                TradeOff(
                    gained=t.get("gained", ""),
                    sacrificed=t.get("sacrificed", ""),
                    justification=t.get("justification")
                )
                for t in (raw_tradeoffs if isinstance(raw_tradeoffs, list) else [])
                if isinstance(t, dict)
            ]
            
            result.append(DecisionTrail(
                id=d.get("id", ""),
                decision=d.get("decision", ""),
                context=d.get("context", ""),
                rationale=d.get("rationale", ""),
                confidence=d.get("confidence", 0.5),
                related_goals=d.get("related_goals", []),
                dependencies=d.get("dependencies", []),
                invalidated_by=d.get("invalidated_by"),
                turn_id=d.get("turn_id"),
                alternatives=alternatives,
                evidence=evidence,
                trade_offs=trade_offs,
            ))
        
        return result

    # Issue #109: Attribute keys to surface in context output for numeric preservation
    # Issue #114: Added evidence, trigger for decision richness
    # Issue #116: Added abandoned_reason for goal lifecycle
    _CONTEXT_ATTR_KEYS = {
        "count", "quantity", "percentage", "amount", "timeline",
        "duration", "metric", "target", "completed", "total",
        "breakdown", "target_date", "budget", "cost", "rationale",
        "status", "goal_type", "confidence", "evidence", "trigger",
        "abandoned_reason",
        # Issue #107: FACT-specific attribute keys
        "value", "unit", "category", "as_of",
    }

    def _format_node_content(self, node) -> str:
        """Format a graph node into a content string that includes key attributes.
        
        Issue #109: Preserves numeric/quantitative data in assembled context
        by appending relevant attributes to the label.
        """
        label = node.label
        if not node.attrs:
            return label
        
        # Gather displayable attrs (numeric + key semantic attrs)
        display_parts = []
        for key in sorted(node.attrs.keys()):
            if key in self._CONTEXT_ATTR_KEYS:
                val = node.attrs[key]
                if val is not None and val != "" and val != []:
                    display_parts.append(f"{key}: {val}")
        
        if not display_parts:
            return label
        
        return f"{label} ({', '.join(display_parts)})"

    def _goal_type_to_intent_type(self, goal_type: str) -> IntentType:
        """Convert unified graph goal_type string to IntentType enum."""
        mapping = {
            "mission": IntentType.MISSION,
            "goal": IntentType.GOAL,
            "task": IntentType.TASK,
            "decision": IntentType.DECISION,
        }
        return mapping.get(goal_type.lower(), IntentType.GOAL)
    
    def _status_to_intent_status(self, status: str) -> IntentStatus:
        """Convert unified graph status string to IntentStatus enum."""
        mapping = {
            "active": IntentStatus.ACTIVE,
            "completed": IntentStatus.COMPLETED,
            "abandoned": IntentStatus.ABANDONED,
            "pending": IntentStatus.PENDING,
            "blocked": IntentStatus.BLOCKED,
        }
        return mapping.get(status.lower(), IntentStatus.ACTIVE)


# =============================================================================
# Singleton Factory (Issue #100)
# =============================================================================

# Module-level instance registry keyed by storage_path
_mcal_instances: dict[str, MCAL] = {}


def get_mcal(storage_path: str | None = None, **kwargs) -> MCAL:
    """
    Get or create a shared MCAL instance for the given storage path.
    
    This avoids the cost of re-creating MCAL (and its LLM clients,
    extractors, graphs) on every LangGraph node invocation.  The
    in-memory graph cache stays warm across calls.
    
    Usage in LangGraph nodes::
    
        from mcal import get_mcal
        
        async def retrieve_context(state):
            mcal = get_mcal(storage_path="/tmp/mcal", openai_api_key="...")
            return await mcal.search(...)
        
        async def store_memory(state):
            mcal = get_mcal(storage_path="/tmp/mcal", openai_api_key="...")
            await mcal.add(...)
    
    The first call creates the instance; subsequent calls with the same
    ``storage_path`` return the cached instance (extra kwargs are ignored
    after creation).
    
    Args:
        storage_path: Persistence directory (also used as cache key).
                      Defaults to ``~/.mcal``.
        **kwargs: Forwarded to ``MCAL(...)`` on first creation only.
        
    Returns:
        Shared MCAL instance
    """
    key = storage_path or str(Path.home() / ".mcal")
    if key not in _mcal_instances:
        _mcal_instances[key] = MCAL(storage_path=storage_path, **kwargs)
        logger.info(f"Created shared MCAL instance for {key}")
    return _mcal_instances[key]


def close_mcal(storage_path: str | None = None) -> None:
    """
    Close and remove a shared MCAL instance from the registry.
    
    Safe to call even if no instance exists for the given path.
    
    Args:
        storage_path: The key used in ``get_mcal()``.  If ``None``,
                      closes ALL registered instances.
    """
    if storage_path is None:
        keys = list(_mcal_instances.keys())
        for k in keys:
            _mcal_instances[k].close()
            del _mcal_instances[k]
        logger.info(f"Closed all {len(keys)} shared MCAL instance(s)")
    else:
        key = storage_path
        if key in _mcal_instances:
            _mcal_instances[key].close()
            del _mcal_instances[key]
            logger.info(f"Closed shared MCAL instance for {key}")
