"""Memory dreaming — three-phase consolidation engine.

This is the **engine** for Phase 6 (Memory Dreaming) of the Rule 12
migration plan. It is deliberately thin: all strategy lives in the three
``memory-dream-*`` skills (SKILL.md files + config/schedule.yaml). This
module's only job is to:

  1. Read the right input set for each phase (light: recent chat turns;
     deep: hippocampal episodes from the last 24h; REM: neocortical
     triples from the last 7 days).
  2. Build the LLM call from the skill's system prompt + the input set.
  3. Call the LLM at the right model tier (read from skill config).
  4. Parse the LLM's structured JSON response against the documented
     schema in the skill.
  5. Write the resulting triples through ``KnowledgeGraph.add_triple``
     with the right ``system_layer``, ``episode_id``, ``situational_context``,
     ``affective_weight``, ``justified_by``, and ``epistemic_status``.
  6. Emit ``ActivityLogger`` and ``DecisionLogger`` events for the cycle.
  7. Return a structured result the cron / agent / API can inspect.

**The engine contains zero hardcoded thresholds, prompts, or rules.**
Every knob comes from the skill config YAML. If you find yourself wanting
to tune behavior here, you're touching the wrong file — edit the skill
or its config instead. (And if the arch test catches you, that's the
test working as designed.)

**The engine never crashes the cron loop.** Every phase wraps its work
in try/except, records failures via the work queue (so they retry with
exponential backoff), and returns a result dict instead of raising. A
broken LLM provider should not silently kill the consolidation pipeline.

Architecture: see CLAUDE.md Rule 12 + the docstring on
``KnowledgeGraph.add_triple`` for the CLS field semantics, and the three
SKILL.md files under ``defaults/skills/core/memory-dream-*/`` for the
strategy each phase implements.
"""

from __future__ import annotations

import asyncio
import hashlib
import json
import logging
import re
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from agntspace.llm.base import LLMProvider
    from agntspace.memory.graph import KnowledgeGraph
    from agntspace.skills.loader import SkillLoader

log = logging.getLogger(__name__)


# ── Phase identifiers ──────────────────────────────────────────────────────
# These are stable internal symbols, not user-facing strategy. They appear
# in activity events, decision rationales, and the skill names. Treat them
# as ASCII identifiers per Rule 11.

PHASE_LIGHT = "light"
PHASE_DEEP = "deep"
PHASE_REM = "rem"

_SKILL_NAME = {
    PHASE_LIGHT: "memory-dream-light",
    PHASE_DEEP: "memory-dream-deep",
    PHASE_REM: "memory-dream-rem",
}


# ── Result shape ───────────────────────────────────────────────────────────


def _empty_result(phase: str) -> dict:
    """Standard result shape for any dream phase invocation."""
    return {
        "phase": phase,
        "success": True,
        "skill_loaded": False,
        "input_units": 0,
        "triples_written": 0,
        "rejected_hypotheses": 0,
        "predictions_validated": 0,
        "contradictions_surfaced": 0,
        "episodes_consolidated": 0,
        "model_tier": None,
        "duration_ms": 0,
        "skipped_reason": None,
        "error": None,
    }


# ── Schedule loading ───────────────────────────────────────────────────────


def load_phase_schedule(loader: SkillLoader, phase: str) -> dict:
    """Load a dream phase's schedule.yaml.

    Returns the parsed dict, or an empty dict if the loader is missing
    or the file isn't there. The handler treats an empty schedule as
    "use the documented defaults" — never as "fail the run". Engine
    must be tolerant of missing strategy files.
    """
    skill = _SKILL_NAME.get(phase)
    if not skill or loader is None:
        return {}
    try:
        data = loader.load_skill_config_file(skill, "schedule.yaml")
    except Exception:
        log.debug("dream: schedule.yaml load failed for %s", phase, exc_info=True)
        return {}
    return data if isinstance(data, dict) else {}


# ── Episode ID generation ──────────────────────────────────────────────────


def make_episode_id(source_ref: str, when: datetime | None = None) -> str:
    """Generate an episode_id of the form ``ep-<iso8601>-<short_hash>``.

    The ISO timestamp orders episodes chronologically when listed; the
    short hash disambiguates episodes that fall within the same minute
    (which happens during rapid chat turns). Hash is 4 hex chars from
    sha1 of the source_ref — enough to disambiguate ~65k siblings,
    cheap to compute, and stable across runs (so the same source_ref
    at the same minute always produces the same id, which lets retries
    be idempotent).
    """
    when = when or datetime.now(UTC)
    iso_minute = when.strftime("%Y-%m-%dT%H:%M:00")
    h = hashlib.sha1(source_ref.encode("utf-8", errors="replace")).hexdigest()[:4]
    return f"ep-{iso_minute}-{h}"


# ── JSON extraction from LLM output ────────────────────────────────────────


_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", re.DOTALL)


def extract_json_from_response(response: str) -> Any:
    """Pull a JSON object or array out of the LLM's response.

    LLMs at fast/capable tiers reliably wrap structured output in
    fenced ```json blocks but sometimes return bare JSON. This helper
    tries the fenced form first, then falls back to "find the first
    { or [ and parse from there". Returns ``None`` on failure rather
    than raising — the dream phase must degrade gracefully when the
    LLM produces malformed output.
    """
    if not response or not isinstance(response, str):
        return None

    # Try fenced block first
    match = _JSON_BLOCK_RE.search(response)
    if match:
        try:
            return json.loads(match.group(1))
        except json.JSONDecodeError:
            log.debug("dream: fenced JSON block parse failed; trying bare")

    # Fallback: find the first { or [ and use json.loads directly.
    # The naive depth-scan approach fails on JSON where keys or values
    # contain literal brace/bracket characters (e.g. {"{": null}) because
    # it can't distinguish structural delimiters from string contents.
    # json.loads handles this correctly, so we just try increasingly
    # large slices of the response starting from the first opener.
    response = response.strip()
    for opener in ("{", "["):
        start = response.find(opener)
        if start == -1:
            continue
        # Try parsing from the opener to progressively earlier endpoints.
        # json.loads will tell us exactly when the JSON ends.
        candidate = response[start:]
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            pass
        # If that failed (trailing garbage after the JSON), try a raw
        # parse which may succeed on a prefix — json.JSONDecoder can
        # handle this via raw_decode.
        try:
            decoder = json.JSONDecoder()
            obj, _ = decoder.raw_decode(candidate)
            return obj
        except (json.JSONDecodeError, ValueError):
            pass
    return None


# ── DreamPhaseHandler ──────────────────────────────────────────────────────


