"""
cortex/hippo/assembler.py — ContextAssembler: DAG → message array with cornerstone injection.

Assembles context from:
1. Cornerstones (injected first, if cornerstone_provider configured)
2. DAG summaries (from context_items)
3. Fresh tail messages (last N messages always raw)

Respects max_tokens budget — prunes oldest summaries first if over budget.
Returns ordered list of ContextItem objects.

Issue: #803
"""

from __future__ import annotations

import json
import logging
from dataclasses import dataclass
from typing import Any, Optional

import httpx

from cortex.hippo.config import HippoConfig
from cortex.hippo.transcript_repair import sanitize_tool_use_result_pairing
from cortex.hippo.models import (
    AssembleResult,
    ContextItem,
    ContextItemType,
)
from cortex.hippo.store.conversation_store import ConversationStore, _estimate_tokens
from cortex.hippo.store.summary_store import SummaryStore

logger = logging.getLogger(__name__)


# ═══════════════════════════════════════════════════════════════════════════
# MessagePartRecord — lightweight representation of a stored message part.
# Mirrors the TypeScript MessagePartRecord shape. Will be populated from
# the DB once message_parts table exists; until then, tests use dicts.
# ═══════════════════════════════════════════════════════════════════════════


@dataclass
class MessagePartRecord:
    """A single part of a multipart message, as stored in message_parts."""

    part_id: int = 0
    message_id: int = 0
    ordinal: int = 0
    part_type: str = "text"  # text | tool | reasoning
    text_content: Optional[str] = None
    tool_call_id: Optional[str] = None
    tool_name: Optional[str] = None
    tool_input: Optional[str] = None
    tool_output: Optional[str] = None
    metadata: Optional[str] = None  # JSON string


# ═══════════════════════════════════════════════════════════════════════════
# Multipart Reconstruction — standalone functions (ported from assembler.ts)
#
# These reconstruct runtime messages from stored parts, handling:
# - Role remapping (DB role → runtime role via part metadata)
# - Content block reconstruction (text, tool calls, tool results, reasoning)
# - Raw metadata passthrough for provider-specific block formats
# ═══════════════════════════════════════════════════════════════════════════


def _parse_json(value: Optional[str]) -> Any:
    """Safely parse a JSON string, returning None on failure."""
    if not isinstance(value, str) or not value.strip():
        return None
    try:
        return json.loads(value)
    except (json.JSONDecodeError, ValueError):
        return None


def _get_part_metadata(part: MessagePartRecord) -> dict:
    """Extract originalRole, rawType, raw from part metadata JSON.

    Returns a dict with keys present only when the metadata contains them.
    Ported from TS ``getPartMetadata()``.
    """
    decoded = _parse_json(part.metadata)
    if not isinstance(decoded, dict):
        return {}

    result: dict[str, Any] = {}
    original_role = decoded.get("originalRole")
    if isinstance(original_role, str) and original_role:
        result["originalRole"] = original_role

    raw_type = decoded.get("rawType")
    if isinstance(raw_type, str) and raw_type:
        result["rawType"] = raw_type

    raw = decoded.get("raw")
    if raw is not None:
        result["raw"] = raw

    return result


def _get_original_role(parts: list[MessagePartRecord]) -> Optional[str]:
    """Check parts metadata for an originalRole annotation.

    Returns the first non-empty originalRole found, or None.
    Ported from TS ``getOriginalRole()``.
    """
    for part in parts:
        decoded = _parse_json(part.metadata)
        if not isinstance(decoded, dict):
            continue
        role = decoded.get("originalRole")
        if isinstance(role, str) and role:
            return role
    return None


def _to_runtime_role(db_role: str, parts: list[MessagePartRecord]) -> str:
    """Map DB role + part metadata to runtime role.

    Priority: part metadata originalRole > DB role fallback.
    Returns one of: "user", "assistant", "toolResult".
    Ported from TS ``toRuntimeRole()``.
    """
    original_role = _get_original_role(parts)
    if original_role == "toolResult":
        return "toolResult"
    if original_role == "assistant":
        return "assistant"
    if original_role == "user":
        return "user"
    if original_role == "system":
        return "user"  # system → user at runtime

    # Fallback to DB role
    if db_role == "tool":
        return "toolResult"
    if db_role == "assistant":
        return "assistant"
    return "user"


