"""
cortex/api/plugin.py — CortexPlugin: Main Entry Point (Module 13 / M13).

The orchestration layer that wires ALL Cortex modules together into a single,
coherent API. This is the ONLY class that host applications (MCP server, HTTP
server, direct integration) need to interact with.

Modules wired:
    M1:  SQLiteStorageAdapter — persistence
    M2:  ExtractionPipeline — claim extraction from conversation
    M3:  EntityResolver — entity canonicalization
    M4:  ReconciliationEngine — claim dedup/merge/supersede
    M5:  CornerstoneGuardian — identity protection
    M8:  RetrievalPipeline — memory search and context assembly
    M9:  TokenBudgetManager — context window budget allocation
    M11: DriftCalculator — cornerstone integrity monitoring
    M12: FeedbackSystem — retrieval quality tracking

Public API surface (all async):
    remember(messages, session_id)  → extract + reconcile + store claims
    retrieve(query)                 → search + rank + budget-trim memories
    forget(memory_id)               → soft-delete a claim (cornerstone-protected)
    feedback(memory_id, helpful)    → rate a retrieved memory
    wake(session_id)                → start a session, load cornerstones
    sleep(session_id)               → end a session, run dream cycle
    health()                        → system health check

Guard rails:
    - No module failure crashes the plugin — every module call is wrapped in try/except
    - Cornerstone protection on forget() (refuses to delete cornerstones)
    - All public methods return typed result dataclasses (never raw dicts)
    - Graceful degradation: if a module is unavailable, the plugin continues
      with reduced functionality rather than crashing

Dependencies: All Cortex modules (M1-M12).
Used by: cortex.api.mcp_server (MCP tools), cortex.api.http_server (REST API).
"""
from __future__ import annotations

import asyncio
import json
import logging
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, TYPE_CHECKING

from cortex.validation import (
    validate_conversation,
    validate_retrieve_query,
    validate_cornerstone_fields,
)
from cortex.llm.fallback import build_provider_cascade
from cortex.logging_utils import install_redaction_filter
from cortex.capture.wake_pipeline import (
    strip_transport_metadata_conversation as _strip_transport_metadata_conversation,
    conversation_has_signal as _conversation_has_signal,
    run_extraction as _run_extraction,
    _config_int,
)
from cortex.capture.episode_adapter import log_episode_event as _log_episode_event

logger = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Result / report types
# ---------------------------------------------------------------------------

@dataclass
class RememberResult:
    """Result of the remember() pipeline (extract → reconcile → store).

    Attributes:
        session_id: Session this remember() call belongs to.
        claims_extracted: How many claims the LLM extracted from the conversation.
        claims_stored: How many claims were actually persisted (after dedup).
        entities_resolved: How many entity mentions were resolved to canonical IDs.
        noise_filtered: How many messages were dropped by the noise filter.
        errors: List of non-fatal error messages (module failures that didn't crash).
        ok: True if the pipeline completed without fatal errors.
    """
    session_id: str
    claims_extracted: int
    claims_stored: int
    entities_resolved: int
    noise_filtered: int
    errors: List[str] = field(default_factory=list)
    warnings: List[str] = field(default_factory=list)
    zero_claim_warning: bool = False
    ok: bool = True


@dataclass
class ForgetResult:
    """Result of the forget() operation.

    Attributes:
        memory_id: The claim ID that was targeted for deletion.
        deleted: True if the claim was successfully soft-deleted (archived).
                 False if the claim is a cornerstone (deletion blocked) or not found.
    """
    memory_id: str
    deleted: bool
    reason: str = ""
    errors: List[str] = field(default_factory=list)


@dataclass
class HealthReport:
    """Returned by health()."""
    ok: bool
    storage: str = "unknown"
    modules: Dict[str, str] = field(default_factory=dict)
    errors: List[str] = field(default_factory=list)
    timestamp: str = field(default_factory=lambda: _now())


@dataclass
class EvolutionProposalResult:
    """Returned by propose_cornerstone_evolution().

    Attributes:
        action: 'auto_applied' | 'queued'
        proposal_id: ID of the stored proposal.
        proposed_content: The LLM-generated revised golden copy.
        diff: Unified diff between original and proposed content.
        reasoning: LLM explanation for the proposed change.
        confidence: LLM-reported confidence (0.0–1.0).
        requires_operator_review: False only when auto-applied.
        cornerstone: Updated CornerstoneMemory (only when auto-applied).
        errors: Non-fatal errors encountered.
        ok: False if the operation failed entirely.
    """
    action: str
    proposal_id: str
    proposed_content: str
    diff: str
    reasoning: str
    confidence: float
    requires_operator_review: bool = True
    cornerstone: Any = None
    errors: List[str] = field(default_factory=list)
    ok: bool = True


@dataclass
class AskResult:
    """Returned by ask() — synthesised answer from retrieved memories.

    Attributes:
        answer:     LLM-generated answer grounded in stored memories.
        sources:    Memory dicts used to produce the answer.
        confidence: Mean retrieval score of the sources (0.0–1.0).
        reasoning:  Short note on how the answer was derived.
        errors:     Non-fatal errors (empty when everything worked).
        ok:         False only on hard failure (LLM unavailable, etc.).
    """

    answer: str
    sources: List[Dict[str, Any]] = field(default_factory=list)
    confidence: float = 0.0
    reasoning: str = ""
    errors: List[str] = field(default_factory=list)
    ok: bool = True


@dataclass
class WakeReport:
    """Returned by wake()."""
    session_id: str
    cornerstones_loaded: int = 0
    handoff_packet: Optional[Dict[str, Any]] = None
    due_commitments: int = 0
    open_loops: int = 0
    errors: List[str] = field(default_factory=list)


@dataclass
class SleepReport:
    """Returned by sleep()."""
    session_id: str
    memories_summarised: int = 0
    errors: List[str] = field(default_factory=list)


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _now() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")


def _new_session_id() -> str:
    return str(uuid.uuid4())


# Transport metadata helpers and _config_int are now in cortex.capture.wake_pipeline


# ---------------------------------------------------------------------------
# CortexPlugin
# ---------------------------------------------------------------------------