class DreamPhaseHandler:
    """Three-phase memory consolidation engine.

    Constructed once per process and reused for all dream invocations.
    Holds references to the knowledge graph, skill loader, LLM provider,
    and (optionally) activity / decision loggers and a model router for
    tier resolution. None of the references are required — when something
    is missing, the corresponding behavior degrades gracefully (no LLM
    means the phase records 'no extraction performed' and exits cleanly).
    """

    def __init__(
        self,
        graph: KnowledgeGraph,
        skill_loader: SkillLoader | None = None,
        llm: LLMProvider | None = None,
        model_router: object | None = None,
        activity_logger: object | None = None,
        decision_logger: object | None = None,
    ) -> None:
        self._graph = graph
        self._skill_loader = skill_loader
        self._llm = llm
        self._model_router = model_router
        self._activity = activity_logger
        self._decisions = decision_logger
        # Serialize dream phases so concurrent cron firings don't
        # race on graph reads/writes. Light + deep could overlap if
        # light runs long and deep fires on schedule.
        self._phase_lock = asyncio.Lock()

    # ── Phase entry points ─────────────────────────────────────────────────

    async def run_light(self, recent_units: list[dict]) -> dict:
        """Light dream — capture recent experience as hippocampal episodes.

        ``recent_units`` is a list of dicts shaped like::

            {
                "source_unit": "chat_turn|journal_entry|vault_edit",
                "source_ref": "conv-123:turn-45" or "journal/2026-04-11.md",
                "content": "<the actual text>",
                "when": <iso8601 string>,  # optional, defaults to now
            }

        The handler walks each unit, sends it to the LLM with the
        memory-dream-light skill prompt, parses the structured response,
        and writes the resulting hippocampal triples through
        ``graph.add_triple`` with ``system_layer="hippocampal"``,
        ``episode_id`` set, and ``situational_context`` populated.

        Returns a result dict with counts and any errors. Never raises.
        """
        async with self._phase_lock:
            return await self._run_light_inner(recent_units)

    async def _run_light_inner(self, recent_units: list[dict]) -> dict:
        """Light dream body — caller holds ``_phase_lock``."""
        result = _empty_result(PHASE_LIGHT)
        if not recent_units:
            result["skipped_reason"] = "no_input"
            return result

        schedule = load_phase_schedule(self._skill_loader, PHASE_LIGHT)
        result["skill_loaded"] = bool(schedule)
        max_episodes = int(schedule.get("max_episodes_per_run", 30) or 30)
        if max_episodes > 0 and len(recent_units) > max_episodes:
            log.info(
                "dream-light: capping %d input units to %d (per skill config)",
                len(recent_units), max_episodes,
            )
            recent_units = recent_units[:max_episodes]
        result["input_units"] = len(recent_units)
        result["model_tier"] = schedule.get("model_tier", "fast")

        if self._llm is None:
            # No LLM available — record the empty pass and exit cleanly.
            # The cron will retry next cycle when (hopefully) the LLM
            # is back. Work queue handles persistent failure.
            result["skipped_reason"] = "no_llm_available"
            self._emit_activity("light_dream_skipped", "No LLM available", result)
            return result

        started = datetime.now(UTC)
        try:
            for unit in recent_units:
                episode_count = await self._process_light_unit(unit, schedule)
                result["triples_written"] += episode_count
            result["duration_ms"] = int(
                (datetime.now(UTC) - started).total_seconds() * 1000
            )
        except Exception as exc:  # pragma: no cover — defensive net
            log.exception("dream-light: top-level handler crashed")
            result["success"] = False
            result["error"] = repr(exc)

        # Persist the accumulated hippocampal triples + any new entities
        # the LLM introduced. ``add_triple`` only marks the graph dirty;
        # without this save the dream's work evaporates at process exit.
        if result["triples_written"] > 0:
            try:
                self._graph.save()
            except Exception:
                log.exception("dream-light: graph.save() failed")

        self._emit_activity(
            "light_dream_completed",
            f"Captured {result['triples_written']} hippocampal triples from {result['input_units']} units",
            result,
        )
        self._write_dream_file(PHASE_LIGHT, result)
        return result

    async def run_deep(self, hippocampal_triples: list[dict]) -> dict:
        """Deep dream — extract neocortical patterns from hippocampal input.

        ``hippocampal_triples`` is the set of hippocampal triples from the
        deep phase's lookback window (default 24h). The handler groups them
        by episode_id, sends them to the LLM with the memory-dream-deep skill
        prompt, parses the structured response, and writes the resulting
        neocortical triples with explicit ``justified_by`` chains pointing
        back to the source hippocampal triple ids.

        Contradictions surfaced by the LLM are NOT auto-resolved — they're
        recorded as high-importance events for user review.

        Returns a result dict; never raises.
        """
        async with self._phase_lock:
            return await self._run_deep_inner(hippocampal_triples)

    async def _run_deep_inner(self, hippocampal_triples: list[dict]) -> dict:
        """Deep dream body — caller holds ``_phase_lock``."""
        result = _empty_result(PHASE_DEEP)
        if not hippocampal_triples:
            result["skipped_reason"] = "no_hippocampal_input"
            return result

        schedule = load_phase_schedule(self._skill_loader, PHASE_DEEP)
        result["skill_loaded"] = bool(schedule)
        result["input_units"] = len(hippocampal_triples)
        result["model_tier"] = schedule.get("model_tier", "capable")

        if self._llm is None:
            result["skipped_reason"] = "no_llm_available"
            self._emit_activity("deep_dream_skipped", "No LLM available", result)
            return result

        started = datetime.now(UTC)
        try:
            parsed = await self._call_dream_llm(
                phase=PHASE_DEEP,
                schedule=schedule,
                payload=self._serialize_hippocampal_input(hippocampal_triples),
            )
            if parsed is None:
                result["skipped_reason"] = "llm_no_parseable_output"
                self._emit_activity(
                    "deep_dream_no_output",
                    "LLM returned no parseable JSON",
                    result,
                    importance="high",
                )
                return result

            written = self._write_deep_outputs(parsed, schedule)
            result["triples_written"] = written["neocortical_triples"]
            result["predictions_validated"] = written["predictions_validated"]
            result["contradictions_surfaced"] = written["contradictions_surfaced"]
            result["duration_ms"] = int(
                (datetime.now(UTC) - started).total_seconds() * 1000
            )
        except Exception as exc:  # pragma: no cover — defensive net
            log.exception("dream-deep: top-level handler crashed")
            result["success"] = False
            result["error"] = repr(exc)

        # Same reason as light dream: persist the dirty graph now.
        if result["triples_written"] > 0 or result.get("contradictions_surfaced", 0) > 0:
            try:
                self._graph.save()
            except Exception:
                log.exception("dream-deep: graph.save() failed")

        self._emit_activity(
            "deep_dream_completed",
            f"Wrote {result['triples_written']} neocortical patterns; surfaced {result['contradictions_surfaced']} contradictions",
            result,
        )
        # Pass the parsed neocortical triples to the file writer
        neo_triples = parsed.get("neocortical_triples", []) if isinstance(parsed, dict) else []
        self._write_dream_file(PHASE_DEEP, result, neocortical_triples=neo_triples)
        # Record prediction accuracy snapshot for the trend chart (UI-7).
        # One line appended to memory/predictions-history.jsonl per deep
        # dream cycle. The file grows at most 365 lines/year (one per day).
        self._append_prediction_history()
        return result

    async def run_rem(self, neocortical_window: list[dict]) -> dict:
        """REM dream — cross-week patterns + counterfactual extraction.

        ``neocortical_window`` is the set of neocortical triples from the
        REM phase's lookback window (default 7 days). The handler sends
        them to the LLM with the memory-dream-rem skill prompt, parses
        the structured response, and:

          - Writes new neocortical triples for cross-week patterns
            (justified by the source neocortical triple ids).
          - Moves rejected hypotheses to the counterfactual layer
            (system_layer="counterfactual",
             epistemic_status="rejected_hypothesis"), preserving the
            defeating evidence in the situational_context.
          - Marks fully-consolidated old hippocampal episodes as
            superseded so their detail can decay naturally.

        Returns a result dict; never raises.
        """
        async with self._phase_lock:
            return await self._run_rem_inner(neocortical_window)

    async def _run_rem_inner(self, neocortical_window: list[dict]) -> dict:
        """REM dream body — caller holds ``_phase_lock``."""
        result = _empty_result(PHASE_REM)
        if not neocortical_window:
            result["skipped_reason"] = "no_neocortical_input"
            return result

        schedule = load_phase_schedule(self._skill_loader, PHASE_REM)
        result["skill_loaded"] = bool(schedule)
        result["input_units"] = len(neocortical_window)
        result["model_tier"] = schedule.get("model_tier", "reasoning")

        if self._llm is None:
            result["skipped_reason"] = "no_llm_available"
            self._emit_activity("rem_dream_skipped", "No LLM available", result)
            return result

        started = datetime.now(UTC)
        try:
            parsed = await self._call_dream_llm(
                phase=PHASE_REM,
                schedule=schedule,
                payload=self._serialize_neocortical_input(neocortical_window),
            )
            if parsed is None:
                result["skipped_reason"] = "llm_no_parseable_output"
                self._emit_activity(
                    "rem_dream_no_output",
                    "LLM returned no parseable JSON",
                    result,
                    importance="high",
                )
                return result

            written = self._write_rem_outputs(parsed, schedule)
            result["triples_written"] = written["neocortical_triples_added"]
            result["rejected_hypotheses"] = written["rejected_hypotheses"]
            result["episodes_consolidated"] = written["episodes_consolidated"]
            result["duration_ms"] = int(
                (datetime.now(UTC) - started).total_seconds() * 1000
            )
        except Exception as exc:  # pragma: no cover — defensive net
            log.exception("dream-rem: top-level handler crashed")
            result["success"] = False
            result["error"] = repr(exc)

        # Persist any new normative patterns, rejected-hypothesis moves,
        # and episode consolidation mutations. See _run_light_inner for
        # the full rationale.
        if (
            result["triples_written"] > 0
            or result.get("rejected_hypotheses", 0) > 0
            or result.get("episodes_consolidated", 0) > 0
        ):
            try:
                self._graph.save()
            except Exception:
                log.exception("dream-rem: graph.save() failed")

        self._emit_activity(
            "rem_dream_completed",
            f"Added {result['triples_written']} normative patterns; rejected {result['rejected_hypotheses']} hypotheses; consolidated {result['episodes_consolidated']} episodes",
            result,
        )
        rej_list = parsed.get("hypotheses_rejected", []) if isinstance(parsed, dict) else []
        self._write_dream_file(PHASE_REM, result, rejected=rej_list)
        return result

    # ── Per-unit processing helpers ────────────────────────────────────────

    async def _process_light_unit(self, unit: dict, schedule: dict) -> int:
        """Process one chat turn / journal entry / vault edit into hippocampal
        triples. Returns the number of triples written for this unit."""
        source_ref = str(unit.get("source_ref", ""))
        content = str(unit.get("content", ""))
        when_iso = unit.get("when")
        if when_iso:
            try:
                when = datetime.fromisoformat(when_iso.replace("Z", "+00:00"))
            except (ValueError, TypeError):
                when = datetime.now(UTC)
        else:
            when = datetime.now(UTC)

        episode_id = make_episode_id(source_ref, when)

        # Check idempotency: if any triple already exists with this
        # episode_id, the unit was already processed. Skip cleanly.
        if self._graph.triples_by_episode(episode_id):
            log.debug("dream-light: episode %s already processed, skipping", episode_id)
            return 0

        payload_obj = {
            "source_unit": unit.get("source_unit", "chat_turn"),
            "source_ref": source_ref,
            "content": content,
            "when": when.isoformat(),
        }
        # Pass along the canonical user name when the caller tagged this
        # unit — the skill prompt uses it to resolve first-person
        # pronouns ("I", "me", "my") to a registered entity.
        user_name = unit.get("user_name")
        if user_name:
            payload_obj["user_name"] = user_name
        parsed = await self._call_dream_llm(
            phase=PHASE_LIGHT,
            schedule=schedule,
            payload=json.dumps(payload_obj),
        )
        if parsed is None:
            self._emit_activity(
                "light_dream_extraction_failed",
                f"No parseable output for {source_ref}",
                {"source_ref": source_ref, "episode_id": episode_id},
                importance="medium",
            )
            return 0

        # Light dream returns a list of episode objects (or one wrapped in a list)
        episodes = parsed if isinstance(parsed, list) else [parsed]
        written = 0
        for ep in episodes:
            if not isinstance(ep, dict):
                continue
            written += self._write_light_episode(ep, episode_id, schedule)
        return written

    def _write_light_episode(
        self, ep: dict, fallback_episode_id: str, schedule: dict
    ) -> int:
        """Write all triples in one light-dream episode object to the graph.

        Each triple gets ``system_layer="hippocampal"``, the episode_id,
        and the situational_context + affective_weight from the episode
        envelope. Returns the count of triples successfully written."""
        episode_id = ep.get("episode_id") or fallback_episode_id
        situational = ep.get("situational_context") or {}
        if not isinstance(situational, dict):
            situational = {}
        try:
            affective = float(ep.get("affective_weight", 0.0))
        except (TypeError, ValueError):
            affective = 0.0
        affective = max(0.0, min(1.0, affective))

        triples = ep.get("triples")
        if not isinstance(triples, list):
            return 0

        written = 0
        for t in triples:
            if not isinstance(t, dict):
                continue
            subject = t.get("subject")
            predicate = t.get("predicate")
            obj = t.get("object")
            if not subject or not predicate or obj is None:
                continue
            try:
                self._graph.add_triple(
                    subject=str(subject),
                    predicate=str(predicate),
                    obj=str(obj),
                    subject_type=str(t.get("subject_type", "concept")),
                    object_type=str(t.get("object_type", "concept")),
                    knowledge_type=str(t.get("knowledge_type", "episodic")),
                    epistemic_status=str(t.get("epistemic_status", "believed")),
                    confidence=float(t.get("confidence", 0.7)),
                    authority="system",
                    system_layer="hippocampal",
                    episode_id=episode_id,
                    situational_context=situational,
                    affective_weight=affective,
                )
                written += 1
            except Exception:
                log.debug(
                    "dream-light: add_triple failed for %s/%s/%s",
                    subject, predicate, obj, exc_info=True,
                )

        if schedule.get("emit_per_episode_events", False):
            self._emit_activity(
                "light_dream_captured",
                f"Captured episode {episode_id} with {written} triples",
                {
                    "episode_id": episode_id,
                    "triples": written,
                    "affective_weight": affective,
                    "mood": situational.get("mood"),
                },
            )
        return written

    # ── Deep / REM output writers ──────────────────────────────────────────

    def _write_deep_outputs(self, parsed: dict, schedule: dict) -> dict:
        """Write the neocortical triples + handle predictions + contradictions."""
        out = {
            "neocortical_triples": 0,
            "predictions_validated": 0,
            "contradictions_surfaced": 0,
        }

        # Build a map from episode_id → list of triple ids that were
        # extracted from that episode. We use this to translate the LLM's
        # ``justified_by_episodes`` references into ``justified_by`` triple
        # ids on the new neocortical triples.
        ep_to_tids: dict[str, list[int]] = {}
        for t in self._graph._triples:  # noqa: SLF001 — engine-internal access
            ep = t.get("episode_id")
            if ep:
                ep_to_tids.setdefault(ep, []).append(t.get("id"))

        max_neo = int(schedule.get("max_neocortical_per_run", 30) or 30)
        new_triples = parsed.get("neocortical_triples")
        if isinstance(new_triples, list):
            for nt in new_triples[:max_neo]:
                if not isinstance(nt, dict):
                    continue
                tid = self._add_neocortical_triple(nt, ep_to_tids)
                if tid is not None:
                    out["neocortical_triples"] += 1

        validations = parsed.get("predictions_validated")
        if isinstance(validations, list):
            for v in validations:
                if not isinstance(v, dict):
                    continue
                if self._apply_prediction_validation(v):
                    out["predictions_validated"] += 1

        contradictions = parsed.get("contradictions_surfaced")
        if isinstance(contradictions, list):
            for c in contradictions:
                if not isinstance(c, dict):
                    continue
                self._surface_contradiction(c)
                out["contradictions_surfaced"] += 1

        return out

    def _add_neocortical_triple(
        self, nt: dict, ep_to_tids: dict[str, list[int]]
    ) -> int | None:
        """Write one neocortical triple with justified_by translated from
        episode_ids to triple ids. Returns the triple id, or None on
        validation failure."""
        subject = nt.get("subject")
        predicate = nt.get("predicate")
        obj = nt.get("object")
        if not subject or not predicate or obj is None:
            return None

        justifying_episodes = nt.get("justified_by_episodes") or []
        justified_tids: list[int] = []
        for ep in justifying_episodes:
            if isinstance(ep, str):
                justified_tids.extend(ep_to_tids.get(ep, []))

        try:
            tid = self._graph.add_triple(
                subject=str(subject),
                predicate=str(predicate),
                obj=str(obj),
                subject_type=str(nt.get("subject_type", "concept")),
                object_type=str(nt.get("object_type", "concept")),
                knowledge_type=str(nt.get("knowledge_type", "declarative")),
                epistemic_status=str(nt.get("epistemic_status", "believed")),
                confidence=float(nt.get("confidence", 0.75)),
                authority="system",
                system_layer="neocortical",
            )
            # Backfill justified_by post-construction (the constructor
            # initializes it to []). The deep dream contract is that
            # every neocortical triple has at least 2 hippocampal sources.
            for t in self._graph._triples:  # noqa: SLF001
                if t.get("id") == tid:
                    t["justified_by"] = justified_tids
                    if "rationale" in nt:
                        t.setdefault("notes", []).append(str(nt["rationale"]))
                    break
            return tid
        except Exception:
            log.debug("dream-deep: add_triple failed for %s/%s/%s", subject, predicate, obj, exc_info=True)
            return None

    def _apply_prediction_validation(self, v: dict) -> bool:
        """Mark a triple's prediction_outcome based on what actually happened."""
        try:
            tid = int(v.get("triple_id_predicted"))
        except (TypeError, ValueError):
            return False
        outcome = v.get("outcome")
        if outcome not in ("confirmed", "surprising"):
            return False
        for t in self._graph._triples:  # noqa: SLF001
            if t.get("id") == tid:
                t["prediction_outcome"] = outcome
                if outcome == "confirmed":
                    t["confidence"] = min(1.0, float(t.get("confidence", 0.5)) + 0.05)
                    t["last_verified"] = datetime.now(UTC).isoformat()
                else:
                    t["epistemic_status"] = "uncertain"
                return True
        return False

    def _surface_contradiction(self, c: dict) -> None:
        """Record a contested fact for user review. Does NOT auto-supersede."""
        existing_id = c.get("existing_triple_id")
        # Mark the existing triple as contested
        for t in self._graph._triples:  # noqa: SLF001
            if t.get("id") == existing_id:
                t["epistemic_status"] = "contested"
                break
        self._emit_activity(
            "deep_dream_contradiction_surfaced",
            f"Conflicting fact for review: {c.get('extracted_subject')}/{c.get('extracted_predicate')}/{c.get('extracted_object')}",
            c,
            importance="high",
        )

    def _write_rem_outputs(self, parsed: dict, schedule: dict) -> dict:
        """Write REM-phase outputs: new neocortical patterns, rejected
        hypotheses to counterfactual layer, consolidated episodes."""
        out = {
            "neocortical_triples_added": 0,
            "rejected_hypotheses": 0,
            "episodes_consolidated": 0,
        }

        max_neo = int(schedule.get("max_neocortical_per_run", 15) or 15)
        max_rej = int(schedule.get("max_rejections_per_run", 10) or 10)

        # Build justified_by map for REM (different from deep — REM cites
        # neocortical triple IDs directly, not episode_ids).
        new_neo = parsed.get("neocortical_triples_added")
        if isinstance(new_neo, list):
            for nt in new_neo[:max_neo]:
                if not isinstance(nt, dict):
                    continue
                tid = self._add_rem_neocortical(nt)
                if tid is not None:
                    out["neocortical_triples_added"] += 1

        rejections = parsed.get("hypotheses_rejected")
        if isinstance(rejections, list):
            for r in rejections[:max_rej]:
                if not isinstance(r, dict):
                    continue
                if self._reject_hypothesis(r):
                    out["rejected_hypotheses"] += 1

        consolidations = parsed.get("episodes_consolidated")
        if isinstance(consolidations, list):
            for c in consolidations:
                if not isinstance(c, dict):
                    continue
                if self._consolidate_episode(c):
                    out["episodes_consolidated"] += 1

        return out

    def _add_rem_neocortical(self, nt: dict) -> int | None:
        """Write a REM-phase neocortical triple with justification chains."""
        subject = nt.get("subject")
        predicate = nt.get("predicate")
        obj = nt.get("object")
        if not subject or not predicate or obj is None:
            return None
        try:
            tid = self._graph.add_triple(
                subject=str(subject),
                predicate=str(predicate),
                obj=str(obj),
                subject_type=str(nt.get("subject_type", "concept")),
                object_type=str(nt.get("object_type", "concept")),
                knowledge_type=str(nt.get("knowledge_type", "normative")),
                epistemic_status=str(nt.get("epistemic_status", "believed")),
                confidence=float(nt.get("confidence", 0.8)),
                authority="system",
                system_layer="neocortical",
            )
            justifying_neo = nt.get("justified_by_neocortical_ids") or []
            justified_ints = [int(x) for x in justifying_neo if isinstance(x, (int, str)) and str(x).isdigit()]
            for t in self._graph._triples:  # noqa: SLF001
                if t.get("id") == tid:
                    t["justified_by"] = justified_ints
                    if "rationale" in nt:
                        t.setdefault("notes", []).append(str(nt["rationale"]))
                    break
            return tid
        except Exception:
            log.debug("dream-rem: add_triple failed for %s/%s/%s", subject, predicate, obj, exc_info=True)
            return None

    def _reject_hypothesis(self, r: dict) -> bool:
        """Move a hypothesis to the counterfactual layer.

        We do NOT delete the original triple. We mutate it in place to
        ``system_layer="counterfactual"`` + ``epistemic_status="rejected_hypothesis"``,
        and we record the defeating evidence in its situational_context.
        """
        rejected_id = r.get("rejected_triple_id")
        try:
            rejected_id = int(rejected_id)
        except (TypeError, ValueError):
            return False
        for t in self._graph._triples:  # noqa: SLF001
            if t.get("id") != rejected_id:
                continue
            # SAFETY: never reject explicit (user-confirmed) facts.
            if t.get("authority") == "explicit":
                self._emit_activity(
                    "rem_dream_rejection_blocked",
                    f"REM tried to reject user-confirmed triple #{rejected_id}; surfaced as contested instead",
                    {"triple_id": rejected_id, "reason": r.get("reason")},
                    importance="high",
                )
                t["epistemic_status"] = "contested"
                return False
            t["system_layer"] = "counterfactual"
            t["epistemic_status"] = "rejected_hypothesis"
            ctx = t.get("situational_context") or {}
            if not isinstance(ctx, dict):
                ctx = {}
            ctx["rejection_reason"] = r.get("reason")
            ctx["defeating_evidence_episodes"] = r.get("defeating_evidence_episode_ids", [])
            t["situational_context"] = ctx
            self._graph._dirty = True  # noqa: SLF001

            # If the LLM provided a replacement triple, write it to neocortical
            replacement = r.get("replacement_triple")
            if isinstance(replacement, dict):
                self._add_rem_neocortical(replacement)
            return True
        return False

    def _consolidate_episode(self, c: dict) -> bool:
        """Mark an old hippocampal episode as superseded — its content is now
        in the neocortical layer and the high-resolution detail can decay."""
        ep_id = c.get("episode_id")
        if not ep_id:
            return False
        marked = 0
        for t in self._graph.triples_by_episode(str(ep_id)):
            if t.get("system_layer") != "hippocampal":
                continue
            if t.get("epistemic_status") == "verified":
                # Verified hippocampal triples (rare — usually from user
                # approval) are not consolidated. Their explicit
                # confirmation makes them load-bearing on their own.
                continue
            t["epistemic_status"] = "superseded"
            self._graph._dirty = True  # noqa: SLF001
            marked += 1
        return marked > 0

    # ── LLM call ───────────────────────────────────────────────────────────

    async def _call_dream_llm(
        self, phase: str, schedule: dict, payload: str
    ) -> Any:
        """Call the LLM for a dream phase. Returns parsed JSON or None.

        The skill body provides the system prompt; the payload is the
        user message containing the input set. Model tier comes from
        the skill schedule. Tokens, temperature, etc. are LLM-provider
        defaults — dreams are not latency-sensitive.
        """
        system_prompt = self._get_skill_body(phase)
        if not system_prompt:
            log.warning("dream-%s: skill body missing, cannot run", phase)
            return None

        model = self._resolve_model_for_tier(schedule.get("model_tier"))
        # Lazy import — keeps dream.py importable without the LLM layer
        # during tests that stub the provider.
        from agntspace.llm.base import Message

        try:
            response = await self._llm.complete(
                messages=[Message(role="user", content=payload)],
                system=system_prompt,
                model=model,
                max_tokens=int(schedule.get("max_tokens_per_run", 60000) or 60000),
            )
        except Exception:
            log.exception("dream-%s: LLM call failed", phase)
            return None

        # complete() returns LLMResponse (dataclass with `.content`); old
        # tests / stubs may still hand back a raw string — support both.
        text = getattr(response, "content", response)
        return extract_json_from_response(text)

    def _get_skill_body(self, phase: str) -> str:
        """Read the SKILL.md body for the given phase from the loader."""
        if self._skill_loader is None:
            return ""
        skill_name = _SKILL_NAME.get(phase)
        if not skill_name:
            return ""
        try:
            skill = self._skill_loader.get_skill(skill_name)
        except Exception:
            return ""
        if not skill:
            return ""
        return getattr(skill, "body", "") or ""

    def _resolve_model_for_tier(self, tier: str | None) -> str | None:
        """Resolve a tier name to a concrete model via the model router."""
        if not tier or self._model_router is None:
            return None
        try:
            return self._model_router.resolve(tier)
        except Exception:
            return None

    # ── Input serialization ───────────────────────────────────────────────

    def _serialize_hippocampal_input(self, triples: list[dict]) -> str:
        """Format hippocampal triples for the deep-dream LLM call.

        Groups by episode_id so the LLM can see the structure of each
        moment as a whole. Includes the situational_context once per
        episode rather than per triple."""
        by_ep: dict[str, list[dict]] = {}
        for t in triples:
            ep = t.get("episode_id") or "unknown"
            by_ep.setdefault(ep, []).append(t)
        episodes = []
        for ep_id, ts in by_ep.items():
            sample = ts[0] if ts else {}
            episodes.append({
                "episode_id": ep_id,
                "situational_context": sample.get("situational_context", {}),
                "affective_weight": sample.get("affective_weight", 0.0),
                "triples": [
                    {
                        "id": t.get("id"),
                        "subject": t.get("subject"),
                        "predicate": t.get("predicate"),
                        "object": t.get("object") or t.get("object_literal"),
                        "knowledge_type": t.get("knowledge_type"),
                        "confidence": t.get("confidence"),
                    }
                    for t in ts
                ],
            })
        return json.dumps({"episodes": episodes}, default=str)

    def _serialize_neocortical_input(self, triples: list[dict]) -> str:
        """Format neocortical triples for the REM-dream LLM call."""
        items = [
            {
                "id": t.get("id"),
                "subject": t.get("subject"),
                "predicate": t.get("predicate"),
                "object": t.get("object") or t.get("object_literal"),
                "knowledge_type": t.get("knowledge_type"),
                "epistemic_status": t.get("epistemic_status"),
                "authority": t.get("authority"),
                "confidence": t.get("confidence"),
                "justified_by": t.get("justified_by", []),
                "source_date": t.get("source_date"),
            }
            for t in triples
        ]
        return json.dumps({"neocortical_triples": items}, default=str)

    # ── Logging ────────────────────────────────────────────────────────────

    def _emit_activity(
        self,
        action: str,
        summary: str,
        details: dict | None = None,
        importance: str = "medium",
    ) -> None:
        """Emit an ActivityLogger event for a dream cycle. Never raises."""
        if self._activity is None or not hasattr(self._activity, "log"):
            return
        try:
            self._activity.log(
                "memory",
                action,
                summary,
                details or {},
                importance=importance,
            )
        except Exception:
            log.debug("dream: activity.log failed", exc_info=True)

    # ── Prediction history recording ─────────────────────────────────

    def _append_prediction_history(self) -> None:
        """Append a prediction-stats snapshot to the JSONL history file.

        Called once per deep-dream cycle. Each line is one JSON object:
        ``{timestamp, confirmed, surprising, pending, accuracy}``.
        The file lives at ``memory/predictions-history.jsonl`` in the
        vault and grows at most 365 lines/year (one per daily deep dream).

        Never raises -- history recording is nice-to-have, not load-bearing.
        """
        writer = getattr(self._graph, "_writer", None)
        if writer is None:
            return
        # Walk all triples to compute stats (same logic as PredictionEngine.stats
        # but we don't require the prediction engine to be wired here).
        try:
            confirmed = surprising = pending = 0
            for t in self._graph._triples:  # noqa: SLF001
                outcome = t.get("prediction_outcome")
                if outcome == "pending":
                    pending += 1
                elif outcome == "confirmed":
                    confirmed += 1
                elif outcome == "surprising":
                    surprising += 1
            total = confirmed + surprising
            accuracy = round(confirmed / total, 3) if total > 0 else None
            now_iso = datetime.now(UTC).isoformat()
            entry = json.dumps({
                "timestamp": now_iso,
                "confirmed": confirmed,
                "surprising": surprising,
                "pending": pending,
                "accuracy": accuracy,
            })
            path = "memory/predictions-history.jsonl"
            writer.append(path, entry + "\n", trust_reason="prediction_history")
        except Exception:
            log.debug("dream: _append_prediction_history failed", exc_info=True)

    # ── Dream markdown output files ────────────────────────────────────

    def _write_dream_file(
        self,
        phase: str,
        result: dict,
        *,
        episodes: list[dict] | None = None,
        neocortical_triples: list[dict] | None = None,
        rejected: list[dict] | None = None,
    ) -> None:
        """Write a human-readable markdown summary of a dream cycle.

        Files land at ``memory/dreams/{phase}/YYYY-MM-DD-HHMM.md`` and
        are vault-readable in any text editor or Obsidian. They are
        narrative summaries, not raw JSON -- the structural triples
        live in the graph, these files capture *what the dream did*
        in prose the user can review.

        Never raises -- dream files are nice-to-have, not load-bearing.
        """
        writer = getattr(self._graph, "_writer", None)
        if writer is None:
            return
        try:
            now = datetime.now(UTC)
            filename = now.strftime("%Y-%m-%d-%H%M")
            path = f"memory/dreams/{phase}/{filename}.md"

            lines = [
                "---",
                f'title: "{phase.capitalize()} Dream -- {now.strftime("%Y-%m-%d %H:%M")}"',
                f'date: "{now.strftime("%Y-%m-%d")}"',
                f"phase: {phase}",
                "author: agent",
                "agent: memory-engine",
                f'description: "Memory consolidation -- {phase} dream cycle"',
                "---",
                "",
                f"# {phase.capitalize()} Dream",
                "",
                f"**Phase**: {phase} | **Model tier**: {result.get('model_tier', 'unknown')} | **Duration**: {result.get('duration_ms', 0)}ms",
                "",
            ]

            if phase == PHASE_LIGHT:
                lines.append(f"**Input units**: {result.get('input_units', 0)} | **Triples written**: {result.get('triples_written', 0)}")
                lines.append("")
                if episodes:
                    lines.append("## Episodes Captured")
                    lines.append("")
                    for ep in episodes[:20]:
                        ep_id = ep.get("episode_id", "?")
                        ctx = ep.get("situational_context", {})
                        mood = ctx.get("mood", "neutral")
                        quote = ctx.get("source_quote", "")[:120]
                        n_triples = len(ep.get("triples", []))
                        lines.append(f"- **{ep_id}** (mood: {mood}, {n_triples} triples)")
                        if quote:
                            lines.append(f'  > "{quote}"')
                    lines.append("")

            elif phase == PHASE_DEEP:
                lines.append(f"**Neocortical patterns**: {result.get('triples_written', 0)} | **Predictions validated**: {result.get('predictions_validated', 0)} | **Contradictions surfaced**: {result.get('contradictions_surfaced', 0)}")
                lines.append("")
                if neocortical_triples:
                    lines.append("## Patterns Extracted")
                    lines.append("")
                    for nt in neocortical_triples[:30]:
                        subj = nt.get("subject", "?")
                        pred = nt.get("predicate", "?").replace("_", " ")
                        obj = nt.get("object", "?")
                        rationale = nt.get("rationale", "")
                        lines.append(f"- **{subj}** {pred} **{obj}**")
                        if rationale:
                            lines.append(f"  *{rationale}*")
                    lines.append("")

            elif phase == PHASE_REM:
                lines.append(f"**Normative patterns**: {result.get('triples_written', 0)} | **Hypotheses rejected**: {result.get('rejected_hypotheses', 0)} | **Episodes consolidated**: {result.get('episodes_consolidated', 0)}")
                lines.append("")
                if rejected:
                    lines.append("## Hypotheses Rejected")
                    lines.append("")
                    for r in rejected[:10]:
                        reason = r.get("reason", "no reason given")
                        lines.append(f"- {reason}")
                    lines.append("")

            if result.get("error"):
                lines.append("## Error")
                lines.append("")
                lines.append(f"```\n{result['error']}\n```")
                lines.append("")

            content = "\n".join(lines)
            writer.write(path, content, trust_reason="dream_output")
        except Exception:
            log.debug("dream: _write_dream_file failed for %s", phase, exc_info=True)