def _parse_stored_value(value: Optional[str]) -> Any:
    """Parse a stored string value — try JSON first, fall back to raw string."""
    if not isinstance(value, str) or not value:
        return None
    parsed = _parse_json(value)
    return parsed if parsed is not None else value


def _block_from_part(part: MessagePartRecord) -> dict:
    """Reconstruct a content block from a stored message part.

    Handles:
    - Raw metadata passthrough (provider-specific blocks)
    - Reasoning blocks
    - Tool call blocks
    - Tool result blocks
    - Text blocks (default)

    Ported from TS ``blockFromPart()``.
    """
    metadata = _get_part_metadata(part)

    # Raw passthrough — restore original block from metadata
    raw = metadata.get("raw")
    if raw is not None and isinstance(raw, dict):
        return dict(raw)

    raw_type = metadata.get("rawType")
    original_role = metadata.get("originalRole")

    # Reasoning blocks
    if part.part_type == "reasoning":
        block_type = "thinking" if raw_type == "thinking" else "reasoning"
        if isinstance(part.text_content, str) and part.text_content:
            if block_type == "thinking":
                return {"type": block_type, "thinking": part.text_content}
            return {"type": block_type, "text": part.text_content}
        return {"type": block_type}

    # Tool blocks — could be call or result
    if part.part_type == "tool":
        if original_role == "toolResult" or raw_type == "function_call_output":
            return _tool_result_block(part, raw_type)
        return _tool_call_block(part, raw_type)

    # rawType-based tool detection (for parts where part_type != "tool")
    tool_call_types = {
        "function_call", "functionCall", "tool_use", "tool-use", "toolUse", "toolCall",
    }
    tool_result_types = {
        "function_call_output", "tool_result", "toolResult",
    }
    if raw_type in tool_call_types:
        return _tool_call_block(part, raw_type)
    if raw_type in tool_result_types:
        return _tool_result_block(part, raw_type)

    # Text block (default)
    if part.part_type == "text" or (isinstance(part.text_content, str) and part.text_content):
        return {"type": "text", "text": part.text_content or ""}

    # Fallback: try to extract something from metadata
    decoded = _parse_json(part.metadata)
    if isinstance(decoded, dict):
        return {"type": "text", "text": json.dumps(decoded)}

    return {"type": "text", "text": ""}


def _tool_call_block(part: MessagePartRecord, raw_type: Optional[str] = None) -> dict:
    """Build a tool call content block from a part."""
    valid_types = {
        "function_call", "functionCall", "tool_use", "tool-use", "toolUse", "toolCall",
    }
    block_type = raw_type if raw_type in valid_types else "toolCall"
    block: dict[str, Any] = {"type": block_type}

    tool_input = _parse_stored_value(part.tool_input)

    if block_type == "function_call":
        if part.tool_call_id:
            block["call_id"] = part.tool_call_id
        if part.tool_name:
            block["name"] = part.tool_name
        if tool_input is not None:
            block["arguments"] = tool_input
    else:
        if part.tool_call_id:
            block["id"] = part.tool_call_id
        if part.tool_name:
            block["name"] = part.tool_name
        if tool_input is not None:
            key = "arguments" if block_type == "functionCall" else "input"
            block[key] = tool_input

    return block


def _tool_result_block(part: MessagePartRecord, raw_type: Optional[str] = None) -> dict:
    """Build a tool result content block from a part."""
    valid_types = {"function_call_output", "tool_result", "toolResult"}
    block_type = raw_type if raw_type in valid_types else "tool_result"
    output = _parse_stored_value(part.tool_output) or part.text_content or ""
    block: dict[str, Any] = {"type": block_type, "output": output}

    if block_type == "function_call_output":
        if part.tool_call_id:
            block["call_id"] = part.tool_call_id
    else:
        if part.tool_call_id:
            block["tool_use_id"] = part.tool_call_id

    return block