class CortexPlugin:
    """
    Main plugin class — single entry point for all Cortex operations.

    Usage::

        plugin = CortexPlugin(config_path="~/.config/cortex.toml")
        await plugin.wake()
        result = await plugin.remember(conversation)
        context = await plugin.retrieve("What does the user prefer?")
        await plugin.sleep()
    """

    def __init__(
        self,
        config_path: Optional[str] = None,
        db_path: Optional[str] = None,
    ) -> None:
        """
        Initialize all Cortex modules with dependency injection.

        Modules are initialised in order:
          config → storage → llm → extraction → entity_resolution →
          reconciliation → cornerstone → token_budget → retrieval → feedback

        Any module that fails to initialise is recorded; the plugin remains
        functional with degraded capability rather than crashing.
        """
        self._init_errors: List[str] = []
        self._session_id: Optional[str] = None
        self._llm_call_count: int = 0   # resets on wake()

        # Install API key redaction on root logger (idempotent)
        install_redaction_filter()

        # -- Config ----------------------------------------------------------
        try:
            from cortex.config import CortexConfig
            self.config = CortexConfig.from_toml(config_path)
            if db_path:
                self.config.storage_db_path = db_path
        except Exception as exc:  # pragma: no cover
            logger.error("Failed to load CortexConfig: %s", exc)
            self._init_errors.append(f"config: {exc}")
            # Fall back to defaults inline so other modules can still init
            from cortex.config import CortexConfig
            self.config = CortexConfig()
            if db_path:
                self.config.storage_db_path = db_path

        max_concurrent = max(1, _config_int(self.config, "max_concurrent_extractions", 4))
        self._extraction_semaphore = asyncio.Semaphore(max_concurrent)
        self._zero_claim_outcomes: int = 0

        # -- Metrics Collector (#368) ----------------------------------------
        self._metrics: Any = None
        try:
            from cortex.core.metrics import MetricsCollector
            self._metrics = MetricsCollector.get(
                owner_id=self.config.owner_id,
                enabled=self.config.metrics_enabled,
            )
            logger.info(
                "CortexPlugin: metrics %s (format=%s)",
                "enabled" if self.config.metrics_enabled else "disabled",
                self.config.metrics_export_format,
            )
        except Exception as exc:
            logger.warning("Failed to init MetricsCollector: %s", exc)

        # -- Feature Flags (#368) --------------------------------------------
        self._flags: Any = None
        try:
            from cortex.core.feature_flags import FeatureFlagManager
            self._flags = FeatureFlagManager(config=self.config)
            logger.info(
                "CortexPlugin: feature flags %s",
                "enabled" if self.config.feature_flags_enabled else "disabled",
            )
        except Exception as exc:
            logger.warning("Failed to init FeatureFlagManager: %s", exc)

        # -- Storage (M1) ----------------------------------------------------
        self.storage: Any = None
        try:
            from cortex.storage.sqlite_adapter import SQLiteAdapter
            self.storage = SQLiteAdapter(
                db_path=self.config.storage_db_path,
                enable_wal=self.config.storage_enable_wal,
                busy_timeout_ms=self.config.storage_busy_timeout_ms,
                embedding_dim=self.config.embedding_dim,
            )
        except Exception as exc:
            logger.error("Failed to init SQLiteAdapter: %s", exc)
            self._init_errors.append(f"storage: {exc}")

        # -- LLM Provider (config-driven cascade) ----------------------------
        # -- Circuit Breaker (config-driven) ------------------------------------
        from cortex.llm.circuit_breaker import CircuitBreaker
        self._circuit_breaker: Optional[CircuitBreaker] = None
        if self.config.circuit_breaker_enabled:
            self._circuit_breaker = CircuitBreaker(
                enabled=True,
                threshold=self.config.circuit_breaker_threshold,
                cooldown_sec=self.config.circuit_breaker_cooldown_sec,
            )
            logger.info(
                "CortexPlugin: circuit breaker enabled (threshold=%d, cooldown=%ds)",
                self.config.circuit_breaker_threshold,
                self.config.circuit_breaker_cooldown_sec,
            )

        # -- LLM Provider (config-driven cascade) ----------------------------
        self.llm: Any = None
        try:
            priority = list(self.config.llm_provider_priority)
            self.llm = build_provider_cascade(
                priority,
                completion_model=self.config.extraction_model,
                embed_model=self.config.embedding_model,
                circuit_breaker=self._circuit_breaker,
            )
            logger.info(
                "CortexPlugin: LLM cascade initialised — priority: %s, "
                "active providers: %s",
                priority,
                self.llm.provider_names(),
            )
        except Exception as exc:
            logger.error("Failed to init LLM provider cascade: %s", exc)
            self._init_errors.append(f"llm: {exc}")

        # -- Model Router (#375 — wrap provider with tier routing) ------------
        if self.llm is not None:
            try:
                from cortex.llm.router import ModelRouter
                self.llm = ModelRouter(provider=self.llm, config=self.config)
                if getattr(self.config, "model_routing_enabled", False):
                    logger.info(
                        "CortexPlugin: model routing enabled — "
                        "fast=%s, main=%s, reasoning=%s",
                        getattr(self.config, "model_routing_fast", ""),
                        getattr(self.config, "model_routing_main", ""),
                        getattr(self.config, "model_routing_reasoning", ""),
                    )
                else:
                    logger.debug("CortexPlugin: model routing disabled (passthrough)")
            except Exception as exc:
                logger.warning("CortexPlugin: model router init failed: %s — using raw provider", exc)

        if self.llm is None:
            logger.warning(
                "CortexPlugin: No LLM provider configured. "
                "Memory extraction, reconciliation, and dream cycles will be disabled. "
                "Set llm_provider_priority in cortex.toml and provide API keys "
                "for at least one provider (openai, anthropic, ollama)."
            )

        # -- Extraction Pipeline (M2) ----------------------------------------
        self.extractor: Any = None
        try:
            from cortex.extraction.extractor import ExtractionPipeline
            self.extractor = ExtractionPipeline(
                llm=self.llm,
                model=self.config.extraction_model,
                custom_instructions=getattr(self.config, "extraction_custom_instructions", ""),
                max_claims_per_extraction=(
                    _config_int(
                        self.config,
                        "max_claims_per_extraction",
                        _config_int(self.config, "max_extraction_calls_per_batch", 25),
                    )
                ),
                max_entities_per_extraction=getattr(self.config, "max_entities_per_extraction", 100),
            )
        except Exception as exc:
            logger.error("Failed to init ExtractionPipeline: %s", exc)
            self._init_errors.append(f"extraction: {exc}")

        # -- Entity Resolver (M3) --------------------------------------------
        self.entity_resolver: Any = None
        try:
            from cortex.core.entity_resolution import EntityResolver
            self.entity_resolver = EntityResolver(
                storage=self.storage,
                llm=self.llm,
            )
        except Exception as exc:
            logger.error("Failed to init EntityResolver: %s", exc)
            self._init_errors.append(f"entity_resolution: {exc}")

        # -- Reconciliation Engine (M4) --------------------------------------
        self.reconciler: Any = None
        try:
            from cortex.core.reconciliation import ReconciliationEngine
            self.reconciler = ReconciliationEngine(
                storage=self.storage,
                llm=self.llm,
                entity_resolver=self.entity_resolver,
                config=self.config,
            )
        except Exception as exc:
            logger.error("Failed to init ReconciliationEngine: %s", exc)
            self._init_errors.append(f"reconciliation: {exc}")

        # -- Cornerstone Guardian (M5) ---------------------------------------
        self.cornerstone_guardian: Any = None
        try:
            from cortex.identity.cornerstone import CornerstoneGuardian
            self.cornerstone_guardian = CornerstoneGuardian(
                storage=self.storage,
                config=self.config,
                llm=self.llm,  # pass LLM for propose_evolution()
            )
        except Exception as exc:
            logger.error("Failed to init CornerstoneGuardian: %s", exc)
            self._init_errors.append(f"cornerstone: {exc}")

        # -- Token Budget Manager (M9) ---------------------------------------
        self.token_budget_manager: Any = None
        try:
            from cortex.core.token_budget import TokenBudgetManager
            self.token_budget_manager = TokenBudgetManager(config=self.config)
        except Exception as exc:
            logger.error("Failed to init TokenBudgetManager: %s", exc)
            self._init_errors.append(f"token_budget: {exc}")

        # -- Working Memory Cache (#371) -------------------------------------
        self.working_memory: Any = None
        if self.config.working_memory_enabled:
            try:
                from cortex.core.working_memory import WorkingMemoryCache
                self.working_memory = WorkingMemoryCache(
                    max_items=self.config.working_memory_max_items,
                    default_ttl_seconds=self.config.working_memory_default_ttl_seconds,
                    enabled=True,
                )
                logger.info(
                    "CortexPlugin: working memory enabled (max_items=%d, ttl=%ds)",
                    self.config.working_memory_max_items,
                    self.config.working_memory_default_ttl_seconds,
                )
            except Exception as exc:
                logger.warning("Failed to init WorkingMemoryCache: %s — continuing without working memory", exc)

        # -- Retrieval Pipeline (M8) -----------------------------------------
        self.retrieval: Any = None
        try:
            from cortex.core.retrieval import RetrievalPipeline
            self.retrieval = RetrievalPipeline(
                storage=self.storage,
                config=self.config,
                token_budget=self.token_budget_manager,
                cornerstone_guardian=self.cornerstone_guardian,
                llm=self.llm,
                vector_weight=self.config.retrieval_vector_weight,
                bm25_weight=self.config.retrieval_bm25_weight,
                working_memory=self.working_memory,
            )
        except Exception as exc:
            logger.error("Failed to init RetrievalPipeline: %s", exc)
            self._init_errors.append(f"retrieval: {exc}")

        # -- Embedding model switch protection --------------------------------
        self._embedding_model_changed: bool = False
        self._embedding_model_warning_emitted: bool = False
        self._check_embedding_model_consistency()

        # -- Feedback System (M12) -------------------------------------------
        self.feedback_system: Any = None
        try:
            from cortex.core.feedback import FeedbackSystem
            self.feedback_system = FeedbackSystem(
                storage=self.storage,
                owner_id=self.config.owner_id,
            )
        except Exception as exc:
            logger.error("Failed to init FeedbackSystem: %s", exc)
            self._init_errors.append(f"feedback: {exc}")

        # -- Promotion Scorer (#376) ------------------------------------------
        self.promotion_scorer: Any = None
        try:
            from cortex.core.promotion import PromotionScorer
            self.promotion_scorer = PromotionScorer(
                config=self.config,
                feedback_system=self.feedback_system,
            )
            logger.info(
                "CortexPlugin: promotion scorer enabled (threshold=%.2f, min_sessions=%d)",
                self.promotion_scorer.threshold,
                self.promotion_scorer._min_sessions,
            )
        except Exception as exc:
            logger.warning("Failed to init PromotionScorer: %s — using legacy S2 heuristics", exc)

        # -- Event Emitter (M19 — In-process event bus) ----------------------
        # Wire WebhookDispatcher → EventEmitter so ALL events get outbound delivery
        self.event_emitter: Any = None
        self._webhook_dispatcher: Any = None
        try:
            from cortex.events import EventEmitter
            from cortex.events.webhook import WebhookDispatcher
            self._webhook_dispatcher = WebhookDispatcher(storage=self.storage)
            self.event_emitter = EventEmitter(
                storage=self.storage,
                webhook_dispatcher=self._webhook_dispatcher,
            )
        except Exception as exc:
            logger.error("Failed to init EventEmitter: %s", exc)
            self._init_errors.append(f"event_emitter: {exc}")

        # -- Deprecation Pipeline (spaced repetition / reinforcement) --------
        self.deprecation_pipeline: Any = None
        try:
            from cortex.deprecation.pipeline import DeprecationPipeline
            self.deprecation_pipeline = DeprecationPipeline(
                storage=self.storage,
                config=self.config,
            )
        except Exception as exc:
            logger.error("Failed to init DeprecationPipeline: %s", exc)
            self._init_errors.append(f"deprecation: {exc}")

        if self._init_errors:
            logger.warning(
                "CortexPlugin initialised with %d error(s): %s",
                len(self._init_errors),
                "; ".join(self._init_errors),
            )
        else:
            logger.info("CortexPlugin initialised successfully")

    # =========================================================================
    # EMBEDDING MODEL CONSISTENCY CHECK
    # =========================================================================

    def _check_embedding_model_consistency(self) -> None:
        """Check if the embedding model has changed since last run.

        Reads the previously used embedding model from the cortex_config table
        and compares it with the current config. If different and embeddings
        exist, logs a loud warning and sets a flag.
        """
        if self.storage is None:
            return

        current_model = self.config.embedding_model

        try:
            # Read previously stored model from DB
            conn = self.storage._get_connection()
            cursor = conn.execute(
                "SELECT value FROM cortex_config WHERE key = 'embedding_model'"
            )
            row = cursor.fetchone()
            previous_model = row[0] if row else None

            # Check if embeddings exist
            embed_cursor = conn.execute(
                "SELECT COUNT(*) FROM embedding_index"
            )
            embed_count = embed_cursor.fetchone()[0]

            if previous_model and previous_model != current_model and embed_count > 0:
                logger.warning(
                    "⚠️  EMBEDDING MODEL CHANGED from '%s' to '%s'. "
                    "%d existing embeddings are INCOMPATIBLE and will produce "
                    "degraded search results. Consider re-indexing or reverting "
                    "the model change.",
                    previous_model,
                    current_model,
                    embed_count,
                )
                self._embedding_model_changed = True

            # Store current model for next startup comparison
            conn.execute(
                "INSERT OR REPLACE INTO cortex_config (key, value) VALUES (?, ?)",
                ("embedding_model", current_model),
            )
            conn.commit()
        except Exception as exc:
            # Table might not exist yet — that's fine, first run
            logger.debug(
                "Embedding model consistency check skipped: %s", exc
            )

    # =========================================================================
    # LLM BUDGET GUARD
    # =========================================================================

    def _budget_check(self, calls_needed: int = 1) -> Optional[str]:
        """Return an error string if *calls_needed* LLM calls would exceed budget.

        Returns None when the budget is sufficient (call is allowed).
        Disabled (returns None always) when max_llm_calls_per_session == 0.
        """
        limit = self.config.max_llm_calls_per_session
        if limit == 0:
            return None  # budget guard disabled
        if self._llm_call_count + calls_needed > limit:
            return (
                f"LLM call budget exhausted: {self._llm_call_count}/{limit} calls "
                f"used this session. Reset on wake()."
            )
        return None

    def _budget_consume(self, n: int = 1) -> None:
        """Consume *n* LLM calls from the session budget and warn at 80%."""
        self._llm_call_count += n
        limit = self.config.max_llm_calls_per_session
        if limit == 0:
            return
        pct = self._llm_call_count / limit
        if pct >= 0.8:
            logger.warning(
                "LLM call budget at %.0f%% (%d/%d calls used this session)",
                pct * 100,
                self._llm_call_count,
                limit,
            )

    # =========================================================================
    # WRITE
    # =========================================================================

    async def remember(
        self,
        conversation: Any,
        session_id: Optional[str] = None,
        scope: str = "user",
        owner_id: Optional[str] = None,
    ) -> RememberResult:
        """
        Extract claims from *conversation* and persist them to memory.

        Pipeline: extract → resolve entities → reconcile → (auto-stored in reconcile)

        Args:
            conversation: List of message dicts, raw text string, or any
                          object ExtractionPipeline.extract() accepts.
            session_id:   Associate memories with this session.
            scope:        Owner scope (default: 'user').

        Returns:
            RememberResult with counts and any soft errors encountered.
        """
        # Resolve effective owner — use request-supplied owner_id when provided
        effective_owner_id = owner_id or self.config.owner_id

        sid = session_id or self._session_id or _new_session_id()
        errors: List[str] = []
        warnings: List[str] = []

        if not hasattr(self, "_extraction_semaphore"):
            max_concurrent = max(1, _config_int(self.config, "max_concurrent_extractions", 4))
            self._extraction_semaphore = asyncio.Semaphore(max_concurrent)
        if not hasattr(self, "_zero_claim_outcomes"):
            self._zero_claim_outcomes = 0

        async def _bump_session_counters(memories_delta: int) -> None:
            """Best-effort session metric update (turn_count + memories_created)."""
            if self.storage is None:
                return
            try:
                if hasattr(self.storage, "increment_session_counters"):
                    await self.storage.increment_session_counters(
                        session_id=sid,
                        owner_id=effective_owner_id,
                        turn_delta=1,
                        memories_created_delta=memories_delta,
                    )
                    return
                existing = None
                if hasattr(self.storage, "get_session_by_sid"):
                    existing = await self.storage.get_session_by_sid(
                        session_id=sid,
                        owner_id=effective_owner_id,
                    )
                if existing is None:
                    return
                await self.storage.update_session(
                    sid,
                    owner_id=effective_owner_id,
                    turn_count=(existing.turn_count or 0) + 1,
                    memories_created=(existing.memories_created or 0) + max(0, memories_delta),
                )
            except Exception as exc:
                logger.warning(
                    "Failed to update session counters for session %s (owner=%s): %s",
                    sid, effective_owner_id, exc,
                )

        async def _finalize_result(result: RememberResult) -> RememberResult:
            await _bump_session_counters(result.claims_stored)
            return result

        # 0a. Validate + sanitize conversation input
        conversation = validate_conversation(conversation)
        conversation = _strip_transport_metadata_conversation(conversation)

        # 0. Ensure session_record exists (FK constraint on semantic_claim.source_session)
        db_session_pk = None
        if self.storage is not None:
            try:
                session_rec = await self.storage.create_session(
                    session_id=sid,
                    owner_id=effective_owner_id,
                )
                db_session_pk = session_rec.id
            except Exception as exc:
                logger.debug("Session record creation (may already exist): %s", exc)
                try:
                    existing = await self.storage.get_session_by_sid(
                        session_id=sid,
                        owner_id=effective_owner_id,
                    )
                    if existing is not None:
                        db_session_pk = existing.id
                except Exception as lookup_exc:
                    logger.warning("Failed to look up existing session: %s", lookup_exc)

        # 0b-pre. Log episode event (evidence-first: always before extraction)
        # Delegates to cortex.capture.episode_adapter — best-effort, never blocks.
        _episode_event_id: Optional[str] = None
        if self.storage is not None:
            _episode_event_id = await _log_episode_event(
                storage=self.storage,
                session_id_pk=db_session_pk,
                conversation=conversation,
                owner_id=effective_owner_id,
                sid=sid,
            )

        # 0b. Check extraction_enabled toggle
        if not getattr(self.config, "extraction_enabled", True):
            logger.info(
                "Extraction disabled (extraction_enabled=False) — skipping for session %s",
                sid,
            )
            return await _finalize_result(RememberResult(
                session_id=sid,
                claims_extracted=0,
                claims_stored=0,
                entities_resolved=0,
                noise_filtered=0,
                errors=[],
                warnings=[],
                zero_claim_warning=False,
                ok=True,
            ))

        # Steps 1-4: Extraction → dedup → entity resolution → reconcile → embed
        # Delegated to cortex.capture.wake_pipeline.run_extraction()
        outcome = await _run_extraction(
            extractor=self.extractor,
            storage=self.storage,
            llm=self.llm,
            entity_resolver=self.entity_resolver,
            cornerstone_guardian=self.cornerstone_guardian,
            reconciler=self.reconciler,
            event_emitter=getattr(self, "event_emitter", None),
            config=self.config,
            extraction_semaphore=self._extraction_semaphore,
            conversation=conversation,
            sid=sid,
            db_session_pk=db_session_pk,
            effective_owner_id=effective_owner_id,
            episode_event_id=_episode_event_id,
            budget_check=self._budget_check,
            budget_consume=self._budget_consume,
            errors=errors,
            warnings=warnings,
            zero_claim_outcomes=self._zero_claim_outcomes,
        )
        # Write back mutable plugin state updated inside the pipeline
        self._zero_claim_outcomes = outcome.updated_zero_claim_outcomes

        # Update extraction metrics for quality gates (#565)
        if self._metrics is not None:
            try:
                await self._metrics.increment("extractions_run")
                if not hasattr(self, "_extraction_total"):
                    self._extraction_total = 0
                    self._extraction_success = 0
                self._extraction_total += 1
                if outcome.ok:
                    self._extraction_success += 1
                rate = self._extraction_success / self._extraction_total
                await self._metrics.set_gauge("extraction_success_rate", rate)
            except Exception as exc:
                logger.debug("Failed to update extraction metrics: %s", exc)

        return await _finalize_result(RememberResult(
            session_id=sid,
            claims_extracted=outcome.claims_extracted,
            claims_stored=outcome.claims_stored,
            entities_resolved=outcome.entities_resolved,
            noise_filtered=outcome.noise_filtered,
            errors=outcome.errors,
            warnings=outcome.warnings,
            zero_claim_warning=outcome.zero_claim_warning,
            ok=outcome.ok,
        ))

    # =========================================================================
    # READ
    # =========================================================================

    async def retrieve(
        self,
        query: str,
        token_budget: Optional[int] = None,
        constraints: Any = None,
        mode: str = "auto",
        session_id: Optional[str] = None,
        owner_id: Optional[str] = None,
    ) -> Any:
        """
        Retrieve relevant memories for *query* within *token_budget*.

        Returns a RetrievalResult (from cortex.core.retrieval) or None on error.
        """
        if self.retrieval is None:
            logger.error("RetrievalPipeline not available")
            return None

        # Warn on first retrieve if embedding model changed
        if getattr(self, "_embedding_model_changed", False) and not getattr(
            self, "_embedding_model_warning_emitted", False
        ):
            logger.warning(
                "⚠️  Retrieving with changed embedding model. Results may be "
                "degraded — existing embeddings were generated with a different model."
            )
            self._embedding_model_warning_emitted = True

        # Validate query string
        query = validate_retrieve_query(query)

        # Normalise legacy/alias mode names to engine-supported values
        _MODE_ALIASES = {"fast": "lightweight", "thorough": "agentic"}
        mode = _MODE_ALIASES.get(mode, mode)

        budget = token_budget or self.config.retrieval_token_budget

        # Resolve effective owner — use request-supplied owner_id when provided
        effective_owner_id = owner_id or self.config.owner_id

        # Build constraints if token_budget was explicitly supplied
        if constraints is None and token_budget is not None:
            try:
                from cortex.core.retrieval import RetrievalConstraints
                constraints = RetrievalConstraints(token_budget=budget)
            except ImportError:
                pass

        try:
            result = await self.retrieval.retrieve(
                query=query,
                owner_id=effective_owner_id,
                mode=mode,
                constraints=constraints,
            )
            # Fix 1: Spaced repetition — trigger reinforcement for each retrieved claim
            if getattr(self, "deprecation_pipeline", None) is not None and result is not None:
                for item in getattr(result, "items", []):
                    claim_id = getattr(item, "item_id", None)
                    if claim_id:
                        try:
                            await self.deprecation_pipeline.check_reinforcement(claim_id)
                        except Exception as reinforce_exc:
                            logger.debug(
                                "Reinforcement check failed for %s: %s",
                                claim_id,
                                reinforce_exc,
                            )

            # Debug logging: emit retrieval event
            from cortex.debug import log_retrieval_event
            items = getattr(result, "items", []) if result else []
            top_score = 0.0
            if items:
                scores = [getattr(it, "score", 0.0) or 0.0 for it in items]
                top_score = max(scores) if scores else 0.0
            tokens_used_val = getattr(result, "tokens_used", 0) if result else 0
            log_retrieval_event(
                session_id=session_id or "",
                query=query,
                mode=mode,
                items_returned=len(items),
                top_score=top_score,
                tokens_used=tokens_used_val or 0,
            )

            return result
        except Exception as exc:
            logger.error("Retrieval failed: %s", exc, exc_info=True)
            return None

    # =========================================================================
    # MID-TURN RE-RETRIEVAL (#372)
    # =========================================================================

    async def on_tool_result(
        self,
        session_id: str,
        tool_name: str,
        result_text: str,
        current_context: Optional[List[str]] = None,
        existing_memory_ids: Optional[List[str]] = None,
        budget_tokens: int = 2000,
        owner_id: Optional[str] = None,
    ) -> Optional[Any]:
        """Handle a tool result and optionally trigger supplemental retrieval.

        Called by the host when a tool call returns results mid-conversation.
        If re-retrieval is enabled and a trigger is detected, executes a
        supplemental retrieval and returns the result.

        Args:
            session_id: Active session ID.
            tool_name: Name of the tool that returned results.
            result_text: The tool's result text.
            current_context: Existing context strings for comparison.
            existing_memory_ids: Already-injected memory IDs to avoid duplicates.
            budget_tokens: Remaining token budget for supplemental results.
            owner_id: Multi-tenancy scope key (defaults to config.owner_id).

        Returns:
            ReRetrievalResult if supplemental retrieval was triggered, else None.
        """
        if not getattr(self.config, "re_retrieval_enabled", True):
            return None

        if not result_text or not result_text.strip():
            return None

        try:
            from cortex.core.re_retrieval import (
                ReRetrievalEngine,
                ReRetrievalRequest,
                ReRetrievalTrigger,
            )

            if self.retrieval is None:
                logger.debug("on_tool_result: retrieval pipeline not available")
                return None

            # Lazy-init the re-retrieval engine (one per plugin instance)
            if not hasattr(self, "_re_retrieval_engine"):
                from cortex.core.working_memory import WorkingMemoryCache

                wm = getattr(self, "working_memory", None)
                if wm is None:
                    wm = WorkingMemoryCache(enabled=False)

                self._re_retrieval_engine = ReRetrievalEngine(
                    retrieval=self.retrieval,
                    working_memory=wm,
                    config=self.config,
                )

            engine: ReRetrievalEngine = self._re_retrieval_engine

            # Check if we should trigger
            trigger = await engine.should_trigger(
                new_context=result_text,
                current_context=current_context or [],
            )

            if trigger is None:
                logger.debug(
                    "on_tool_result: no trigger for tool=%s",
                    tool_name,
                )
                return None

            effective_owner = owner_id or self.config.owner_id

            request = ReRetrievalRequest(
                trigger=trigger,
                new_context=result_text,
                session_id=session_id,
                owner_id=effective_owner,
                existing_memory_ids=existing_memory_ids or [],
                budget_tokens=budget_tokens,
            )

            result = await engine.retrieve(request)
            logger.info(
                "on_tool_result: re-retrieval triggered (tool=%s trigger=%s items=%d latency=%.1fms)",
                tool_name,
                trigger.value,
                len(result.items),
                result.latency_ms,
            )
            return result

        except Exception as exc:
            logger.error("on_tool_result failed: %s", exc, exc_info=True)
            return None

    # =========================================================================
    # DELETE
    # =========================================================================

    async def forget(
        self,
        memory_id: str,
        hard_delete: bool = False,
    ) -> ForgetResult:
        """
        Delete a memory by ID, with cornerstone protection.

        If the memory is a sealed cornerstone it will NOT be deleted —
        the caller must unseal_cornerstone() first.
        """
        # 1. Cornerstone protection check ------------------------------------
        if self.cornerstone_guardian is not None:
            try:
                is_cs = await self.cornerstone_guardian.is_cornerstone_subject(
                    subject=memory_id,
                    owner_id=self.config.owner_id,
                )
                if is_cs:
                    return ForgetResult(
                        memory_id=memory_id,
                        deleted=False,
                        reason="Memory is a sealed cornerstone — unseal first",
                    )
            except Exception as exc:
                err = f"Cornerstone check failed: {exc}"
                logger.warning(err)
                # Fail-safe: refuse deletion when protection check fails
                return ForgetResult(
                    memory_id=memory_id,
                    deleted=False,
                    reason=f"Cannot verify cornerstone status: {exc}",
                    errors=[err],
                )

        # 2. Storage delete ---------------------------------------------------
        if self.storage is None:
            return ForgetResult(
                memory_id=memory_id,
                deleted=False,
                reason="Storage not available",
                errors=["SQLiteAdapter not initialised"],
            )

        try:
            await self.storage.delete_claim(
                claim_id=memory_id,
                owner_id=self.config.owner_id,
                hard=hard_delete,
            )
            return ForgetResult(memory_id=memory_id, deleted=True, reason="ok")
        except Exception as exc:
            err = f"Delete failed: {exc}"
            logger.error(err, exc_info=True)
            return ForgetResult(
                memory_id=memory_id,
                deleted=False,
                reason=str(exc),
                errors=[err],
            )

    # =========================================================================
    # ENTITY RESOLUTION (M3) — operator / dream-cycle helpers
    # =========================================================================

    async def suggest_merges(
        self,
        similarity_threshold: float = 80.0,
        confidence_threshold: float = 0.5,
        cross_session_owner_ids: Optional[List[str]] = None,
        use_embedding_prefilter: bool = True,
        embedding_prefilter_threshold: float = 0.60,
    ) -> List[Any]:
        """Return entity pairs that look like duplicates, for human/dream-cycle review.

        Delegates to EntityResolver.suggest_merges() with embedding-based
        pre-filtering and optional cross-session co-reference detection.

        Args:
            similarity_threshold: Minimum fuzzy alias score (0-100).
            confidence_threshold: Minimum normalised confidence (0.0-1.0).
            cross_session_owner_ids: Compare entities from additional owner IDs
                against the plugin's own owner_id.  Enables cross-session
                co-reference linking.
            use_embedding_prefilter: Skip fuzzy comparison for pairs with low
                embedding cosine similarity (faster for large entity sets).
            embedding_prefilter_threshold: Cosine-similarity cutoff (0-1).

        Returns:
            List[MergeSuggestion] sorted by similarity (highest first).
            Returns [] if EntityResolver is not available.
        """
        if self.entity_resolver is None:
            logger.warning("suggest_merges: EntityResolver not available")
            return []
        try:
            return await self.entity_resolver.suggest_merges(
                owner_id=self.config.owner_id,
                similarity_threshold=similarity_threshold,
                confidence_threshold=confidence_threshold,
                cross_session_owner_ids=cross_session_owner_ids,
                use_embedding_prefilter=use_embedding_prefilter,
                embedding_prefilter_threshold=embedding_prefilter_threshold,
            )
        except Exception as exc:
            logger.error("suggest_merges failed: %s", exc, exc_info=True)
            return []

    async def merge_entities(
        self,
        keep_id: str,
        merge_id: str,
    ) -> Optional[Any]:
        """Merge two entities that refer to the same real-world thing.

        All aliases and claim FK references (subject_entity_id / object_entity_id)
        are atomically reassigned from merge_id → keep_id.  The merge_id entity
        is then deleted.  This is typically called after reviewing suggestions
        from suggest_merges().

        Args:
            keep_id: Entity to keep (the canonical one).
            merge_id: Entity to absorb and delete.

        Returns:
            The surviving Entity, or None if EntityResolver is unavailable.

        Raises:
            ValueError: If keep_id == merge_id.
        """
        if self.entity_resolver is None:
            logger.warning("merge_entities: EntityResolver not available")
            return None
        try:
            result = await self.entity_resolver.merge_entities(
                keep_id=keep_id,
                merge_id=merge_id,
            )
            # Emit ENTITY_MERGED event
            if getattr(self, "event_emitter", None) is not None and result is not None:
                try:
                    from cortex.events.emitter import CortexEvent, EventType
                    await self.event_emitter.emit(CortexEvent(
                        event_type=EventType.ENTITY_MERGED,
                        owner_id=self.config.owner_id,
                        payload={
                            "keep_id": keep_id,
                            "merge_id": merge_id,
                            "surviving_entity": getattr(result, "canonical_name", ""),
                        },
                    ))
                except Exception as emit_exc:
                    logger.debug("Event emit failed (ENTITY_MERGED): %s", emit_exc)
            return result
        except ValueError:
            raise
        except Exception as exc:
            logger.error("merge_entities failed: %s", exc, exc_info=True)
            return None

    # =========================================================================
    # CORNERSTONES
    # =========================================================================

    async def get_cornerstones(self) -> List[Any]:
        """Return all sealed cornerstones. Returns [] on error."""
        if self.cornerstone_guardian is None:
            logger.error("CornerstoneGuardian not available")
            return []
        try:
            return await self.cornerstone_guardian.get_all(
                owner_id=self.config.owner_id,
            )
        except Exception as exc:
            logger.error("get_cornerstones failed: %s", exc, exc_info=True)
            return []

    async def seal_cornerstone(
        self,
        memory_id: str,
        label: str = "",
        content: str = "",
        **kwargs: Any,
    ) -> Any:
        """
        Seal *memory_id* as a cornerstone.

        Returns a CornerstoneMemory or None on error.
        """
        if self.cornerstone_guardian is None:
            logger.error("CornerstoneGuardian not available")
            return None

        # Validate and sanitize label and content
        effective_label = label or memory_id
        effective_label, content = validate_cornerstone_fields(effective_label, content)

        try:
            result = await self.cornerstone_guardian.seal(
                label=effective_label,
                content=content,
                owner_id=self.config.owner_id,
                **kwargs,
            )
            # Emit CORNERSTONE_SEALED event
            if getattr(self, "event_emitter", None) is not None and result is not None:
                try:
                    from cortex.events.emitter import CortexEvent, EventType
                    await self.event_emitter.emit(CortexEvent(
                        event_type=EventType.CORNERSTONE_SEALED,
                        owner_id=self.config.owner_id,
                        payload={
                            "cornerstone_id": getattr(result, "id", ""),
                            "label": effective_label,
                        },
                    ))
                except Exception as emit_exc:
                    logger.debug("Event emit failed (CORNERSTONE_SEALED): %s", emit_exc)
            return result
        except Exception as exc:
            logger.error("seal_cornerstone failed: %s", exc, exc_info=True)
            return None

    async def unseal_cornerstone(self, memory_id: str) -> Any:
        """
        Unseal a cornerstone, making it eligible for modification/deletion.

        Returns an updated CornerstoneMemory or None on error.
        """
        if self.cornerstone_guardian is None:
            logger.error("CornerstoneGuardian not available")
            return None
        try:
            return await self.cornerstone_guardian.unseal(cs_id=memory_id)
        except Exception as exc:
            logger.error("unseal_cornerstone failed: %s", exc, exc_info=True)
            return None

    async def propose_cornerstone_evolution(
        self,
        cornerstone_id: str,
        new_evidence: str,
        source: str = "operator",
    ) -> EvolutionProposalResult:
        """
        Propose an LLM-generated evolution for a cornerstone.

        Uses the LLM to generate a revised golden copy that incorporates
        *new_evidence* while preserving core identity.  The proposal is stored
        and returned with its diff/reasoning — it does NOT auto-apply unless
        ``config.cornerstone_auto_apply_threshold`` is met.

        Args:
            cornerstone_id: The cornerstone to evolve.
            new_evidence:   Claims / observations that suggest the update.
            source:         Origin tag ('operator', 'dream_cycle', …).

        Returns:
            EvolutionProposalResult with all proposal details.
        """
        if self.cornerstone_guardian is None:
            err = "CornerstoneGuardian not available"
            logger.error(err)
            return EvolutionProposalResult(
                action="error",
                proposal_id="",
                proposed_content="",
                diff="",
                reasoning="",
                confidence=0.0,
                errors=[err],
                ok=False,
            )
        try:
            result = await self.cornerstone_guardian.propose_evolution(
                cornerstone_id=cornerstone_id,
                new_evidence=new_evidence,
                source=source,
            )
            return EvolutionProposalResult(
                action=result.get("action", "queued"),
                proposal_id=result.get("proposal_id", ""),
                proposed_content=result.get("proposed_content", ""),
                diff=result.get("diff", ""),
                reasoning=result.get("reasoning", ""),
                confidence=result.get("confidence", 0.0),
                requires_operator_review=result.get("requires_operator_review", True),
                cornerstone=result.get("cornerstone"),
                ok=True,
            )
        except ValueError as exc:
            err = str(exc)
            logger.warning("propose_cornerstone_evolution validation error: %s", err)
            return EvolutionProposalResult(
                action="error",
                proposal_id="",
                proposed_content="",
                diff="",
                reasoning="",
                confidence=0.0,
                errors=[err],
                ok=False,
            )
        except Exception as exc:
            err = f"propose_cornerstone_evolution failed: {exc}"
            logger.error(err, exc_info=True)
            return EvolutionProposalResult(
                action="error",
                proposal_id="",
                proposed_content="",
                diff="",
                reasoning="",
                confidence=0.0,
                errors=[err],
                ok=False,
            )

    async def apply_cornerstone_evolution(
        self,
        cornerstone_id: str,
        proposal_id: str,
        applied_by: str = "operator",
    ) -> Any:
        """
        Apply an approved evolution proposal to a cornerstone.

        Fetches the pending proposal, validates it, and calls
        CornerstoneGuardian.apply_evolution() to update the golden copy.

        Args:
            cornerstone_id: The cornerstone to update.
            proposal_id:    ID of the pending proposal to apply.
            applied_by:     Who is authorizing the change.

        Returns:
            Updated CornerstoneMemory on success, or None on error.
        """
        if self.cornerstone_guardian is None:
            logger.error("CornerstoneGuardian not available")
            return None
        try:
            return await self.cornerstone_guardian.apply_evolution(
                cornerstone_id=cornerstone_id,
                proposal_id=proposal_id,
                applied_by=applied_by,
            )
        except ValueError as exc:
            logger.warning("apply_cornerstone_evolution validation error: %s", exc)
            return None
        except Exception as exc:
            logger.error("apply_cornerstone_evolution failed: %s", exc, exc_info=True)
            return None

    # =========================================================================
    # SESSION LIFECYCLE
    # =========================================================================

    async def wake(self, session_id: Optional[str] = None, owner_id: Optional[str] = None) -> WakeReport:
        """
        Start a new session (wake hook).

        Loads cornerstones and sets the active session_id.
        """
        sid = session_id or _new_session_id()
        effective_owner = owner_id or self.config.owner_id
        self._session_id = sid
        self._llm_call_count = 0   # reset per-session LLM budget
        errors: List[str] = []
        cornerstones_loaded = 0

        # Record session start in storage
        if self.storage is not None:
            try:
                await self.storage.create_session(
                    session_id=sid,
                    owner_id=effective_owner,
                )
            except Exception as exc:
                err = f"Failed to record session start: {exc}"
                logger.warning(err)
                errors.append(err)

        # Pre-load cornerstones
        if self.cornerstone_guardian is not None:
            try:
                cs_list = await self.cornerstone_guardian.load_all(
                    owner_id=effective_owner,
                )
                cornerstones_loaded = len(cs_list)
                logger.info("Woke: session=%s cornerstones=%d", sid, cornerstones_loaded)
            except Exception as exc:
                err = f"Failed to load cornerstones on wake: {exc}"
                logger.warning(err)
                errors.append(err)

        # Load latest handoff packet from previous sleep cycle
        handoff_packet: Optional[Dict[str, Any]] = None
        if self.storage is not None:
            try:
                packets = await self.storage.get_handoff_packets(
                    owner_id=effective_owner,
                    status="active",
                    limit=1,
                )
                if packets:
                    handoff_packet = packets[0]
                    logger.info("Wake: loaded handoff packet=%s", handoff_packet.get("id", "?"))
            except Exception as exc:
                err = f"Failed to load handoff packet on wake: {exc}"
                logger.warning(err)
                errors.append(err)

        # Load due commitments and open loops for boot injection
        due_commitments_count = 0
        open_loops_count = 0
        if self.storage is not None:
            try:
                from cortex.commitments.carry_forward import get_boot_items
                boot_items = await get_boot_items(
                    storage=self.storage,
                    owner_id=effective_owner,
                )
                due_commitments_count = len(boot_items.get("due_commitments", []))
                open_loops_count = len(boot_items.get("open_loops", []))
                logger.info(
                    "Wake: boot items loaded — due_commitments=%d open_loops=%d",
                    due_commitments_count, open_loops_count,
                )
            except Exception as exc:
                err = f"Failed to load boot items on wake: {exc}"
                logger.warning(err)
                errors.append(err)

        # Emit SESSION_STARTED event
        if getattr(self, "event_emitter", None) is not None:
            try:
                from cortex.events.emitter import CortexEvent, EventType
                await self.event_emitter.emit(CortexEvent(
                    event_type=EventType.SESSION_STARTED,
                    owner_id=effective_owner,
                    payload={
                        "session_id": sid,
                        "cornerstones_loaded": cornerstones_loaded,
                        "handoff_packet_id": handoff_packet.get("id") if handoff_packet else None,
                        "due_commitments": due_commitments_count,
                        "open_loops": open_loops_count,
                    },
                    session_id=sid,
                ))
            except Exception as emit_exc:
                logger.debug("Event emit failed (SESSION_STARTED): %s", emit_exc)

        # Boot loader — deterministic continuity context (#361)
        boot_sequence = None
        if getattr(self.config, "boot_enabled", True):
            try:
                boot_sequence = await self.boot_session(
                    session_id=sid,
                    owner_id=effective_owner,
                )
                if boot_sequence is not None:
                    logger.info(
                        "Wake: boot sequence loaded — tokens=%d time=%.1fms",
                        boot_sequence.boot_tokens,
                        boot_sequence.boot_time_ms,
                    )
            except Exception as exc:
                err = f"Failed to run boot sequence: {exc}"
                logger.warning(err)
                errors.append(err)

        return WakeReport(
            session_id=sid,
            cornerstones_loaded=cornerstones_loaded,
            handoff_packet=handoff_packet,
            due_commitments=due_commitments_count,
            open_loops=open_loops_count,
            errors=errors,
        )

    async def boot_session(
        self,
        session_id: str,
        owner_id: Optional[str] = None,
        context_budget: Optional[int] = None,
    ) -> Optional[Any]:
        """Run the session boot loader for continuity context (#361).

        Builds a deterministic boot sequence: handoff, identity, commitments,
        preferences, and recent summaries — then pre-warms working memory.

        Called automatically by wake() when boot_enabled is True.

        Args:
            session_id: The session being booted.
            owner_id: Memory owner namespace (defaults to config.owner_id).
            context_budget: Total context window tokens. Defaults to
                            config retrieval_token_budget.

        Returns:
            BootSequence or None if boot loader is unavailable.
        """
        if self.storage is None:
            logger.warning("boot_session: storage not available")
            return None

        effective_owner = owner_id or self.config.owner_id
        budget = context_budget or self.config.retrieval_token_budget

        try:
            from cortex.core.boot_loader import SessionBootLoader

            # Build compiler if available (used for token estimation)
            compiler = None
            if self.token_budget_manager is not None:
                try:
                    from cortex.core.context_compiler import ContextCompiler
                    compiler = ContextCompiler(
                        token_budget=self.token_budget_manager,
                        config=self.config,
                    )
                except Exception as exc:
                    logger.warning("boot_session: ContextCompiler init failed: %s — proceeding without compiler", exc)

            loader = SessionBootLoader(
                storage=self.storage,
                compiler=compiler,
                working_memory=self.working_memory,
                config=self.config,
            )
            return await loader.boot(
                session_id=session_id,
                owner_id=effective_owner,
                context_budget=budget,
            )
        except Exception as exc:
            logger.error("boot_session failed: %s", exc, exc_info=True)
            return None

    async def sleep(
        self,
        session_id: Optional[str] = None,
        consolidate: bool = False,
    ) -> SleepReport:
        """
        End the current session (sleep hook).

        Closes the session record in storage.  When *consolidate=True* the full
        SleepConsolidator pipeline (memory dump -> deprecation -> drift check ->
        consolidation -> confirmation) runs before the normal session teardown.

        Args:
            session_id:  Session to close.  Uses the current session when absent.
            consolidate: If True, run SleepConsolidator.sleep() before closing.
                         Defaults to False to preserve existing behaviour.
        """
        sid = session_id or self._session_id or "unknown"
        errors: List[str] = []
        memories_summarised = 0

        # ------------------------------------------------------------------
        # Optional consolidation path
        # ------------------------------------------------------------------
        if consolidate and self.storage is not None:
            logger.info("sleep(): running SleepConsolidator for session %s", sid)
            try:
                from cortex.circadian.sleep import SleepConsolidator
                from cortex.identity.drift_calculator import DriftCalculator
                from cortex.deprecation.pipeline import DeprecationPipeline

                drift_calculator = DriftCalculator(
                    storage=self.storage,
                    llm=self.llm,
                    config=self.config,
                )
                deprecation_pipeline = DeprecationPipeline(
                    storage=self.storage,
                    config=self.config,
                )
                consolidator = SleepConsolidator(
                    storage=self.storage,
                    config=self.config,
                    llm=self.llm,
                    cornerstone_guardian=self.cornerstone_guardian,
                    drift_calculator=drift_calculator,
                    deprecation_pipeline=deprecation_pipeline,
                    event_emitter=getattr(self, "event_emitter", None),
                )
                consolidation_report = await consolidator.sleep(sid)
                memories_summarised = getattr(consolidation_report, "claims_promoted", 0)
                logger.info(
                    "sleep(): consolidation complete for session %s "
                    "(promoted=%d pruned=%d drift_checks=%d)",
                    sid,
                    getattr(consolidation_report, "claims_promoted", 0),
                    getattr(consolidation_report, "claims_pruned", 0),
                    len(getattr(consolidation_report, "drift_checks", [])),
                )
                # SleepConsolidator.sleep() already called update_session with
                # status="sleeping", so we skip the close_session call below.
                _consolidation_handled_close = True
            except Exception as exc:
                err = f"SleepConsolidator failed (session={sid}): {exc}"
                logger.warning(err, exc_info=True)
                errors.append(err)
                _consolidation_handled_close = False
        else:
            if consolidate:
                logger.warning(
                    "sleep(): consolidate=True but storage is unavailable; skipping consolidation"
                )
            _consolidation_handled_close = False
            logger.debug("sleep(): consolidate=False -- minimal close for session %s", sid)

        if self.storage is not None and not _consolidation_handled_close:
            try:
                await self.storage.close_session(
                    session_id=sid,
                    owner_id=self.config.owner_id,
                    ended_at=_now(),
                    status="sleeping",
                )
                logger.info("Session %s closed (sleep)", sid)
            except Exception as exc:
                err = f"Failed to close session: {exc}"
                logger.warning(err)
                errors.append(err)

        # Clear working memory for the ended session
        if getattr(self, "working_memory", None) is not None:
            try:
                cleared = await self.working_memory.clear_session(sid)
                if cleared > 0:
                    logger.info("Cleared %d working memory entries for session %s", cleared, sid)
            except Exception as exc:
                err = f"Failed to clear working memory for session {sid}: {exc}"
                logger.warning(err)
                errors.append(err)

        # Emit SESSION_ENDED event
        if getattr(self, "event_emitter", None) is not None:
            try:
                from cortex.events.emitter import CortexEvent, EventType
                await self.event_emitter.emit(CortexEvent(
                    event_type=EventType.SESSION_ENDED,
                    owner_id=self.config.owner_id,
                    payload={"session_id": sid},
                    session_id=sid,
                ))
            except Exception as emit_exc:
                logger.debug("Event emit failed (SESSION_ENDED): %s", emit_exc)

        self._session_id = None
        return SleepReport(
            session_id=sid,
            memories_summarised=memories_summarised,
            errors=errors,
        )

    # =========================================================================
    # DREAM CYCLE (M6a/M6b full consolidation)
    # =========================================================================

    async def dream(self, owner_id: str = "default") -> Any:
        """Trigger the full nightly dream cycle (Electric Sheep consolidation).

        Wires CircadianEngine + SleepConsolidator and runs the complete dream
        pipeline: orphan recovery, Ebbinghaus decay, claim consolidation,
        contradiction resolution, cornerstone proposals, and journal generation.

        This is the canonical entry point for SDK and HTTP callers; MCP uses
        the same logic internally.

        Args:
            owner_id: Memory owner namespace.

        Returns:
            DreamReport dataclass instance.
        """
        from cortex.circadian.engine import CircadianEngine
        from cortex.circadian.sleep import SleepConsolidator

        from cortex.identity.drift_calculator import DriftCalculator
        from cortex.deprecation.pipeline import DeprecationPipeline

        drift_calculator = DriftCalculator(
            storage=self.storage,
            llm=self.llm,
            config=self.config,
        )
        deprecation_pipeline = DeprecationPipeline(
            storage=self.storage,
            config=self.config,
        )
        engine = CircadianEngine(
            storage=self.storage,
            config=self.config,
            llm=self.llm,
            cornerstone_guardian=self.cornerstone_guardian,
            drift_calculator=drift_calculator,
            deprecation_pipeline=deprecation_pipeline,
            promotion_scorer=getattr(self, "promotion_scorer", None),
        )
        return await engine.dream(owner_id=owner_id)

    # =========================================================================
    # FEEDBACK
    # =========================================================================

    async def feedback(
        self,
        memory_id: str,
        helpful: bool,
        context: Optional[str] = None,
        target_type: str = "claim",
    ) -> Any:
        """
        Submit retrieval feedback for a memory.

        Args:
            memory_id:   The memory being rated.
            helpful:     True = helpful, False = harmful.
            context:     Optional free-form context string.
            target_type: 'claim' | 'cornerstone' | 'procedure' | etc.

        Returns:
            MemoryFeedback record or None on error.
        """
        if self.feedback_system is None:
            logger.error("FeedbackSystem not available")
            return None
        try:
            result = await self.feedback_system.submit_feedback(
                memory_id=memory_id,
                helpful=helpful,
                context=context,
                target_type=target_type,
            )
            # Emit FEEDBACK_SUBMITTED event
            if getattr(self, "event_emitter", None) is not None and result is not None:
                try:
                    from cortex.events.emitter import CortexEvent, EventType
                    await self.event_emitter.emit(CortexEvent(
                        event_type=EventType.FEEDBACK_SUBMITTED,
                        owner_id=self.config.owner_id,
                        payload={
                            "memory_id": memory_id,
                            "helpful": helpful,
                            "target_type": target_type,
                            "feedback_id": getattr(result, "id", ""),
                        },
                        session_id=self._session_id,
                    ))
                except Exception as emit_exc:
                    logger.debug("Event emit failed (FEEDBACK_SUBMITTED): %s", emit_exc)
            return result
        except Exception as exc:
            logger.error("feedback submission failed: %s", exc, exc_info=True)
            return None

    # =========================================================================
    # DIALECTIC (M14) — Ask your memory
    # =========================================================================

    async def ask(
        self,
        query: str,
        owner_id: Optional[str] = None,
        history: Optional[List[Dict[str, Any]]] = None,
        mode: str = "fast",
        max_steps: int = 5,
    ) -> "AskResult":
        """
        Ask a question and synthesise an answer from retrieved memories.

        Two modes are supported:
            - ``"fast"`` (default): single-shot retrieval + LLM synthesis.
            - ``"agentic"``: multi-step retrieval loop for complex questions.
              Decomposes the question, retrieves per sub-query, checks
              sufficiency, and synthesises a final grounded answer.

        If *history* is provided (fast mode only), passes it to
        ask_with_history() for follow-up question support.

        Args:
            query:     Natural-language question.
            owner_id:  Memory namespace (defaults to plugin config owner_id).
            history:   Optional prior conversation turns — list of {role, content}.
                       Ignored when mode="agentic".
            mode:      ``"fast"`` (default) or ``"agentic"``.
            max_steps: Maximum retrieval steps for agentic mode (default 5).

        Returns:
            AskResult with synthesised answer, source memories, and confidence.
        """
        effective_owner_id = owner_id or self.config.owner_id
        errors: List[str] = []

        if self.retrieval is None or self.llm is None:
            err = "Dialectic requires retrieval pipeline and LLM — one or both unavailable"
            logger.error(err)
            return AskResult(
                answer="I cannot answer: the memory system is not fully initialised.",
                errors=[err],
                ok=False,
            )

        try:
            from cortex.dialectic.engine import DialecticEngine

            engine = DialecticEngine(
                retrieval_engine=self.retrieval,
                llm_provider=self.llm,
                config=self.config,
            )

            if mode == "agentic":
                response = await engine.ask_agentic(
                    query=query,
                    owner_id=effective_owner_id,
                    max_steps=max_steps,
                )
            elif history:
                response = await engine.ask_with_history(
                    query=query,
                    owner_id=effective_owner_id,
                    history=history,
                )
            else:
                response = await engine.ask(
                    query=query,
                    owner_id=effective_owner_id,
                )

            return AskResult(
                answer=response.answer,
                sources=response.sources,
                confidence=response.confidence,
                reasoning=response.reasoning,
                errors=errors,
                ok=True,
            )

        except Exception as exc:
            err = f"Dialectic ask failed: {exc}"
            logger.error(err, exc_info=True)
            return AskResult(
                answer="An error occurred while answering your question.",
                errors=[err],
                ok=False,
            )

    # =========================================================================
    # HEALTH
    # =========================================================================

    async def health(self) -> HealthReport:
        """
        Return a system status report.

        Checks every module for liveness. Returns HealthReport with per-module
        status strings and a top-level ok flag.
        """
        errors: List[str] = list(self._init_errors)
        modules: Dict[str, str] = {}

        # Storage
        if self.storage is None:
            modules["storage"] = "unavailable"
        else:
            try:
                await self.storage.ping()
                modules["storage"] = "ok"
            except Exception as exc:
                modules["storage"] = f"error: {exc}"
                errors.append(f"storage health: {exc}")

        # All other modules — just check presence
        module_attrs = {
            "extractor": "extraction",
            "entity_resolver": "entity_resolution",
            "reconciler": "reconciliation",
            "cornerstone_guardian": "cornerstone",
            "token_budget_manager": "token_budget",
            "retrieval": "retrieval",
            "feedback_system": "feedback",
            "llm": "llm",
        }
        for attr, label in module_attrs.items():
            obj = getattr(self, attr, None)
            modules[label] = "ok" if obj is not None else "unavailable"

        # Check sqlite-vec availability (informational — does not affect ok status)
        if self.storage is not None:
            vec_loaded = getattr(self.storage, "_vec_loaded", False)
            modules["sqlite_vec_available"] = "ok" if vec_loaded else "unavailable"

        # Quality Gates (#368)
        if self.config.quality_gates_enabled and self._metrics is not None:
            try:
                from cortex.core.quality_gates import QualityGateEngine
                gate_engine = QualityGateEngine(
                    metrics=self._metrics,
                    config=self.config,
                )
                gate_results = await gate_engine.check_all()
                failed_gates = [r for r in gate_results if not r.passed]
                modules["quality_gates"] = "ok" if not failed_gates else "degraded"
                for r in failed_gates:
                    if r.gate.action == "block":
                        errors.append(f"quality_gate/{r.gate.name}: {r.message}")
                    else:
                        # warn/degrade don't affect overall ok
                        modules[f"gate/{r.gate.name}"] = r.message
            except Exception as exc:
                logger.warning("Quality gate evaluation failed: %s", exc)
                modules["quality_gates"] = f"error: {exc}"

        ok = all(
            v == "ok" for k, v in modules.items()
            if k not in ("sqlite_vec_available", "quality_gates")
            and not k.startswith("gate/")
        ) and not errors

        return HealthReport(
            ok=ok,
            storage=modules.get("storage", "unknown"),
            modules=modules,
            errors=errors,
        )