# ── Input gathering helpers (Phase 6C) ──────────────────────────────────────
#
# These functions are pure data-access helpers — they read recent chat
# turns / hippocampal triples / neocortical triples from their respective
# stores and shape them as input dicts for the corresponding dream phase.
# They contain NO strategy: no thresholds, no filters beyond the time
# window, no priority logic. The dream phases themselves (driven by their
# skills) decide what to do with the input set.
#
# Tests call them directly with stub stores; the cron registration helper
# below wires them with the real ConversationMemory and KnowledgeGraph.


async def gather_recent_chat_turns(
    conversation_memory: object,
    *,
    lookback_hours: int = 12,
    max_turns: int = 50,
    now: datetime | None = None,
) -> list[dict]:
    """Pair user→assistant messages from the last N hours into chat turns.

    A "turn" is a user message followed by the next assistant response in
    the same conversation. Tool calls and system messages between them are
    folded into the turn's content. Each turn becomes one light dream input
    unit with shape:

        {
            "source_unit": "chat_turn",
            "source_ref": "<conversation_id>:<user_message_id>",
            "content": "user: ...\\nassistant: ...",
            "when": <iso8601 of the user message>,
        }

    The ``source_ref`` includes the user message id so the deterministic
    ``make_episode_id`` produces stable ids — re-running the light phase
    on the same window is idempotent.

    ``conversation_memory`` must expose ``get_messages_since(since_iso, limit)``
    returning a list of dicts with ``conversation_id``, ``message_id``,
    ``role``, ``content``, ``created_at``. We pass it as ``object`` rather
    than the real type to keep test stubs simple.
    """
    when = now or datetime.now(UTC)
    cutoff = (when - timedelta(hours=lookback_hours)).isoformat()
    try:
        rows = await conversation_memory.get_messages_since(cutoff, limit=max_turns * 4)
    except Exception:
        log.debug("dream: get_messages_since failed", exc_info=True)
        return []

    # Group by conversation, walk in order, pair user→assistant
    by_conv: dict[str, list[dict]] = {}
    for row in rows:
        by_conv.setdefault(row["conversation_id"], []).append(row)

    turns: list[dict] = []
    for conv_id, msgs in by_conv.items():
        i = 0
        while i < len(msgs):
            msg = msgs[i]
            if msg.get("role") == "user":
                # Find the next assistant response
                content_parts = [f"user: {msg.get('content', '')}"]
                user_msg_id = msg.get("message_id", "")
                user_when = msg.get("created_at", "")
                j = i + 1
                while j < len(msgs):
                    nxt = msgs[j]
                    nxt_role = nxt.get("role")
                    if nxt_role == "user":
                        # New turn starts before this one's assistant arrived
                        break
                    if nxt_role == "assistant":
                        content_parts.append(f"assistant: {nxt.get('content', '')}")
                        j += 1
                        break
                    # Tool/system message between user and assistant — fold in
                    content_parts.append(f"{nxt_role}: {nxt.get('content', '')}")
                    j += 1
                turns.append({
                    "source_unit": "chat_turn",
                    "source_ref": f"{conv_id}:{user_msg_id}",
                    "content": "\n".join(content_parts),
                    "when": user_when,
                })
                i = j
            else:
                i += 1

    # Hard cap. Light dream's own schedule.yaml further caps via max_episodes_per_run.
    if max_turns > 0 and len(turns) > max_turns:
        turns = turns[-max_turns:]
    return turns