def _dict_to_message_part_record(d: dict) -> MessagePartRecord:
    """Convert a raw dict from the DB (get_message_parts / get_message_parts_bulk)
    to a MessagePartRecord dataclass instance."""
    return MessagePartRecord(
        part_id=d.get("id", 0),
        message_id=d.get("message_id", 0),
        ordinal=d.get("ordinal", 0),
        part_type=d.get("part_type", "text"),
        text_content=d.get("text_content"),
        tool_call_id=d.get("tool_call_id"),
        tool_name=d.get("tool_name"),
        tool_input=d.get("tool_input"),
        tool_output=d.get("tool_output"),
        metadata=d.get("metadata"),
    )


def _reconstruct_message(
    message: MessageRecord,
    parts: list[MessagePartRecord],
) -> dict[str, Any]:
    """Build a runtime message dict from a DB message + its parts.

    Uses role remapping and content block reconstruction.
    Ported from TS assembler's message resolution pipeline.
    """
    if not parts:
        # No parts — simple text message
        return {"role": message.role, "content": message.content}

    role = _to_runtime_role(message.role, parts)
    blocks = [_block_from_part(p) for p in parts]

    # Optimization: single text block for user role → unwrap to string
    if (
        role == "user"
        and len(blocks) == 1
        and isinstance(blocks[0], dict)
        and blocks[0].get("type") == "text"
        and isinstance(blocks[0].get("text"), str)
    ):
        content: Any = blocks[0]["text"]
    else:
        content = blocks

    # For tool results, check for toolCallId — degrade to assistant if missing.
    # Matches lossless-claw's degradeToAssistant path: toolResult rows without
    # a toolCallId cannot be serialised for Anthropic-compatible APIs.
    if role == "toolResult":
        tool_call_id: Optional[str] = None
        for p in parts:
            if p.tool_call_id:
                tool_call_id = p.tool_call_id
                break
        if tool_call_id is None:
            # Degrade: emit as assistant with text content (or empty array)
            text_content: Any = None
            for p in parts:
                if p.text_content:
                    text_content = p.text_content
                    break
            degraded_content: Any = (
                [{"type": "text", "text": text_content}]
                if text_content
                else []
            )
            return {"role": "assistant", "content": degraded_content}

    result: dict[str, Any] = {"role": role, "content": content}

    # For tool results, attach tool metadata at message level
    if role == "toolResult":
        for p in parts:
            if p.tool_call_id:
                result["toolCallId"] = p.tool_call_id
                break
        for p in parts:
            if p.tool_name:
                result["toolName"] = p.tool_name
                break

    return result


# ═══════════════════════════════════════════════════════════════════════════
# Summary Signal — used to generate LCM system prompt guidance
# ═══════════════════════════════════════════════════════════════════════════


@dataclass
class SummarySignal:
    """Signal extracted from a resolved summary for system prompt generation."""

    kind: str
    depth: int
    descendant_count: int