def gather_hippocampal_window(
    graph: KnowledgeGraph,
    *,
    lookback_hours: int = 24,
    now: datetime | None = None,
) -> list[dict]:
    """Return hippocampal triples whose ``last_accessed`` is within the window.

    For freshly-added hippocampal triples ``last_accessed`` equals the
    creation time (``add_triple`` sets it to ``now`` at construction).
    Querying a triple later bumps ``last_accessed``, but in practice the
    deep dream's 24h window is generous enough that this is acceptable —
    a re-queried hippocampal triple is still recent and worth re-processing.

    The deep phase's strict input contract is "the agent's recent
    experience"; what matters is freshness, not creation timestamps.
    """
    when = now or datetime.now(UTC)
    cutoff_iso = (when - timedelta(hours=lookback_hours)).isoformat()
    out = []
    for t in graph.triples_by_layer("hippocampal"):
        last = t.get("last_accessed") or t.get("source_date") or ""
        if last >= cutoff_iso:
            out.append(t)
    return out


_JOURNAL_SECTION_RE = re.compile(r"^## ([^\n]+)$", re.MULTILINE)
_TIMESTAMP_LINE_RE = re.compile(r"^\s*-\s*(\d{2}):(\d{2})\s*—", re.MULTILINE)


def _split_journal_sections(body: str) -> list[tuple[str, str]]:
    """Split a journal file body into ``[(section_heading, section_content), ...]``.

    Sections are delimited by ``## Heading`` lines. Text before the first
    ``##`` is dropped (typically empty or frontmatter artifacts). Empty
    sections (no content beyond whitespace, HTML comments, or italic hints)
    are filtered out — they're placeholder scaffolding the user hasn't
    filled in and contain no semantic signal.
    """
    matches = list(_JOURNAL_SECTION_RE.finditer(body))
    if not matches:
        return []
    out: list[tuple[str, str]] = []
    for idx, m in enumerate(matches):
        heading = m.group(1).strip()
        content_start = m.end()
        content_end = matches[idx + 1].start() if idx + 1 < len(matches) else len(body)
        content = body[content_start:content_end]
        # Strip HTML comments and italic-only hint lines (template scaffolding)
        stripped = re.sub(r"<!--.*?-->", "", content, flags=re.DOTALL)
        stripped = re.sub(r"^\*[^*\n]+\*\s*$", "", stripped, flags=re.MULTILINE).strip()
        if stripped:
            out.append((heading, stripped))
    return out


async def gather_recent_journal_entries(
    vault_reader: object,
    *,
    lookback_hours: int = 12,
    max_entries: int = 30,
    now: datetime | None = None,
) -> list[dict]:
    """Emit one light-dream unit per non-empty user-journal section modified in window.

    Scans ``journal/user/*.md``. Each file is split into ``## Sections``;
    each non-empty section becomes one input unit::

        {
            "source_unit": "journal_entry",
            "source_ref": "journal/user/2026-04-18.md#thoughts",
            "content": "<section heading + body>",
            "when": <iso of first HH:MM in section, else file mtime>,
        }

    Section-level granularity means ``make_episode_id(source_ref, when)``
    is stable across runs — re-processing the same file doesn't create
    new episodes for untouched sections. A later edit to "Thoughts" only
    triggers re-extraction for that section, not the whole file.

    Only reads files whose mtime is within ``lookback_hours``. User-
    authored prose is the target; the agent-journal (``journal/agnt/``)
    is already LLM-structured output and isn't included here.

    Returns ``[]`` silently when the reader or journal directory is
    missing — the engine must degrade gracefully per Rule 12.
    """
    if vault_reader is None:
        return []
    when = now or datetime.now(UTC)
    cutoff_ts = (when - timedelta(hours=lookback_hours)).timestamp()

    try:
        files = vault_reader.list_files(directory="journal/user", pattern="*.md")
    except Exception:
        log.debug("dream: list_files(journal/user) failed", exc_info=True)
        return []

    units: list[dict] = []
    for path in files:
        try:
            mtime = path.stat().st_mtime
        except OSError:
            continue
        if mtime < cutoff_ts:
            continue

        try:
            doc = vault_reader.read(f"journal/user/{path.name}")
        except Exception:
            log.debug("dream: journal read failed for %s", path.name, exc_info=True)
            continue

        for heading, section_body in _split_journal_sections(doc.content):
            section_slug = re.sub(r"[^a-z0-9]+", "-", heading.lower()).strip("-") or "section"
            source_ref = f"journal/user/{path.name}#{section_slug}"
            # Extract first HH:MM timestamp in the section as "when", else file mtime
            ts_match = _TIMESTAMP_LINE_RE.search(section_body)
            if ts_match:
                date_part = path.stem.replace("user-journal_", "")
                try:
                    section_when = datetime.fromisoformat(
                        f"{date_part}T{ts_match.group(1)}:{ts_match.group(2)}:00+00:00"
                    ).isoformat()
                except ValueError:
                    section_when = datetime.fromtimestamp(mtime, UTC).isoformat()
            else:
                section_when = datetime.fromtimestamp(mtime, UTC).isoformat()

            units.append({
                "source_unit": "journal_entry",
                "source_ref": source_ref,
                "content": f"## {heading}\n\n{section_body}",
                "when": section_when,
            })

    if max_entries > 0 and len(units) > max_entries:
        # Newest sections win when over budget
        units.sort(key=lambda u: u.get("when", ""), reverse=True)
        units = units[:max_entries]
    return units