def build_system_prompt_addition(
    signals: list[SummarySignal],
) -> Optional[str]:
    """Build LCM usage guidance for the runtime system prompt.

    Guidance is emitted only when summaries are present in assembled context.
    Depth-aware: minimal for shallow compaction, full guidance for deep trees.

    Ported from TypeScript assembler.ts ``buildSystemPromptAddition``.
    """
    if not signals:
        return None

    max_depth = max(s.depth for s in signals)
    condensed_count = sum(1 for s in signals if s.kind == "condensed")
    heavily_compacted = max_depth >= 2 or condensed_count >= 2

    sections: list[str] = []

    # Core recall workflow — always present when summaries exist
    sections.append("## LCM Recall")
    sections.append("")
    sections.append(
        "Summaries above are compressed context — maps to details, not the details themselves."
    )
    sections.append("")
    sections.append(
        "**Recall priority:** LCM tools first, then qmd (for Granola/Limitless/pre-LCM data), then memory_search as last resort."
    )
    sections.append("")
    sections.append("**Tool escalation:**")
    sections.append(
        "1. `lcm_grep` — search by regex or full-text across messages and summaries"
    )
    sections.append(
        "2. `lcm_describe` — inspect a specific summary (cheap, no sub-agent)"
    )
    sections.append(
        "3. `lcm_expand_query` — deep recall: spawns bounded sub-agent, expands DAG, returns answer with cited summary IDs (~120s, don't ration it)"
    )
    sections.append("")
    sections.append(
        "**`lcm_expand_query` usage** — two patterns (always requires `prompt`):"
    )
    sections.append(
        '- With IDs: `lcm_expand_query(summaryIds: ["sum_xxx"], prompt: "What config changes were discussed?")`'
    )
    sections.append(
        '- With search: `lcm_expand_query(query: "database migration", prompt: "What strategy was decided?")`'
    )
    sections.append(
        "- Optional: `maxTokens` (default 2000), `conversationId`, `allConversations: true`"
    )
    sections.append("")
    sections.append(
        '**Summaries include "Expand for details about:" footers** listing compressed specifics. Use `lcm_expand_query` with that summary\'s ID to retrieve them.'
    )

    # Precision/evidence rules — always present but stronger when heavily compacted
    if heavily_compacted:
        sections.append("")
        sections.append(
            "**\u26a0 Deeply compacted context — expand before asserting specifics.**"
        )
        sections.append("")
        sections.append("Default recall flow for precision work:")
        sections.append("1) `lcm_grep` to locate relevant summary/message IDs")
        sections.append("2) `lcm_expand_query` with a focused prompt")
        sections.append("3) Answer with citations to summary IDs used")
        sections.append("")
        sections.append("**Uncertainty checklist (run before answering):**")
        sections.append(
            "- Am I making exact factual claims from a condensed summary?"
        )
        sections.append("- Could compaction have omitted a crucial detail?")
        sections.append("- Would this answer fail if the user asks for proof?")
        sections.append("")
        sections.append("If yes to any \u2192 expand first.")
        sections.append("")
        sections.append(
            "**Do not guess** exact commands, SHAs, file paths, timestamps, config values, or causal claims from condensed summaries. Expand first or state that you need to expand."
        )
    else:
        sections.append("")
        sections.append(
            "**For precision/evidence questions** (exact commands, SHAs, paths, timestamps, config values, root-cause chains): expand before answering."
        )
        sections.append(
            "Do not guess from condensed summaries — expand first or state uncertainty."
        )

    return "\n".join(sections)


class ContextAssembler:
    """Builds the ordered message array for the model.

    Assembly order:
    1. Inject cornerstones (if Cortex bridge available)
    2. Resolve context items (summaries → XML-wrapped, messages → raw)
    3. Separate into evictable prefix + protected fresh tail
    4. Fit within token budget (prune oldest summaries first)
    """

    def __init__(
        self,
        conversation_store: ConversationStore,
        summary_store: SummaryStore,
        config: HippoConfig,
    ) -> None:
        self._conversation_store = conversation_store
        self._summary_store = summary_store
        self._config = config

    async def assemble(
        self,
        conversation_id: int,
        owner_id: str,
        token_budget: int,
    ) -> AssembleResult:
        """Assemble the full context for the model.

        Parameters
        ----------
        conversation_id : int
            The conversation to assemble context for.
        owner_id : str
            Tenant identifier.
        token_budget : int
            Maximum tokens for the assembled context.

        Returns
        -------
        AssembleResult
            Ordered context items + messages + token stats.
            On failure, returns a fallback result with raw recent messages.
        """
        try:
            return await self._assemble_inner(
                conversation_id, owner_id, token_budget
            )
        except Exception:
            logger.warning(
                "Assembly failed for conversation %d, returning fallback",
                conversation_id,
                exc_info=True,
            )
            return await self._assemble_fallback(
                conversation_id, owner_id, token_budget
            )

    async def _assemble_fallback(
        self,
        conversation_id: int,
        owner_id: str,
        token_budget: int,
    ) -> AssembleResult:
        """Fallback assembly: return raw recent messages from conversation store."""
        try:
            all_msgs = await self._conversation_store.get_messages(
                conversation_id, owner_id
            )
        except Exception:
            logger.warning(
                "Fallback assembly also failed to fetch messages for conversation %d",
                conversation_id,
                exc_info=True,
            )
            return AssembleResult()

        if not all_msgs:
            return AssembleResult()

        items: list[ContextItem] = []
        messages: list[dict[str, Any]] = []
        total_tokens = 0

        for msg in all_msgs:
            if total_tokens + msg.token_count > token_budget:
                break
            ci = ContextItem(
                kind=ContextItemType.MESSAGE,
                role=msg.role,
                content=msg.content,
                token_count=msg.token_count,
                source_id=str(msg.message_id),
            )
            items.append(ci)
            messages.append({"role": msg.role, "content": msg.content})
            total_tokens += msg.token_count

        if messages:
            messages = sanitize_tool_use_result_pairing(messages)

        return AssembleResult(
            items=items,
            messages=messages,
            total_tokens=total_tokens,
            message_tokens=total_tokens,
        )

    async def _assemble_inner(
        self,
        conversation_id: int,
        owner_id: str,
        token_budget: int,
    ) -> AssembleResult:
        """Core assembly logic — may raise on store/DB errors."""
        items: list[ContextItem] = []
        messages: list[dict[str, Any]] = []
        remaining_budget = token_budget
        cornerstone_tokens = 0
        summary_tokens = 0
        message_tokens = 0

        # ── Step 1: Inject cornerstones ────────────────────────────────
        if self._config.cornerstone_provider:
            cs_items, cs_tokens = await self._fetch_cornerstones(
                owner_id, remaining_budget
            )
            items.extend(cs_items)
            for ci in cs_items:
                messages.append({"role": ci.role, "content": ci.content})
            cornerstone_tokens = cs_tokens
            remaining_budget -= cs_tokens

        # ── Step 2: Get context items from DB (ordinal-sorted) ────────
        ctx_records = await self._summary_store.get_context_items(conversation_id)

        # ── Step 3: Resolve all items in ordinal order (unified list) ─
        # R-035: Match lossless-claw's single contextItems array ordered by
        # ordinal. Summaries and messages interleave chronologically instead
        # of summaries-first. (#ordinal-interleaving)
        #
        # W3-7: Batch-fetch all message context items to avoid N+1 (#1168)

        # Collect all message_ids first, then batch-fetch messages + parts
        message_ids_needed = [
            record.message_id
            for record in ctx_records
            if record.item_type == "message" and record.message_id
        ]
        msg_map = await self._conversation_store.get_messages_by_ids(
            message_ids_needed, owner_id=owner_id
        )
        # Batch-fetch all parts to avoid N+1 queries (#1168 extension)
        parts_map_raw: dict[int, list[dict]] = (
            await self._conversation_store.get_message_parts_bulk(message_ids_needed)
            if message_ids_needed
            else {}
        )
        # Convert raw dicts → MessagePartRecord instances
        parts_map: dict[int, list[MessagePartRecord]] = {
            mid: [_dict_to_message_part_record(d) for d in raw_parts]
            for mid, raw_parts in parts_map_raw.items()
        }

        # Resolved items in DB ordinal order — single unified list
        resolved: list[ContextItem] = []
        # Parallel list of SummarySignal (None for message items)
        resolved_signals: list[Optional[SummarySignal]] = []
        # Count totals before budget eviction (for truncated flag)
        all_summary_count = 0

        for record in ctx_records:
            if record.item_type == "summary" and record.summary_id:
                summary = await self._summary_store.get_summary(
                    owner_id, record.summary_id
                )
                if summary:
                    ci = ContextItem(
                        kind=ContextItemType.SUMMARY,
                        role="user",
                        content=self._wrap_summary_xml(summary),
                        token_count=summary.token_count,
                        source_id=summary.summary_id,
                    )
                    signal = SummarySignal(
                        kind=summary.kind,
                        depth=summary.depth,
                        descendant_count=summary.descendant_count,
                    )
                    resolved.append(ci)
                    resolved_signals.append(signal)
                    all_summary_count += 1

            elif record.item_type == "message" and record.message_id:
                msg = msg_map.get(record.message_id)
                if msg:
                    # Fetch parts (already batch-fetched above)
                    msg_parts = parts_map.get(msg.message_id, [])
                    if msg_parts:
                        # Full reconstruction: role remapping + content block assembly
                        reconstructed = _reconstruct_message(msg, msg_parts)
                        # ContextItem.content must be a string — use the plain-text
                        # fallback from the DB for token accounting. The real
                        # structured content lives in ci.reconstructed.
                        reconstructed_content = reconstructed.get("content", "")
                        ci_content: str = (
                            reconstructed_content
                            if isinstance(reconstructed_content, str)
                            else msg.content
                        )
                        ci = ContextItem(
                            kind=ContextItemType.MESSAGE,
                            role=reconstructed["role"],
                            content=ci_content,
                            token_count=msg.token_count,
                            source_id=str(msg.message_id),
                        )
                        ci.reconstructed = reconstructed
                    else:
                        # No parts stored — fall back to raw content
                        ci = ContextItem(
                            kind=ContextItemType.MESSAGE,
                            role=msg.role,
                            content=msg.content,
                            token_count=msg.token_count,
                            source_id=str(msg.message_id),
                        )
                        ci.reconstructed = None
                    resolved.append(ci)
                    resolved_signals.append(None)

        # ── Step 4: Split unified list into fresh tail + evictable prefix
        # Protect the last fresh_tail_count items (any type) — matching
        # lossless-claw's freshTailCount semantics.
        tail_count = self._config.fresh_tail_count
        tail_start = max(0, len(resolved) - tail_count)
        fresh_tail = resolved[tail_start:]
        fresh_tail_signals = resolved_signals[tail_start:]
        evictable = resolved[:tail_start]
        evictable_signals = resolved_signals[:tail_start]

        # ── Step 5: Calculate tail token cost (always included) ────────
        # #1234: Skip individual tail items that exceed remaining budget
        # rather than including them and blowing the budget.
        filtered_tail: list[ContextItem] = []
        filtered_tail_signals: list[Optional[SummarySignal]] = []
        dropped_tail = 0
        tail_budget = token_budget - cornerstone_tokens  # full budget minus cornerstones
        tail_accum = 0
        for ci, sig in zip(fresh_tail, fresh_tail_signals):
            if tail_accum + ci.token_count <= tail_budget:
                filtered_tail.append(ci)
                filtered_tail_signals.append(sig)
                tail_accum += ci.token_count
            else:
                dropped_tail += 1
        if dropped_tail > 0:
            logger.warning(
                "Assembly budget overflow: dropped %d tail items "
                "(budget=%d, tail_tokens_attempted=%d)",
                dropped_tail,
                tail_budget,
                sum(ci.token_count for ci in fresh_tail),
            )
        fresh_tail = filtered_tail
        fresh_tail_signals = filtered_tail_signals
        tail_tokens = tail_accum
        remaining_budget -= tail_tokens

        # ── Step 6: Budget-aware evictable selection ───────────────────
        # Walk evictable from newest to oldest, accumulate until budget
        # exhausted, then reverse to restore chronological order.
        # Matches lossless-claw eviction: stop at first item that doesn't fit.
        evict_total = sum(ci.token_count for ci in evictable)
        selected_evictable: list[ContextItem] = []
        selected_evictable_signals: list[Optional[SummarySignal]] = []
        evictable_tokens_used = 0

        if evict_total <= remaining_budget:
            # Everything fits
            selected_evictable = list(evictable)
            selected_evictable_signals = list(evictable_signals)
            evictable_tokens_used = evict_total
        else:
            # Walk from newest → oldest, stop at first item that doesn't fit
            kept_rev: list[ContextItem] = []
            kept_rev_signals: list[Optional[SummarySignal]] = []
            accum = 0
            for ci, sig in zip(reversed(evictable), reversed(evictable_signals)):
                if accum + ci.token_count <= remaining_budget:
                    kept_rev.append(ci)
                    kept_rev_signals.append(sig)
                    accum += ci.token_count
                else:
                    # Stop — all older items are also dropped (greedy from newest)
                    break
            kept_rev.reverse()
            kept_rev_signals.reverse()
            selected_evictable = kept_rev
            selected_evictable_signals = kept_rev_signals
            evictable_tokens_used = accum

        # ── Step 7: Build final unified ordered list ────────────────────
        # Order: cornerstones → selected evictable (ordinal order) → fresh tail
        final_resolved = selected_evictable + fresh_tail
        final_resolved_signals = selected_evictable_signals + fresh_tail_signals
        final_items = items + final_resolved  # prepend cornerstones

        # Compute per-type token stats from final selection
        for ci, sig in zip(final_resolved, final_resolved_signals):
            if sig is not None:
                summary_tokens += ci.token_count
            else:
                message_tokens += ci.token_count

        # ── Step 8: Build final_messages from resolved items ───────────
        final_messages = []
        for ci in final_items:
            reconstructed = ci.reconstructed
            if reconstructed is not None:
                final_messages.append(reconstructed)
            else:
                final_messages.append({"role": ci.role, "content": ci.content})

        # ── Step 8a: Normalize assistant string content → block array ──
        # Anthropic rejects assistant messages with string content in multi-turn
        # conversations. Wrap plain strings in a single text block.
        # Ported from lossless-claw assembler.ts post-budget normalization.
        for i, msg in enumerate(final_messages):
            if msg.get("role") == "assistant" and isinstance(msg.get("content"), str):
                content = msg["content"]
                final_messages[i] = {
                    **msg,
                    "content": [{"type": "text", "text": content}] if content else [],
                }

        final_messages = sanitize_tool_use_result_pairing(final_messages)

        fitted_summary_count = sum(1 for sig in final_resolved_signals if sig is not None)
        total_tokens = cornerstone_tokens + summary_tokens + message_tokens
        # Truncated if tail exceeded budget, or any evictable items were dropped
        truncated = remaining_budget < 0 or (evict_total > remaining_budget)

        # ── Step 9: Build LCM system prompt addition ──────────────────
        # Use ALL summary signals from the FULL pre-eviction resolved list.
        # Matches lossless-claw: systemPromptAddition is computed before
        # budget truncation so the model knows about all compaction layers
        # even if some summaries were evicted from the final context window.
        all_pre_eviction_signals: list[SummarySignal] = [
            sig for sig in resolved_signals if sig is not None
        ]
        system_prompt_addition = build_system_prompt_addition(all_pre_eviction_signals)

        return AssembleResult(
            items=final_items,
            messages=final_messages,
            total_tokens=total_tokens,
            cornerstone_tokens=cornerstone_tokens,
            summary_tokens=summary_tokens,
            message_tokens=message_tokens,
            truncated=truncated,
            system_prompt_addition=system_prompt_addition,
        )

    async def _fetch_cornerstones(
        self, owner_id: str, budget: int
    ) -> tuple[list[ContextItem], int]:
        """Fetch cornerstones from Cortex API and return as ContextItems.

        Returns (items, total_tokens).
        """
        url = self._config.cornerstone_provider
        if not url:
            return [], 0

        items: list[ContextItem] = []
        total_tokens = 0

        try:
            async with httpx.AsyncClient(timeout=10.0) as client:
                resp = await client.get(
                    f"{url.rstrip('/')}/api/cornerstones",
                    params={"owner_id": owner_id},
                )
                resp.raise_for_status()
                data = resp.json()

            cornerstones = data if isinstance(data, list) else data.get("cornerstones", [])

            # Cap at 7 cornerstones (from architecture doc)
            for cs in cornerstones[:7]:
                content = cs.get("content", "")
                cs_id = cs.get("id", "unknown")
                sealed_at = cs.get("sealed_at", "")

                wrapped = (
                    f'<cornerstone id="{cs_id}" sealed="{sealed_at}">\n'
                    f"{content}\n"
                    f"</cornerstone>"
                )
                tokens = _estimate_tokens(wrapped)

                if total_tokens + tokens > budget:
                    break  # Don't exceed budget

                items.append(
                    ContextItem(
                        kind=ContextItemType.CORNERSTONE,
                        role="user",
                        content=wrapped,
                        token_count=tokens,
                        source_id=cs_id,
                    )
                )
                total_tokens += tokens

        except Exception as exc:
            logger.warning("Failed to fetch cornerstones from %s: %s", url, exc)

        return items, total_tokens

    @staticmethod
    def _wrap_summary_xml(summary: "SummaryRecord") -> str:
        """Wrap a summary in XML tags matching lossless-claw's format."""
        from cortex.hippo.models import SummaryRecord  # avoid circular at module level
        attrs = [
            f'id="{summary.summary_id}"',
            f'kind="{summary.kind}"',
            f'depth="{summary.depth}"',
            f'descendant_count="{summary.descendant_count}"',
        ]
        if summary.earliest_at:
            attrs.append(f'earliest_at="{summary.earliest_at}"')
        if summary.latest_at:
            attrs.append(f'latest_at="{summary.latest_at}"')

        lines = [f"<summary {' '.join(attrs)}>"]
        lines.append("  <content>")
        lines.append(summary.content)
        lines.append("  </content>")
        lines.append("</summary>")
        return "\n".join(lines)