def gather_neocortical_window(
    graph: KnowledgeGraph,
    *,
    lookback_days: int = 7,
    now: datetime | None = None,
) -> list[dict]:
    """Return neocortical triples whose ``source_date`` is within the window.

    REM dream operates at daily granularity (its window is 7 days by
    default), so source_date (YYYY-MM-DD) is sufficient. Includes user-
    confirmed (explicit authority) triples in the input but the REM
    skill is responsible for never rejecting them — the safety rail
    lives in the handler (see ``DreamPhaseHandler._reject_hypothesis``).
    """
    when = now or datetime.now(UTC)
    cutoff_date = (when - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
    out = []
    for t in graph.triples_by_layer("neocortical"):
        d = t.get("source_date") or ""
        if d >= cutoff_date:
            out.append(t)
    return out


# ── Cron registration ──────────────────────────────────────────────────────


def _resolve_user_name(vault_reader: object | None) -> str | None:
    """Read the user's canonical name from USER.md for first-person resolution.

    Light-dream payloads are raw prose with no identity context — the LLM
    has no way to know who "I" / "me" / "my" refer to. Tagging every unit
    with ``user_name`` lets the skill prompt instruct the model to
    substitute the canonical name, so `(Brigita, child_of, "me")` becomes
    `(Brigita, child_of, "Romanas Wolfsborg")` — which the entity
    registry can resolve as a real node instead of an unlinked placeholder.

    Returns ``None`` when USER.md is missing, unreadable, or the Name
    field hasn't been filled in (still `[Your name]` placeholder). In
    that case the skill prompt should fall back to leaving pronouns
    unresolved rather than fabricating.
    """
    if vault_reader is None:
        return None
    try:
        doc = vault_reader.read("system/identity/USER.md")
        text = getattr(doc, "content", None) or getattr(doc, "raw", "") or str(doc)
    except Exception:
        return None
    if not text:
        return None
    m = re.search(r"\*\*Name\*\*:\s*(.+)", text)
    if not m:
        return None
    name = m.group(1).strip()
    # Skip unfilled placeholder markers like "[Your name]".
    if not name or name.startswith("["):
        return None
    return name


async def run_light_over_recent(
    handler: DreamPhaseHandler,
    conversation_memory: object | None,
    vault_reader: object | None,
    skill_loader: SkillLoader,
) -> dict:
    """Pull fresh units from all enabled sources and run one light-dream pass.

    Used by both the cron tick (safety net) and the journal-edit push
    trigger. Sources are toggleable via ``schedule.yaml``::

        sources:
          chat_turns: true        # default
          journal_entries: true   # default

    A disabled source contributes no units. If both are disabled or
    yield nothing, ``run_light`` still runs and records an empty pass
    (``skipped_reason: no_input``) so the cron tick leaves a trace.
    """
    schedule = load_phase_schedule(skill_loader, PHASE_LIGHT)
    lookback = int(schedule.get("lookback_hours", 12) or 12)
    max_units = int(schedule.get("max_episodes_per_run", 30) or 30)
    sources = schedule.get("sources") or {}
    # Defaults-on: if the YAML doesn't list a source at all, include it.
    use_chat = bool(sources.get("chat_turns", True))
    use_journal = bool(sources.get("journal_entries", True))

    units: list[dict] = []
    if use_chat and conversation_memory is not None:
        try:
            units += await gather_recent_chat_turns(
                conversation_memory,
                lookback_hours=lookback,
                max_turns=max_units,
            )
        except Exception:
            log.debug("dream: gather_recent_chat_turns failed", exc_info=True)
    if use_journal and vault_reader is not None:
        try:
            units += await gather_recent_journal_entries(
                vault_reader,
                lookback_hours=lookback,
                max_entries=max_units,
            )
        except Exception:
            log.debug("dream: gather_recent_journal_entries failed", exc_info=True)

    # Tag every unit with the canonical user name so the skill prompt
    # can resolve first-person pronouns to a real entity. Journal units
    # especially are almost always written in first person.
    user_name = _resolve_user_name(vault_reader)
    if user_name:
        for u in units:
            u.setdefault("user_name", user_name)

    return await handler.run_light(units)


async def register_dream_cron(
    cron: object,
    handler: DreamPhaseHandler,
    conversation_memory: object,
    graph: KnowledgeGraph,
    skill_loader: SkillLoader,
    vault_reader: object | None = None,
) -> dict[str, dict]:
    """Wire the three dream phases into the persistent cron scheduler.

    Reads the cron expression from each phase's ``schedule.yaml`` (so
    cadence stays user-editable per Rule 12) and registers the matching
    handler with the scheduler. Each handler closes over the gathering
    helpers and the dream handler instance.

    ``vault_reader`` is optional — when provided, the light dream also
    pulls user-journal sections as hippocampal episodes. When absent
    (or disabled via skill config), only chat turns are captured.

    Returns a dict mapping phase name → ``{schedule, registered}`` so
    callers can log what was registered. Never raises — a missing skill
    config falls back to the documented default cron expression and
    logs a warning.
    """
    if cron is None or not hasattr(cron, "register_handler") or not hasattr(cron, "add_job"):
        log.warning("dream: cron scheduler missing or incompatible; dreams will not be scheduled")
        return {}

    # ── Light dream action ──
    async def _action_light() -> str:
        result = await run_light_over_recent(
            handler, conversation_memory, vault_reader, skill_loader,
        )
        if not result.get("success", True):
            raise RuntimeError(f"light dream failed: {result.get('error', 'unknown')}")
        return f"light dream: {result.get('triples_written', 0)} hippocampal triples from {result.get('input_units', 0)} units"

    # ── Deep dream action ──
    async def _action_deep() -> str:
        schedule = load_phase_schedule(skill_loader, PHASE_DEEP)
        lookback = int(schedule.get("lookback_hours", 24) or 24)
        triples = gather_hippocampal_window(graph, lookback_hours=lookback)
        result = await handler.run_deep(triples)
        if not result.get("success", True):
            raise RuntimeError(f"deep dream failed: {result.get('error', 'unknown')}")
        return (
            f"deep dream: {result.get('triples_written', 0)} neocortical patterns, "
            f"{result.get('contradictions_surfaced', 0)} contradictions surfaced"
        )

    # ── REM dream action ──
    async def _action_rem() -> str:
        schedule = load_phase_schedule(skill_loader, PHASE_REM)
        lookback = int(schedule.get("lookback_days", 7) or 7)
        triples = gather_neocortical_window(graph, lookback_days=lookback)
        result = await handler.run_rem(triples)
        if not result.get("success", True):
            raise RuntimeError(f"rem dream failed: {result.get('error', 'unknown')}")
        return (
            f"rem dream: {result.get('triples_written', 0)} normative patterns, "
            f"{result.get('rejected_hypotheses', 0)} hypotheses rejected, "
            f"{result.get('episodes_consolidated', 0)} episodes consolidated"
        )

    # Register the three actions with the cron scheduler
    try:
        cron.register_handler("memory_dream_light", _action_light)
        cron.register_handler("memory_dream_deep", _action_deep)
        cron.register_handler("memory_dream_rem", _action_rem)
    except Exception:
        log.exception("dream: failed to register cron handlers")
        return {}

    # Add the three cron jobs, reading their schedules from the skills.
    # Defaults match the documented OpenClaw cadence (light 6h, deep 3am
    # daily, REM 5am sunday) when the YAML is missing.
    DEFAULT_CRONS = {
        PHASE_LIGHT: "0 */6 * * *",
        PHASE_DEEP: "0 3 * * *",
        PHASE_REM: "0 5 * * 0",
    }
    registered: dict[str, dict] = {}
    for phase in (PHASE_LIGHT, PHASE_DEEP, PHASE_REM):
        schedule = load_phase_schedule(skill_loader, phase)
        cron_expr = schedule.get("cron") if isinstance(schedule.get("cron"), str) else None
        if not cron_expr:
            cron_expr = DEFAULT_CRONS[phase]
            log.warning(
                "dream: %s phase has no cron in schedule.yaml; using default %s",
                phase, cron_expr,
            )
        try:
            await cron.add_job(
                name=f"memory_dream_{phase}",
                schedule=cron_expr,
                action=f"memory_dream_{phase}",
                deliver_to="observations",
            )
            registered[phase] = {"schedule": cron_expr, "registered": True}
        except Exception:
            log.exception("dream: failed to add cron job for %s", phase)
            registered[phase] = {"schedule": cron_expr, "registered": False}

    log.info("dream: registered %d cron jobs: %s", len(registered), list(registered.keys()))
    return registered
