"""Unix socket daemon for claude-engram."""

from __future__ import annotations

import asyncio
import contextlib
import json
import logging
import os
import signal
import sys
import threading
import time
from collections.abc import Coroutine
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING, Any

from cachetools import TTLCache

if TYPE_CHECKING:
    from .handoff import ContextSample, HandoffState

from .cache import QueryCache
from .calibration import CalibrationConfig
from .categories import CROSS_WINDOW_CATEGORIES
from .paths import SOCKET_PATH
from .storage import MemoryStorage
from .token_tracker import TokenTracker

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger = logging.getLogger("claude-engram")
PID_FILE = Path.home() / ".local" / "share" / "claude-engram" / "daemon.pid"

# Maximum allowed sizes for input validation (H5)
_MAX_CONTENT_LENGTH = 50_000
_MAX_BATCH_SIZE = 50
_MAX_LIMIT = 100

# Timeout constants for asyncio.to_thread() calls (#39)
# Without timeouts, hanging operations can exhaust the thread pool.
TIMEOUT_EMBEDDING = 30.0  # Embedding can be slow for large batches
TIMEOUT_DB_READ = 10.0  # DB reads should be fast
TIMEOUT_DB_WRITE = 15.0  # DB writes slightly slower than reads

# D10: Tools that trigger micro-tier checkpoint (file mutations)
_MICRO_TOOLS = frozenset({"Write", "Edit", "MultiEdit"})
# D10: Rate limit for micro-tier checkpoints (seconds)
_MICRO_RATE_LIMIT = 30.0
# D10: Default progress tier thresholds per profile
_DEFAULT_PROGRESS_THRESHOLDS = {"light": 8, "balanced": 5, "ultra": 3}


async def with_timeout(
    coro: Coroutine[Any, Any, Any],
    timeout: float,
    label: str = "operation",
) -> Any:
    """Wrap a coroutine with a timeout and logging.

    Args:
        coro: The coroutine to wrap
        timeout: Timeout in seconds
        label: Description of the operation for logging

    Returns:
        The result of the coroutine

    Raises:
        asyncio.TimeoutError: If the operation times out
    """
    try:
        return await asyncio.wait_for(coro, timeout=timeout)
    except TimeoutError:
        logger.warning("Operation '%s' timed out after %.1fs", label, timeout)
        raise


class MemoryDaemon:
    """Async Unix socket server for memory operations."""

    def __init__(self) -> None:
        self.storage = MemoryStorage()
        self.cache = QueryCache()
        self.token_tracker = TokenTracker()
        self.server: asyncio.Server | None = None
        self._shutdown = asyncio.Event()
        # Q3: Initialize _cleanup_task so stop() doesn't need hasattr
        self._cleanup_task: asyncio.Task[None] | None = None
        # Q5: TTLCache for bounded checkpoint/session tracking (24h TTL)
        # Thread-safe: only accessed from the async event loop, never from worker threads.
        self._checkpoint_counters: TTLCache[str, int] = TTLCache(maxsize=1000, ttl=86400)
        # Q7: Env-configurable checkpoint thresholds
        _default_thresholds = {"light": 40, "balanced": 25, "ultra": 15}
        self._checkpoint_thresholds: dict[str, int] = {
            k: int(os.environ.get(f"ENGRAM_CHECKPOINT_{k.upper()}", v))
            for k, v in _default_thresholds.items()
        }
        self._last_cleanup: datetime = datetime.now(tz=UTC)
        # Q7: Env-configurable cleanup interval
        self._cleanup_interval: int = int(os.environ.get("ENGRAM_CLEANUP_INTERVAL", "3600"))
        # Socket health check interval (default 30s) - detects orphan socket scenarios
        self._socket_check_interval: int = int(os.environ.get("ENGRAM_SOCKET_CHECK_INTERVAL", "30"))
        self._socket_check_task: asyncio.Task[None] | None = None
        # Q5: TTLCache for bounded session end tracking (24h TTL)
        self._last_session_end: TTLCache[str, str] = TTLCache(maxsize=100, ttl=86400)
        # Cached startup config from last star_startup call
        init_profile = os.environ.get("STAR_PROFILE", "balanced").lower()
        self._last_startup_config: dict[str, str | None] = {
            "profile": init_profile,
            "proj_tag": None,
        }
        # H4: Track injected memory IDs per session to dedup with contextual_load
        # Thread-safe: only accessed from the async event loop, never from worker threads.
        self._injected_ids: TTLCache[str, set[int]] = TTLCache(maxsize=100, ttl=86400)

        # Auto-consolidation state (Phase E2)
        # Counter resets on daemon restart; startup check compensates via DB timestamp
        self._store_count: int = 0
        self._consolidation_threshold: int = int(
            os.environ.get("ENGRAM_CONSOLIDATION_THRESHOLD", "50")
        )
        self._consolidation_cooldown_hours: int = 24

        # Context lifecycle manager state (Phase 3.9)
        # All lost on daemon restart — safe fallback to fresh monitoring (SC#11)
        self._context_history: TTLCache[str, list[ContextSample]] = TTLCache(maxsize=200, ttl=86400)
        self._session_memories: TTLCache[str, list[int]] = TTLCache(maxsize=200, ttl=86400)
        self._session_tool_counts: TTLCache[str, dict[str, int]] = TTLCache(maxsize=200, ttl=86400)
        self._handoff_state: TTLCache[str, HandoffState] = TTLCache(maxsize=200, ttl=86400)
        self._calibration = CalibrationConfig.from_env()

        # D10: Micro tier — last timestamp per session for rate limiting
        self._last_micro_ts: dict[str, float] = {}
        # D10: Progress tier — configurable thresholds per profile
        self._progress_thresholds: dict[str, int] = {
            k: int(os.environ.get(f"ENGRAM_PROGRESS_{k.upper()}", v))
            for k, v in _DEFAULT_PROGRESS_THRESHOLDS.items()
        }

    async def handle_client(
        self,
        reader: asyncio.StreamReader,
        writer: asyncio.StreamWriter,
    ) -> None:
        """Handle a single client connection."""
        try:
            while True:
                # Read line-delimited JSON with per-client timeout (30s).
                # Prevents stalled clients from holding connection slots forever.
                try:
                    line = await asyncio.wait_for(reader.readline(), timeout=30.0)
                except TimeoutError:
                    logger.warning("Client timed out waiting for readline (30s)")
                    break
                if not line:
                    break

                try:
                    request = json.loads(line.decode())
                    response = await self._process_request(request)
                except json.JSONDecodeError as e:
                    response = {"status": "error", "error": f"Invalid JSON: {e}"}
                except Exception as e:
                    logger.exception("Error processing request")
                    response = {"status": "error", "error": str(e)}

                # Send response
                writer.write(json.dumps(response).encode() + b"\n")
                await writer.drain()
        except asyncio.CancelledError:
            pass
        except Exception:
            logger.exception("Client handler error")
        finally:
            writer.close()
            await writer.wait_closed()

    def _check_memory_ownership(self, mem_id: int, proj_tag: str) -> str | None:
        """Check if a memory belongs to a given project.

        Returns error message string if ownership check fails, or None if OK.
        Runs in the calling thread so must be called via asyncio.to_thread().
        """
        # Intentional access to storage._get_conn(): daemon is tightly coupled
        # to storage internals for direct SQL queries not covered by the public API.
        conn = self.storage._get_conn()
        row = conn.execute("SELECT proj_tag FROM memories WHERE id = ?", (mem_id,)).fetchone()
        if row is None:
            return None  # Memory does not exist; let delete handle it
        db_proj_tag = row["proj_tag"] if row["proj_tag"] else None
        normalized_req = proj_tag if proj_tag else None
        if db_proj_tag != normalized_req:
            return f"Memory {mem_id} does not belong to project {proj_tag}"
        return None

    @staticmethod
    def _parse_worker_message(content: str) -> dict[str, str]:
        """Parse worker message format: 'TO:<target> FROM:<sender> — <body>'.

        Returns dict with 'to', 'from', and 'body' keys.
        Handles malformed messages gracefully with empty strings.
        """
        result = {"to": "", "from": "", "body": ""}

        # Parse TO:<target>
        if content.startswith("TO:"):
            space_idx = content.find(" ", 3)
            if space_idx > 3:
                result["to"] = content[3:space_idx]
                rest = content[space_idx + 1 :]
            else:
                result["to"] = content[3:]
                return result
        else:
            return result

        # Parse FROM:<sender>
        if rest.startswith("FROM:"):
            sep_idx = rest.find(" — ")
            if sep_idx > 5:
                result["from"] = rest[5:sep_idx]
                result["body"] = rest[sep_idx + 3 :]
            else:
                result["from"] = rest[5:]
        else:
            # No FROM:, treat rest as body
            result["body"] = rest

        return result

    async def _store_tier_checkpoint(
        self,
        content: str,
        category: str,
        metadata: dict[str, Any],
        proj_tag: str | None = None,
    ) -> None:
        """Fire-and-forget tier checkpoint store (D10).

        Catches all exceptions to avoid crashing the event loop when
        the background task runs. Never blocks the hook response.
        """
        try:
            await with_timeout(
                asyncio.to_thread(
                    self.storage.store,
                    content=content,
                    category=category,
                    metadata=metadata,
                    proj_tag=proj_tag,
                ),
                timeout=TIMEOUT_DB_WRITE,
                label=f"tier_{metadata.get('tier', 'unknown')}_store",
            )
        except Exception:  # noqa: BLE001
            logger.debug("Tier checkpoint store failed (fire-and-forget): %s", content[:80])

    def _health_check_db_queries(self) -> dict[str, Any]:
        """Run blocking database queries for health_check.

        Called via asyncio.to_thread(). Returns dict with stats and
        timestamp info from the database.
        """
        stats = self.storage.stats()
        memory_count = stats.get("total_memories", 0)

        oldest_memory = None
        newest_memory = None
        # Intentional access to storage._get_conn(): daemon needs direct SQL
        # for timestamp aggregation not exposed by the storage public API.
        conn = self.storage._get_conn()
        timestamp_query = conn.execute(
            "SELECT MIN(created_at) as oldest, MAX(created_at) as newest FROM memories"
        ).fetchone()
        if timestamp_query and timestamp_query["oldest"]:
            oldest_memory = timestamp_query["oldest"]
            newest_memory = timestamp_query["newest"]

        return {
            "memory_count": memory_count,
            "oldest_memory": oldest_memory,
            "newest_memory": newest_memory,
        }

    def _calculate_velocity(
        self,
        samples: list[ContextSample],
        config: CalibrationConfig,
    ) -> float | None:
        """Calculate EMA-smoothed context consumption velocity.

        Uses the exponential moving average of per-turn pct deltas.
        Returns None if fewer than min_samples observations.

        This is an inline implementation. When Plan 03.9-02 lands with
        calculate_velocity() in handoff.py, this can delegate to it.
        """
        if len(samples) < config.min_samples:
            return None

        velocity: float | None = None
        for i in range(1, len(samples)):
            delta_pct = samples[i].used_pct - samples[i - 1].used_pct
            delta_turns = samples[i].turn_number - samples[i - 1].turn_number
            if delta_turns <= 0:
                continue
            rate = delta_pct / delta_turns
            if velocity is None:
                velocity = rate
            else:
                velocity = config.ema_alpha * rate + (1 - config.ema_alpha) * velocity

        return max(velocity, 0.001) if velocity is not None else None

    def _is_claim_expired(self, expires_at: str, now: datetime | None = None) -> bool:
        """Check if a claim has expired based on its expires_at timestamp.

        Args:
            expires_at: ISO timestamp of expiration.
            now: Current time (defaults to now if not provided).

        Returns:
            True if claim is expired, False otherwise.
        """
        if not expires_at:
            return True
        if now is None:
            now = datetime.now(tz=UTC)
        try:
            expires = datetime.fromisoformat(expires_at)
            return now >= expires
        except (ValueError, TypeError):
            return True

    def _format_claim_warning(self, claim: dict[str, Any], now: datetime | None = None) -> str:
        """Format an XML warning for a conflicting claim.

        Args:
            claim: Claim metadata dict.
            now: Current time for TTL calculation.

        Returns:
            XML warning string per CONTEXT.md format.
        """
        from xml.sax.saxutils import escape

        if now is None:
            now = datetime.now(tz=UTC)

        # Calculate remaining TTL
        expires_at_str = claim.get("expires_at", "")
        try:
            expires = datetime.fromisoformat(expires_at_str)
            remaining = expires - now
            total_seconds = max(0, int(remaining.total_seconds()))
        except (ValueError, TypeError):
            total_seconds = 0

        if total_seconds >= 3600:
            expires_in = f"{total_seconds // 3600}h {(total_seconds % 3600) // 60}m"
        elif total_seconds >= 60:
            expires_in = f"{total_seconds // 60}m"
        else:
            expires_in = f"{total_seconds}s"

        # Escape XML content
        target = escape(str(claim.get("target", "")))
        agent_id = escape(str(claim.get("agent_id", "")))
        task = escape(str(claim.get("task_description", "")))
        level = claim.get("level", "file")
        status = claim.get("status", "active")
        created_at = claim.get("created_at", "")

        return f"""<claim_warning file="{target}" level="{level}" status="{status}">
  <claimant agent="{agent_id}" task="{task}"/>
  <timing created="{created_at}" expires_in="{expires_in}"/>
  <advisory>Proceeding is allowed. This is informational.</advisory>
</claim_warning>"""

    def _check_snooze(self, session_id: str, used_pct: float) -> bool:
        """Check if a session's handoff is snoozed.

        Returns True if snoozed (should suppress tier), False otherwise.
        When used_pct exceeds snooze_until_pct, the snooze expires.
        """
        state = self._handoff_state.get(session_id)
        if state is None:
            return False
        if state.snooze_until_pct is None:
            return False
        if used_pct >= state.snooze_until_pct:
            # Snooze expired
            state.snooze_until_pct = None
            return False
        return True

    def _filter_interfaces(
        self,
        results: list[dict[str, Any]],
        package: str | None,
        pattern: str | None,
        status: str | None,
    ) -> list[dict[str, Any]]:
        """Filter interface results by package prefix, name pattern, and status.

        Args:
            results: List of memory dicts from storage.
            package: Optional module_path prefix filter.
            pattern: Optional regex pattern to match against name.
            status: Optional status filter (uncommitted/committed/deleted).

        Returns:
            List of filtered interface metadata dicts.
        """
        import re

        interfaces = []
        for mem in results:
            metadata = mem.get("metadata", {})
            if not metadata:
                continue

            # Apply package prefix filter
            if package is not None:
                module_path = metadata.get("module_path", "")
                if not module_path.startswith(package):
                    continue

            # Apply name pattern filter
            if pattern is not None:
                name = metadata.get("name", "")
                try:
                    if not re.search(pattern, name):
                        continue
                except re.error:
                    # Invalid regex, skip filter
                    pass

            # Apply status filter
            if status is not None and metadata.get("status") != status:
                continue

            interfaces.append(metadata)

        return interfaces

    def _handle_context_monitor(self, request: dict[str, Any]) -> dict[str, Any]:
        """Handle context_monitor action.

        Records a context sample, calculates velocity/ETA, determines tier,
        checks snooze, and marks handoff readiness when appropriate.

        Request: {"action": "context_monitor", "session_id": str,
                  "used_pct": float, "turn_number": int}
        Response: {"status": "ok", "tier": str, "velocity": float|null,
                   "eta": float|null, "used_pct": float, "samples": int,
                   "handoff_ready": bool}
        """
        from .calibration import Tier, determine_tier, determine_tier_without_velocity
        from .handoff import ContextSample, HandoffState, SessionType

        session_id = request.get("session_id", "default")
        used_pct = float(request.get("used_pct", 0))
        turn_number = int(request.get("turn_number", 0))
        timestamp = datetime.now(tz=UTC).isoformat()

        # 1. Record sample in context history
        sample = ContextSample(
            used_pct=used_pct,
            turn_number=turn_number,
            timestamp=timestamp,
        )
        if session_id not in self._context_history:
            self._context_history[session_id] = []
        history = self._context_history[session_id]
        history.append(sample)
        # Cap at 20 samples per session (keep most recent)
        if len(history) > 20:
            self._context_history[session_id] = history[-20:]
            history = self._context_history[session_id]

        # 2. Calculate velocity via EMA
        config = self._calibration
        velocity = self._calculate_velocity(history, config)

        # 3. Calculate ETA (turns remaining to target)
        eta: float | None = None
        if velocity is not None and velocity > 0:
            remaining_pct = config.target_pct - used_pct
            eta = 0.0 if remaining_pct <= 0 else remaining_pct / velocity

        # Update last sample's velocity
        history[-1].velocity = velocity

        # 4. Determine tier
        if velocity is not None and eta is not None:
            tier = determine_tier(eta, used_pct, config)
        else:
            # Not enough data for velocity -> use percentage-based fallback
            # to preserve ADVISORY/HANDOFF tiers instead of jumping to EMERGENCY
            tier = determine_tier_without_velocity(used_pct, config)

        # 5. Check snooze
        if self._check_snooze(session_id, used_pct):
            tier = Tier.NONE

        # 6. Mark handoff readiness
        handoff_ready = False
        existing_state = self._handoff_state.get(session_id)
        if existing_state and existing_state.ready:
            handoff_ready = True
        elif tier >= Tier.HANDOFF:
            self._handoff_state[session_id] = HandoffState(
                created_at=timestamp,
                session_type=SessionType.CODING,
                ready=True,
                tier=tier,
            )
            handoff_ready = True

        return {
            "status": "ok",
            "tier": tier.name.lower(),
            "velocity": velocity,
            "eta": eta,
            "used_pct": used_pct,
            "samples": len(history),
            "handoff_ready": handoff_ready,
        }

    async def _handle_get_handoff_status(self, request: dict[str, Any]) -> dict[str, Any]:
        """Handle get_handoff_status action.

        Lazily classifies the session and generates a handoff document on first
        status query. EMERGENCY tier regenerates even if a document exists
        (Pitfall 4: EMERGENCY needs fresh urgency data).

        Request: {"action": "get_handoff_status", "session_id": str,
                  "git_branch": str|null}
        Response: {"status": "ok", "ready": bool, "tier": str,
                   "used_pct": float, "eta": float|null, "session_type": str}
        """
        from .calibration import Tier
        from .handoff import (
            calculate_velocity,
            classify_session,
            generate_handoff_document,
            should_generate_handoff,
        )

        session_id = request.get("session_id", "default")
        state = self._handoff_state.get(session_id)

        if not state or not state.ready:
            return {"status": "ok", "ready": False}

        # Determine if document needs (re)generation:
        # - No document yet (lazy generation)
        # - EMERGENCY tier with existing non-emergency document (Pitfall 4)
        needs_generation = state.document is None
        if not needs_generation:
            assert state.document is not None  # narrowing for mypy
            if state.tier == Tier.EMERGENCY and state.document.get("_tier_marker") != "emergency":
                needs_generation = True

        if needs_generation:
            # Get session data for classifier
            memory_ids = self._session_memories.get(session_id, [])
            tool_counts = self._session_tool_counts.get(session_id, {})
            total_tools = sum(tool_counts.values())

            # Gap G2: minimum-signal threshold
            if not should_generate_handoff(total_tools, len(memory_ids)):
                state.ready = False
                return {"status": "ok", "ready": False, "reason": "insufficient_session_data"}

            # Fetch actual memory content for classifier + generator
            memories: list[dict[str, Any]] = []
            for mid in memory_ids[-20:]:  # Last 20 memories max
                try:
                    mem = await with_timeout(
                        asyncio.to_thread(self.storage.get_by_id, mid),
                        timeout=TIMEOUT_DB_READ,
                        label="get_by_id",
                    )
                    if mem:
                        memories.append(mem)
                except (TimeoutError, Exception):  # noqa: BLE001
                    logger.debug("Failed to fetch memory %s for startup", mid)

            # Get git branch for GSD detection
            git_branch = request.get("git_branch") or ""

            # Classify session
            session_type = classify_session(memories, tool_counts, git_branch)
            state.session_type = session_type

            # Get latest context sample for ETA
            samples = self._context_history.get(session_id, [])
            latest_pct = samples[-1].used_pct if samples else 0.0
            _velocity, eta = calculate_velocity(samples, self._calibration)

            # Generate document
            state.document = generate_handoff_document(
                session_type=session_type,
                memories=memories,
                tool_counts=tool_counts,
                git_branch=git_branch,
                used_pct=latest_pct,
                eta=eta if eta is not None else 0.0,
            )
            # Mark tier for regeneration detection (Pitfall 4)
            state.document["_tier_marker"] = state.tier.name.lower()

        assert state.document is not None  # guaranteed by generation above
        return {
            "status": "ok",
            "ready": True,
            "tier": state.tier.name.lower(),
            "used_pct": state.document.get("used_pct", 0),
            "eta": state.document.get("eta"),
            "session_type": state.session_type.value,
        }

    async def _process_request(self, request: dict[str, Any]) -> dict[str, Any]:
        """Process a memory request."""
        action = request.get("action")

        if action == "store":
            # H5: Input validation
            if "content" not in request:
                return {"status": "error", "error": "Missing required field: content"}
            content = request["content"]
            if not isinstance(content, str) or len(content) > _MAX_CONTENT_LENGTH:
                return {
                    "status": "error",
                    "error": f"content must be a string under {_MAX_CONTENT_LENGTH} chars",
                }

            category = request.get("category", "general")
            proj_tag = request.get("proj_tag")
            try:
                memory_id = await with_timeout(
                    asyncio.to_thread(
                        self.storage.store,
                        content=content,
                        category=category,
                        metadata=request.get("metadata"),
                        proj_tag=proj_tag,
                        valid_from=request.get("valid_from"),
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="store",
                )
            except TimeoutError:
                return {"status": "error", "error": "Store operation timed out"}
            # Phase 3.9: Track session_id -> memory_id mapping for handoff
            metadata_dict = request.get("metadata") or {}
            store_session_id = (
                metadata_dict.get("session_id") if isinstance(metadata_dict, dict) else None
            )
            if store_session_id:
                if store_session_id not in self._session_memories:
                    self._session_memories[store_session_id] = []
                self._session_memories[store_session_id].append(memory_id)

            # Track session end: set timestamp AFTER store completes
            # so the session_summary itself won't appear in what_changed
            if category == "session_summary" and proj_tag is not None:
                self._last_session_end[proj_tag] = datetime.now(tz=UTC).isoformat()

            # Phase E2: Increment store counter and trigger auto-consolidation
            self._store_count += 1
            if self._store_count >= self._consolidation_threshold:
                self._store_count = 0
                asyncio.create_task(self._auto_consolidate())

            return {"id": memory_id, "status": "stored"}

        elif action == "search":
            # H5: Input validation
            if "query" not in request:
                return {"status": "error", "error": "Missing required field: query"}

            # Phase E2: Accept string or int detail_level
            raw_level = request.get("detail_level", 1)
            if isinstance(raw_level, str):
                level_map = {"index": 0, "summary": 1, "full": 2}
                detail_level_int = level_map.get(raw_level.lower(), 1)
            else:
                detail_level_int = int(raw_level)

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.search,
                        query=request["query"],
                        limit=min(request.get("limit", 5), _MAX_LIMIT),
                        category=request.get("category"),
                        proj_tag=request.get("proj_tag"),
                        include_global=request.get("include_global", True),
                        detail_level=detail_level_int,
                        include_inactive=request.get("include_inactive", False),
                        valid_at=request.get("valid_at"),
                        search_mode=request.get("search_mode", "vector"),
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="search",
                )
            except TimeoutError:
                return {"status": "error", "error": "Search operation timed out"}
            return {"status": "ok", "results": results}

        elif action == "recent":
            # C1: Wrap sync storage call in asyncio.to_thread()
            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        limit=min(request.get("limit", 10), _MAX_LIMIT),
                        category=request.get("category"),
                        proj_tag=request.get("proj_tag"),
                        include_global=request.get("include_global", True),
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="recent",
                )
            except TimeoutError:
                return {"status": "error", "error": "Recent operation timed out"}
            return {"status": "ok", "results": results}

        elif action == "delete":
            # H5: Input validation
            if "id" not in request:
                return {"status": "error", "error": "Missing required field: id"}

            mem_id = request["id"]
            proj_tag = request.get("proj_tag")

            # C4: Verify ownership if proj_tag provided
            if proj_tag:
                try:
                    error = await with_timeout(
                        asyncio.to_thread(self._check_memory_ownership, mem_id, proj_tag),
                        timeout=TIMEOUT_DB_READ,
                        label="check_ownership",
                    )
                except TimeoutError:
                    return {"status": "error", "error": "Ownership check timed out"}
                if error:
                    return {"status": "error", "error": error}

            # C1: Wrap sync storage call in asyncio.to_thread()
            try:
                success = await with_timeout(
                    asyncio.to_thread(self.storage.delete, mem_id),
                    timeout=TIMEOUT_DB_WRITE,
                    label="delete",
                )
            except TimeoutError:
                return {"status": "error", "error": "Delete operation timed out"}
            return {"status": "ok", "deleted": success}

        elif action == "supersede":
            # G3: Mark a memory as superseded by a newer one
            if "old_id" not in request or "new_id" not in request:
                return {
                    "status": "error",
                    "error": "Missing required fields: old_id and new_id",
                }
            old_id = request["old_id"]
            new_id = request["new_id"]
            if not isinstance(old_id, int) or not isinstance(new_id, int):
                return {"status": "error", "error": "old_id and new_id must be integers"}
            try:
                superseded = await with_timeout(
                    asyncio.to_thread(self.storage.supersede_memory, old_id, new_id),
                    timeout=TIMEOUT_DB_WRITE,
                    label="supersede",
                )
            except TimeoutError:
                return {"status": "error", "error": "Supersede operation timed out"}
            return {"status": "ok", "superseded": superseded, "old_id": old_id, "new_id": new_id}

        elif action == "get_by_id":
            if "id" not in request:
                return {"status": "error", "error": "Missing required field: id"}
            try:
                mem = await with_timeout(
                    asyncio.to_thread(self.storage.get_by_id, request["id"]),
                    timeout=TIMEOUT_DB_READ,
                    label="get_by_id",
                )
            except TimeoutError:
                return {"status": "error", "error": "get_by_id operation timed out"}
            if mem is None:
                return {"status": "ok", "memory": None}
            return {"status": "ok", "memory": mem}

        elif action == "get_by_ids":
            if "ids" not in request:
                return {"status": "error", "error": "Missing required field: ids"}
            ids = request["ids"]
            if not isinstance(ids, list) or len(ids) > 20:
                return {"status": "error", "error": "ids must be a list of up to 20 integers"}
            try:
                memories = await with_timeout(
                    asyncio.to_thread(self.storage.get_by_ids, ids),
                    timeout=TIMEOUT_DB_READ,
                    label="get_by_ids",
                )
            except TimeoutError:
                return {"status": "error", "error": "get_by_ids operation timed out"}
            return {"status": "ok", "memories": memories}

        elif action == "token_budget":
            session_id = request.get("session_id", "default")
            if "candidate_tokens" not in request:
                return {"status": "error", "error": "Missing required field: candidate_tokens"}
            context_budget = request.get("context_budget", 200_000)
            should_inject = self.token_tracker.should_inject_memories(
                session_id=session_id,
                candidate_tokens=request["candidate_tokens"],
                context_budget=context_budget,
            )
            session = self.token_tracker.get_session(session_id)
            return {
                "status": "ok",
                "should_inject": should_inject,
                "session_input_tokens": session.input_tokens,
                "session_output_tokens": session.output_tokens,
                "session_memory_tokens": session.memory_tokens_injected,
                "remaining_budget": max(0, context_budget - session.input_tokens),
            }

        elif action == "record_token_usage":
            session_id = request.get("session_id", "default")
            session = self.token_tracker.record_usage(
                session_id=session_id,
                input_tokens=request.get("input_tokens", 0),
                output_tokens=request.get("output_tokens", 0),
                memory_tokens=request.get("memory_tokens", 0),
            )
            return {
                "status": "ok",
                "session_total": session.total_tokens,
                "memory_tokens_injected": session.memory_tokens_injected,
            }

        elif action == "stats":
            # C1: Wrap sync storage call in asyncio.to_thread()
            try:
                stats_result = await with_timeout(
                    asyncio.to_thread(self.storage.stats),
                    timeout=TIMEOUT_DB_READ,
                    label="stats",
                )
            except TimeoutError:
                return {"status": "error", "error": "Stats operation timed out"}
            # Add process metrics for observability (audit 2026-02-05)
            import resource

            rusage = resource.getrusage(resource.RUSAGE_SELF)
            stats_result["process"] = {
                "memory_mb": round(rusage.ru_maxrss / 1024, 1),
                "threads": threading.active_count(),
                "cache_entries": len(self._checkpoint_counters)
                + len(self._context_history)
                + len(self._session_memories)
                + len(self._handoff_state),
            }
            return {"status": "ok", **stats_result}

        elif action == "ping":
            from claude_engram import __version__

            return {"status": "pong", "version": __version__}

        elif action == "inject":
            # Special action for SessionStart hook
            # Returns formatted context for injection
            query = request.get("query", "recent decisions and context")
            limit = min(request.get("limit", 5), _MAX_LIMIT)
            proj_tag = request.get("proj_tag")
            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.search,
                        query=query,
                        limit=limit,
                        proj_tag=proj_tag,
                        include_global=True,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="inject",
                )
            except TimeoutError:
                return {"status": "error", "error": "Inject operation timed out"}

            if not results:
                return {"context": "", "count": 0}

            # Filter out transient categories that add noise, not value
            _INJECT_EXCLUDE = {"file_ownership", "checkpoint", "session_summary"}
            results = [r for r in results if r["category"] not in _INJECT_EXCLUDE]

            if not results:
                return {"context": "", "count": 0}

            # Format for context injection
            lines = ["## Recalled Memories", ""]
            for r in results:
                lines.append(f"- [{r['category']}] {r['content']}")
            lines.append("")

            # H4: Track injected IDs for dedup with contextual_load
            # Merge (not overwrite) so multiple inject calls in one session accumulate
            session_id = request.get("session_id", proj_tag or "global")
            if session_id not in self._injected_ids:
                self._injected_ids[session_id] = set()
            self._injected_ids[session_id].update(r["id"] for r in results)
            return {"context": "\n".join(lines), "count": len(results)}

        elif action == "star_startup":
            # Claude Star plugin startup: detect plugins and return hook JSON
            try:
                from claude_star.startup import run_startup

                hook_json = run_startup()
                hook_output = json.loads(hook_json)
                # Cache profile and proj_tag from startup output
                if isinstance(hook_output, dict):
                    if "profile" in hook_output:
                        self._last_startup_config["profile"] = hook_output["profile"]
                    if "proj_tag" in hook_output:
                        self._last_startup_config["proj_tag"] = hook_output["proj_tag"]
                return {"hook_output": hook_output}
            except ImportError:
                return {
                    "hook_output": {},
                    "warning": "star package not installed",
                }
            except Exception as e:
                logger.warning("star_startup failed: %s", e)
                return {"hook_output": {}, "error": str(e)}

        elif action == "batch_store":
            # H5: Input validation
            items = request.get("items", [])
            if len(items) > _MAX_BATCH_SIZE:
                return {
                    "status": "error",
                    "error": f"Batch size exceeds maximum of {_MAX_BATCH_SIZE}",
                }
            # Fix #4: Validate content length per item (same as store())
            for i, item in enumerate(items):
                content = item.get("content", "")
                if not isinstance(content, str) or len(content) > _MAX_CONTENT_LENGTH:
                    return {
                        "status": "error",
                        "error": (
                            f"Item {i}: content must be a string under {_MAX_CONTENT_LENGTH} chars"
                        ),
                    }
            try:
                ids = await with_timeout(
                    asyncio.to_thread(self.storage.batch_store, items),
                    timeout=TIMEOUT_DB_WRITE,
                    label="batch_store",
                )
            except TimeoutError:
                return {"status": "error", "error": "Batch store operation timed out"}
            return {"ids": ids, "status": "stored", "count": len(ids)}

        elif action == "batch_search":
            # H5: Input validation
            queries = request.get("queries", [])
            if len(queries) > _MAX_BATCH_SIZE:
                return {
                    "status": "error",
                    "error": f"Batch size exceeds maximum of {_MAX_BATCH_SIZE}",
                }
            try:
                results = await with_timeout(
                    asyncio.to_thread(self.storage.batch_search, queries),
                    timeout=TIMEOUT_DB_READ,
                    label="batch_search",
                )
            except TimeoutError:
                return {"status": "error", "error": "Batch search operation timed out"}
            return {"results": results, "count": len(results)}

        elif action == "checkpoint_tick":
            session_id = request.get("session_id", "default")
            profile = request.get("profile", "balanced").lower()
            proj_tag = request.get("proj_tag")

            # Phase 3.9: Track tool_name per session for handoff generation
            tool_name = request.get("tool_name")
            if tool_name:
                if session_id not in self._session_tool_counts:
                    self._session_tool_counts[session_id] = {}
                counts = self._session_tool_counts[session_id]
                counts[tool_name] = counts.get(tool_name, 0) + 1

            # D10 Micro tier: fire-and-forget checkpoint on file mutations
            if tool_name and tool_name in _MICRO_TOOLS:
                mono_now = time.monotonic()
                last_ts = self._last_micro_ts.get(session_id, 0.0)
                if mono_now - last_ts >= _MICRO_RATE_LIMIT:
                    self._last_micro_ts[session_id] = mono_now
                    counter_snapshot = self._checkpoint_counters.get(session_id, 0) + 1
                    micro_content = f"[micro] {tool_name} at tick {counter_snapshot}"
                    # Fire-and-forget: schedule store without awaiting
                    asyncio.create_task(
                        self._store_tier_checkpoint(
                            content=micro_content,
                            category="checkpoint",
                            metadata={
                                "tier": "micro",
                                "session_id": session_id,
                                "tool_name": tool_name,
                            },
                            proj_tag=proj_tag,
                        )
                    )

            threshold = self._checkpoint_thresholds.get(profile, 25)
            counter = self._checkpoint_counters.get(session_id, 0) + 1
            self._checkpoint_counters[session_id] = counter

            # D10 Progress tier: periodic checkpoint every N ticks (profile-dependent)
            progress_threshold = self._progress_thresholds.get(profile, 5)
            if counter % progress_threshold == 0 and counter > 0 and counter < threshold:
                tool_counts = self._session_tool_counts.get(session_id, {})
                tool_summary = ", ".join(f"{t}={c}" for t, c in sorted(tool_counts.items()))
                progress_content = f"Progress checkpoint #{counter}: tools={{{tool_summary}}}"
                asyncio.create_task(
                    self._store_tier_checkpoint(
                        content=progress_content,
                        category="checkpoint",
                        metadata={
                            "tier": "progress",
                            "session_id": session_id,
                            "counter": counter,
                        },
                        proj_tag=proj_tag,
                    )
                )

            if counter >= threshold:
                self._checkpoint_counters[session_id] = 0
                snapshot = request.get("snapshot", "")
                if snapshot:
                    try:
                        await with_timeout(
                            asyncio.to_thread(
                                self.storage.store,
                                content=snapshot,
                                category="checkpoint",
                                metadata={"session_id": session_id, "source": "auto_checkpoint"},
                                proj_tag=proj_tag,
                            ),
                            timeout=TIMEOUT_DB_WRITE,
                            label="checkpoint_store",
                        )
                    except TimeoutError:
                        # Checkpoint is fire-and-forget; log but don't fail the tick
                        logger.warning("Checkpoint store timed out for session %s", session_id)
                return {"checkpoint_fired": True, "counter": 0, "threshold": threshold}

            return {"checkpoint_fired": False, "counter": counter, "threshold": threshold}

        elif action == "contextual_load":
            query = request.get("message", request.get("query", ""))
            proj_tag = request.get("proj_tag")
            limit = min(request.get("limit", 3), _MAX_LIMIT)
            category = request.get("category")

            if not query:
                return {"context": "", "count": 0}

            # Tier 1: exact cache lookup
            cached = self.cache.get_exact(query)
            if cached is not None:
                return cached

            # Tier 2: semantic search via storage
            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.search,
                        query=query,
                        limit=limit,
                        category=category,
                        proj_tag=proj_tag,
                        include_global=True,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="contextual_load",
                )
            except TimeoutError:
                return {"status": "error", "error": "Contextual load operation timed out"}

            if not results:
                self.cache.record_miss()
                return {"context": "", "count": 0}

            sim_threshold: float = self.cache.similarity_threshold
            relevant = [r for r in results if r.get("similarity", 0) >= sim_threshold]

            if relevant:
                self.cache.record_semantic_hit()
            else:
                self.cache.record_miss()
                return {"context": "", "count": 0}

            # H4: Filter out already-injected IDs to avoid duplication
            session_id = request.get("session_id", proj_tag or "global")
            already_injected = self._injected_ids.get(session_id, set())
            relevant = [r for r in relevant if r["id"] not in already_injected]

            if not relevant:
                return {"context": "", "count": 0}

            lines = []
            for r in relevant:
                lines.append(f"[{r['category']}] {r['content']}")
            context = "\n".join(lines)

            response: dict[str, Any] = {"context": context, "count": len(relevant)}
            self.cache.put(query, response)
            return response

        elif action == "cache_stats":
            return {"cache": self.cache.stats}

        elif action == "session_stats":
            session_id = request.get("session_id", "default")
            counter = self._checkpoint_counters.get(session_id, 0)
            return {"session_id": session_id, "tool_call_count": counter}

        elif action == "get_config":
            return {
                "status": "ok",
                "profile": self._last_startup_config["profile"],
                "proj_tag": self._last_startup_config["proj_tag"],
            }

        elif action == "what_changed":
            proj_tag = request.get("proj_tag")
            if proj_tag is None:
                return {"status": "error", "error": "proj_tag is required"}

            last_end = self._last_session_end.get(proj_tag)
            if last_end is None:
                return {"status": "ok", "count": 0, "categories": {}}

            # SQL-level time filtering: get memories created after last session end
            try:
                recent = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        proj_tag=proj_tag,
                        since=last_end,
                        include_global=False,
                        limit=50,  # Q9: Reduced from 1000
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="what_changed",
                )
            except TimeoutError:
                return {"status": "error", "error": "What changed operation timed out"}

            # Python-level category filtering: only cross-window categories
            category_counts: dict[str, int] = {}
            total = 0
            for mem in recent:
                cat = mem.get("category", "")
                if cat in CROSS_WINDOW_CATEGORIES:
                    category_counts[cat] = category_counts.get(cat, 0) + 1
                    total += 1

            return {"status": "ok", "count": total, "categories": category_counts}

        elif action == "health_check":
            try:
                # Database file size
                db_size_bytes = 0
                if self.storage.db_path.exists():
                    db_size_bytes = self.storage.db_path.stat().st_size

                # C1: Run blocking DB queries in a thread
                db_info = await with_timeout(
                    asyncio.to_thread(self._health_check_db_queries),
                    timeout=TIMEOUT_DB_READ,
                    label="health_check",
                )
                memory_count = db_info["memory_count"]
                oldest_memory = db_info["oldest_memory"]
                newest_memory = db_info["newest_memory"]

                # WAL file size
                wal_path = self.storage.db_path.parent / (self.storage.db_path.name + "-wal")
                wal_size_bytes = 0
                if wal_path.exists():
                    wal_size_bytes = wal_path.stat().st_size

                # Socket info
                socket_path = str(SOCKET_PATH)
                socket_exists = SOCKET_PATH.exists()

                # PID info
                pid = os.getpid()
                pid_file_exists = PID_FILE.exists()

                # Embedding model
                embedding_model_loaded = self.storage.embedding_model_loaded

                # Cache size (number of entries in exact cache)
                cache_size = len(self.cache._exact_cache)

                # Checkpoint sessions
                checkpoint_sessions = len(self._checkpoint_counters)

                # Uptime info
                uptime_info = "pid file exists" if pid_file_exists else "no pid file"

                return {
                    "status": "ok",
                    "health": {
                        "db_size_bytes": db_size_bytes,
                        "memory_count": memory_count,
                        "oldest_memory": oldest_memory,
                        "newest_memory": newest_memory,
                        "wal_size_bytes": wal_size_bytes,
                        "socket_path": socket_path,
                        "socket_exists": socket_exists,
                        "pid": pid,
                        "pid_file_exists": pid_file_exists,
                        "embedding_model_loaded": embedding_model_loaded,
                        "cache_size": cache_size,
                        "checkpoint_sessions": checkpoint_sessions,
                        "uptime_info": uptime_info,
                        "token_tracker": self.token_tracker.stats(),
                    },
                }
            except TimeoutError:
                return {"status": "error", "error": "Health check timed out"}
            except Exception as e:
                logger.exception("health_check failed")
                return {"status": "error", "error": str(e)}

        elif action == "update_counter":
            key = request.get("key")
            if not key or not isinstance(key, str):
                return {"status": "error", "error": "Missing required field: key"}
            delta = request.get("delta", 1)
            if not isinstance(delta, int) or isinstance(delta, bool):
                return {"status": "error", "error": "delta must be an integer"}
            proj_tag = request.get("proj_tag")
            try:
                value = await with_timeout(
                    asyncio.to_thread(
                        self.storage.update_counter, key=key, delta=delta, proj_tag=proj_tag
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="update_counter",
                )
            except TimeoutError:
                return {"status": "error", "error": "Update counter operation timed out"}
            return {
                "status": "ok",
                "key": key,
                "value": value,
                "proj_tag": proj_tag or "",
            }

        elif action == "set_counter":
            key = request.get("key")
            if not key or not isinstance(key, str):
                return {"status": "error", "error": "Missing required field: key"}
            value = request.get("value")
            if value is None:
                return {"status": "error", "error": "Missing required field: value"}
            if not isinstance(value, int) or isinstance(value, bool):
                return {"status": "error", "error": "value must be an integer"}
            proj_tag = request.get("proj_tag")
            try:
                new_value = await with_timeout(
                    asyncio.to_thread(
                        self.storage.set_counter, key=key, value=value, proj_tag=proj_tag
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="set_counter",
                )
            except TimeoutError:
                return {"status": "error", "error": "Set counter operation timed out"}
            return {
                "status": "ok",
                "key": key,
                "value": new_value,
                "proj_tag": proj_tag or "",
            }

        elif action == "get_counters":
            key = request.get("key")
            proj_tag = request.get("proj_tag")
            if key:
                try:
                    value = await with_timeout(
                        asyncio.to_thread(self.storage.get_counter, key=key, proj_tag=proj_tag),
                        timeout=TIMEOUT_DB_READ,
                        label="get_counter",
                    )
                except TimeoutError:
                    return {"status": "error", "error": "Get counter operation timed out"}
                return {"status": "ok", "key": key, "value": value}
            else:
                try:
                    counters = await with_timeout(
                        asyncio.to_thread(self.storage.get_counters, proj_tag=proj_tag),
                        timeout=TIMEOUT_DB_READ,
                        label="get_counters",
                    )
                except TimeoutError:
                    return {"status": "error", "error": "Get counters operation timed out"}
                return {"status": "ok", "counters": counters}

        elif action == "delete_counter":
            key = request.get("key")
            if not key or not isinstance(key, str):
                return {"status": "error", "error": "Missing required field: key"}
            proj_tag = request.get("proj_tag")
            try:
                deleted = await with_timeout(
                    asyncio.to_thread(self.storage.delete_counter, key=key, proj_tag=proj_tag),
                    timeout=TIMEOUT_DB_WRITE,
                    label="delete_counter",
                )
            except TimeoutError:
                return {"status": "error", "error": "Delete counter operation timed out"}
            return {"status": "ok", "deleted": deleted}

        elif action == "context_monitor":
            return self._handle_context_monitor(request)

        elif action == "get_handoff_status":
            return await self._handle_get_handoff_status(request)

        elif action == "get_handoff":
            session_id = request.get("session_id", "default")
            state = self._handoff_state.get(session_id)

            # Fallback: if no state for this session_id, find latest unconsumed
            # (handles session_id change after /clear — Research Open Question 1)
            if not state or not state.ready or state.consumed:
                for sid in list(self._handoff_state.keys()):
                    s = self._handoff_state.get(sid)
                    if s and s.ready and not s.consumed and s.document:
                        state = s
                        break

            if state and state.ready and not state.consumed and state.document:
                state.consumed = True
                return {
                    "status": "ok",
                    "document": state.document,
                    "tier": state.tier.name.lower(),
                }
            return {"status": "ok", "document": None}

        elif action == "clear_handoff":
            session_id = request.get("session_id", "default")
            if session_id in self._handoff_state:
                del self._handoff_state[session_id]
            return {"status": "ok", "cleared": True}

        elif action == "snooze_handoff":
            session_id = request.get("session_id", "default")
            state = self._handoff_state.get(session_id)
            current_pct = float(request.get("current_pct", 0.0))

            if state:
                # Progressive snooze: increment grows with each snooze (I2 + O3)
                base_increment = self._calibration.snooze_pct_increment
                state.snooze_count += 1
                increment = min(
                    base_increment * (1 + 0.6 * (state.snooze_count - 1)),
                    self._calibration.max_snooze_pct,
                )
                state.ready = False
                state.snooze_until_pct = current_pct + increment
                return {
                    "status": "ok",
                    "snooze_until_pct": state.snooze_until_pct,
                    "snooze_count": state.snooze_count,
                }

            return {"status": "ok", "snooze_until_pct": None}

        elif action == "inbox":
            # Worker-to-worker messaging inbox (#52)
            worker_id = request.get("worker_id")
            if not worker_id:
                return {"status": "error", "error": "Missing required field: worker_id"}

            # Query worker_msg category for messages addressed to this worker
            limit = min(request.get("limit", 20), _MAX_LIMIT)
            try:
                recent = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="worker_msg",
                        limit=limit,
                        include_global=True,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="inbox",
                )
            except TimeoutError:
                return {"status": "error", "error": "Inbox operation timed out"}

            # Filter for messages addressed to this worker (TO:<worker_id>)
            prefix = f"TO:{worker_id}"
            messages = []
            for mem in recent:
                content = mem.get("content", "")
                if content.startswith(prefix):
                    # Parse message format: "TO:<target> FROM:<sender> — <body>"
                    parsed = self._parse_worker_message(content)
                    messages.append(
                        {
                            "id": mem["id"],
                            "content": content,
                            "to": parsed["to"],
                            "from": parsed["from"],
                            "body": parsed["body"],
                            "created_at": mem["created_at"],
                        }
                    )

            return {"status": "ok", "messages": messages, "count": len(messages)}

        elif action == "claim_file":
            # Phase 6: Create advisory file/symbol claim
            agent_id = request.get("agent_id")
            branch = request.get("branch")
            target = request.get("target")
            level = request.get("level", "file")
            status = request.get("status", "active")
            task_description = request.get("task_description", "")
            gsd_context = request.get("gsd_context", {})
            tool_context = request.get("tool_context", {})
            proj_tag = request.get("proj_tag")

            # Validate required fields
            if not agent_id:
                return {"status": "error", "error": "Missing required field: agent_id"}
            if not branch:
                return {"status": "error", "error": "Missing required field: branch"}
            if not target:
                return {"status": "error", "error": "Missing required field: target"}

            # Compute expires_at based on status (15min planned, 30min active)
            now = datetime.now(tz=UTC)
            ttl_seconds = 900 if status == "planned" else 1800  # 15min / 30min
            expires = now + timedelta(seconds=ttl_seconds)
            expires_at = expires.isoformat()
            created_at = now.isoformat()

            # Build claim data dict for storage
            claim_data = {
                "agent_id": agent_id,
                "branch": branch,
                "target": target,
                "level": level,
                "status": status,
                "task_description": task_description,
                "gsd_context": gsd_context,
                "tool_context": tool_context,
                "created_at": created_at,
                "expires_at": expires_at,
            }

            # Store as file_claim category with content=f"{branch}:{target}"
            content = f"{branch}:{target}"
            try:
                memory_id = await with_timeout(
                    asyncio.to_thread(
                        self.storage.store,
                        content=content,
                        category="file_claim",
                        metadata=claim_data,
                        proj_tag=proj_tag,
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="claim_file",
                )
            except TimeoutError:
                return {"status": "error", "error": "Claim file operation timed out"}

            claim_data["memory_id"] = memory_id
            return {"status": "ok", "claim": claim_data}

        elif action == "release_file":
            # Phase 6: Release advisory file/symbol claim
            agent_id = request.get("agent_id")
            branch = request.get("branch")
            target = request.get("target")
            proj_tag = request.get("proj_tag")

            if not agent_id:
                return {"status": "error", "error": "Missing required field: agent_id"}
            if not branch:
                return {"status": "error", "error": "Missing required field: branch"}
            if not target:
                return {"status": "error", "error": "Missing required field: target"}

            # Search for matching claim by content key
            content_key = f"{branch}:{target}"
            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="file_claim",
                        limit=50,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="release_file_search",
                )
            except TimeoutError:
                return {"status": "error", "error": "Release file search timed out"}

            released = False
            for mem in results:
                # Match by content (branch:target) and agent_id in metadata
                if mem.get("content") != content_key:
                    continue
                metadata = mem.get("metadata", {})
                if metadata.get("agent_id") != agent_id:
                    continue
                if metadata.get("target") != target:
                    continue

                # Found matching claim - delete it
                mem_id = mem.get("id")
                if mem_id:
                    try:
                        await with_timeout(
                            asyncio.to_thread(self.storage.delete, mem_id),
                            timeout=TIMEOUT_DB_WRITE,
                            label="release_file_delete",
                        )
                        released = True
                    except TimeoutError:
                        return {"status": "error", "error": "Release file delete timed out"}
                break

            return {"status": "ok", "released": released}

        elif action == "check_claim":
            # Phase 6: Check for conflicting claims on a target
            target = request.get("target")
            agent_id = request.get("agent_id")
            branch = request.get("branch")
            proj_tag = request.get("proj_tag")

            if not target:
                return {"status": "error", "error": "Missing required field: target"}
            if not agent_id:
                return {"status": "error", "error": "Missing required field: agent_id"}
            if not branch:
                return {"status": "error", "error": "Missing required field: branch"}

            content_key = f"{branch}:{target}"
            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="file_claim",
                        limit=50,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="check_claim_search",
                )
            except TimeoutError:
                return {"status": "error", "error": "Check claim search timed out"}

            now = datetime.now(tz=UTC)

            for mem in results:
                # Match by content key
                if mem.get("content") != content_key:
                    continue
                metadata = mem.get("metadata", {})
                if metadata.get("target") != target:
                    continue
                if metadata.get("branch") != branch:
                    continue

                # Check if expired
                expires_at_str = metadata.get("expires_at", "")
                if self._is_claim_expired(expires_at_str, now):
                    continue

                # Self-claims are OK
                if metadata.get("agent_id") == agent_id:
                    continue

                # Found a conflict - format XML warning
                warning = self._format_claim_warning(metadata, now)
                return {
                    "status": "ok",
                    "clear": False,
                    "warning": warning,
                    "claim": metadata,
                }

            # No conflicts
            return {"status": "ok", "clear": True, "warning": None, "claim": None}

        elif action == "get_claims":
            # Phase 6: Get all active claims for project with optional filters
            proj_tag = request.get("proj_tag")
            branch = request.get("branch")
            filter_agent_id = request.get("agent_id")

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="file_claim",
                        limit=100,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_claims",
                )
            except TimeoutError:
                return {"status": "error", "error": "Get claims timed out"}

            now = datetime.now(tz=UTC)
            claims = []
            for mem in results:
                metadata = mem.get("metadata", {})

                # Filter expired
                expires_at_str = metadata.get("expires_at", "")
                if self._is_claim_expired(expires_at_str, now):
                    continue

                # Apply branch filter
                if branch is not None and metadata.get("branch") != branch:
                    continue

                # Apply agent_id filter
                if filter_agent_id is not None and metadata.get("agent_id") != filter_agent_id:
                    continue

                claims.append(metadata)

            return {"status": "ok", "claims": claims}

        elif action == "broadcast_interface":
            # Phase 6: Store interface announcement for agent coordination
            interface_id = request.get("interface_id")
            if not interface_id:
                return {"status": "error", "error": "Missing required field: interface_id"}

            name = request.get("name", "")
            module_path = request.get("module_path", "")
            file_path = request.get("file_path", "")
            line_number = request.get("line_number", 0)
            signature = request.get("signature", "")
            docstring = request.get("docstring")
            dependencies = request.get("dependencies", [])
            status = request.get("status", "uncommitted")
            version = request.get("version", 1)
            created_by = request.get("created_by", "")
            gsd_context = request.get("gsd_context", {})
            breaking_change = request.get("breaking_change", False)
            previous_signature = request.get("previous_signature")
            proj_tag = request.get("proj_tag")

            # Build interface data dict
            interface_data = {
                "interface_id": interface_id,
                "name": name,
                "module_path": module_path,
                "file_path": file_path,
                "line_number": line_number,
                "signature": signature,
                "docstring": docstring,
                "dependencies": dependencies,
                "status": status,
                "version": version,
                "created_by": created_by,
                "gsd_context": gsd_context,
                "breaking_change": breaking_change,
                "previous_signature": previous_signature,
                "created_at": datetime.now(tz=UTC).isoformat(),
            }

            # Content is "{module_path}:{name}" for searchability
            content = f"{module_path}:{name}"

            try:
                memory_id = await with_timeout(
                    asyncio.to_thread(
                        self.storage.store,
                        content=content,
                        category="interface_broadcast",
                        metadata=interface_data,
                        proj_tag=proj_tag,
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="broadcast_interface",
                )
            except TimeoutError:
                return {"status": "error", "error": "Broadcast interface operation timed out"}

            return {"status": "ok", "interface_id": interface_id, "version": version}

        elif action == "get_interfaces":
            # Phase 6: Query interfaces by package/pattern/status
            proj_tag = request.get("proj_tag")
            package_filter = request.get("package")
            pattern_filter = request.get("pattern")
            status_filter = request.get("status")
            limit = min(request.get("limit", 50), _MAX_LIMIT)

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="interface_broadcast",
                        limit=limit,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_interfaces",
                )
            except TimeoutError:
                return {"status": "error", "error": "Get interfaces timed out"}

            interfaces = self._filter_interfaces(
                results, package_filter, pattern_filter, status_filter
            )
            return {"status": "ok", "interfaces": interfaces, "count": len(interfaces)}

        elif action == "get_breaking_changes":
            # Phase 6: Query interfaces with breaking_change=True
            proj_tag = request.get("proj_tag")
            since = request.get("since")

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="interface_broadcast",
                        limit=100,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_breaking_changes",
                )
            except TimeoutError:
                return {"status": "error", "error": "Get breaking changes timed out"}

            breaking_changes = []
            for mem in results:
                metadata = mem.get("metadata", {})
                if not metadata.get("breaking_change", False):
                    continue

                # Filter by since timestamp if provided
                if since:
                    created_at = metadata.get("created_at", "")
                    if created_at and created_at <= since:
                        continue

                breaking_changes.append(metadata)

            return {
                "status": "ok",
                "breaking_changes": breaking_changes,
                "count": len(breaking_changes),
            }

        elif action == "log_event":
            # Phase 6: Log coordination event
            from uuid import uuid4

            event_type = request.get("event_type")
            if not event_type:
                return {"status": "error", "error": "Missing required field: event_type"}

            agent_id = request.get("agent_id")
            payload = request.get("payload", {})
            phase = request.get("phase")
            wave = request.get("wave")
            proj_tag = request.get("proj_tag")

            event_id = str(uuid4())
            timestamp = datetime.now(tz=UTC).isoformat()

            event_data = {
                "event_id": event_id,
                "timestamp": timestamp,
                "event_type": event_type,
                "agent_id": agent_id,
                "payload": payload,
                "phase": phase,
                "wave": wave,
            }

            content = f"{event_type}:{agent_id or 'system'}"
            try:
                memory_id = await with_timeout(
                    asyncio.to_thread(
                        self.storage.store,
                        content=content,
                        category="coordination_event",
                        metadata=event_data,
                        proj_tag=proj_tag,
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="log_event",
                )
            except TimeoutError:
                return {"status": "error", "error": "Log event operation timed out"}

            return {"status": "ok", "event_id": event_id, "memory_id": memory_id}

        elif action == "get_events":
            # Phase 6: Query coordination events with filters
            proj_tag = request.get("proj_tag")
            since = request.get("since")
            until = request.get("until")
            event_type_filter = request.get("event_type")
            agent_id_filter = request.get("agent_id")
            limit = min(request.get("limit", 100), _MAX_LIMIT)

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="coordination_event",
                        limit=limit,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_events",
                )
            except TimeoutError:
                return {"status": "error", "error": "Get events operation timed out"}

            # Apply filters
            events = []
            for mem in results:
                metadata = mem.get("metadata", {})
                if not metadata:
                    continue

                # Time range filter (since)
                if since:
                    event_ts = metadata.get("timestamp", "")
                    if event_ts and event_ts < since:
                        continue

                # Time range filter (until)
                if until:
                    event_ts = metadata.get("timestamp", "")
                    if event_ts and event_ts > until:
                        continue

                # Event type filter
                if event_type_filter and metadata.get("event_type") != event_type_filter:
                    continue

                # Agent ID filter
                if agent_id_filter and metadata.get("agent_id") != agent_id_filter:
                    continue

                events.append(metadata)

            return {"status": "ok", "events": events, "count": len(events)}

        elif action == "create_checkpoint":
            # Phase 6: Create checkpoint for time travel debugging
            name = request.get("name")
            if not name:
                return {"status": "error", "error": "Missing required field: name"}

            proj_tag = request.get("proj_tag")
            git_ref = request.get("git_ref", "unknown")
            git_branch = request.get("git_branch", "unknown")
            state_md = request.get("state_md")

            created_at = datetime.now(tz=UTC).isoformat()

            checkpoint_data = {
                "name": name,
                "git_ref": git_ref,
                "git_branch": git_branch,
                "state_md": state_md,
                "created_at": created_at,
            }

            content = f"checkpoint:{name}"
            try:
                memory_id = await with_timeout(
                    asyncio.to_thread(
                        self.storage.store,
                        content=content,
                        category="checkpoint",
                        metadata=checkpoint_data,
                        proj_tag=proj_tag,
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="create_checkpoint",
                )
            except TimeoutError:
                return {"status": "error", "error": "Create checkpoint timed out"}

            checkpoint_data["memory_id"] = memory_id
            return {"status": "ok", "checkpoint": checkpoint_data}

        elif action == "get_checkpoints":
            # Phase 6: Get recent checkpoints for a project
            proj_tag = request.get("proj_tag")
            limit = min(request.get("limit", 10), _MAX_LIMIT)

            try:
                results = await with_timeout(
                    asyncio.to_thread(
                        self.storage.get_recent,
                        category="checkpoint",
                        limit=limit,
                        proj_tag=proj_tag,
                        include_global=False,
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_checkpoints",
                )
            except TimeoutError:
                return {"status": "error", "error": "Get checkpoints timed out"}

            checkpoints = []
            for mem in results:
                metadata = mem.get("metadata", {})
                if metadata and "name" in metadata:
                    checkpoints.append(metadata)

            return {"status": "ok", "checkpoints": checkpoints, "count": len(checkpoints)}

        elif action == "consolidate":
            # Phase 12.1a: HDBSCAN-based lesson consolidation
            dry_run = request.get("dry_run", True)
            min_cluster_size = request.get("min_cluster_size", 3)
            min_samples = request.get("min_samples", 2)
            try:
                from .consolidation import consolidate_lessons

                report = await with_timeout(
                    asyncio.to_thread(
                        consolidate_lessons,
                        storage=self.storage,
                        min_cluster_size=min_cluster_size,
                        min_samples=min_samples,
                        dry_run=dry_run,
                    ),
                    timeout=60.0,  # Clustering can be slow on large datasets
                    label="consolidate_lessons",
                )
            except ImportError as e:
                return {
                    "status": "error",
                    "error": f"scikit-learn >= 1.3 required: {e}",
                }
            except TimeoutError:
                return {"status": "error", "error": "Consolidation timed out"}

            clusters_data = []
            for c in report.clusters:
                clusters_data.append(
                    {
                        "cluster_id": c.cluster_id,
                        "member_ids": c.member_ids,
                        "member_previews": c.member_previews,
                        "representative_id": c.representative_id,
                        "representative_preview": c.representative_preview,
                        "superseded_ids": c.superseded_ids,
                        "token_savings": c.token_savings,
                    }
                )

            return {
                "status": "ok",
                "total_lessons": report.total_lessons,
                "clusters_found": report.clusters_found,
                "memories_consolidated": report.memories_consolidated,
                "tokens_saved": report.tokens_saved,
                "noise_count": report.noise_count,
                "dry_run": report.dry_run,
                "clusters": clusters_data,
            }

        elif action == "consolidation_due":
            # Phase E2: Check if auto-consolidation is due
            overdue = self._consolidation_overdue()
            return {
                "status": "ok",
                "due": overdue,
                "store_count": self._store_count,
                "threshold": self._consolidation_threshold,
            }

        elif action == "broadcast_interfaces_for_file":
            # Phase 6: Extract and broadcast interfaces from a Python file
            # Called by PostToolUse hook after Write/Edit on .py files
            file_path = request.get("file_path")
            if not file_path or not file_path.endswith(".py"):
                return {"status": "ok", "count": 0, "interfaces": []}

            agent_id = request.get("agent_id", "unknown")
            gsd_context = request.get("gsd_context", {})
            proj_tag = request.get("proj_tag")

            try:
                from claude_star.coordination.interfaces import InterfaceManager
                from claude_star.memory.bridge import MemoryBridge

                bridge = MemoryBridge(socket_path=str(SOCKET_PATH))
                manager = InterfaceManager(bridge=bridge, proj_tag=proj_tag)

                broadcasts = manager.broadcast_interface(
                    file_path=file_path,
                    agent_id=agent_id,
                    gsd_context=gsd_context,
                )

                return {
                    "status": "ok",
                    "count": len(broadcasts),
                    "interfaces": [b.name for b in broadcasts],
                }
            except ImportError as e:
                logger.warning("broadcast_interfaces_for_file failed (import): %s", e)
                return {"status": "error", "error": f"Import error: {e}"}
            except Exception as e:
                logger.warning("broadcast_interfaces_for_file failed: %s", e)
                return {"status": "error", "error": str(e)}

        elif action == "semantic_cache_stats":
            cache = self.storage._semantic_cache
            if cache is None:
                return {"status": "ok", "cache": {"enabled": False}}
            stats = cache.stats()
            return {
                "status": "ok",
                "cache": {
                    "enabled": True,
                    "hits": stats.hits,
                    "misses": stats.misses,
                    "hit_rate": stats.hit_rate,
                    "entries": stats.entries,
                    "avg_age_seconds": round(stats.avg_age_seconds, 1),
                },
            }

        elif action == "semantic_cache_clear":
            cache = self.storage._semantic_cache
            if cache is None:
                return {"status": "ok", "cleared": 0}
            cleared = cache.invalidate()
            return {"status": "ok", "cleared": cleared}

        # ---------------------------------------------------------------
        # Graph relation actions (Phase 13a)
        # ---------------------------------------------------------------

        elif action == "add_relation":
            for field in ("source_id", "target_id", "relation_type"):
                if field not in request:
                    return {"status": "error", "error": f"Missing required field: {field}"}

            from claude_engram.graph import add_relation as graph_add_relation

            try:
                rel_id = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_add_relation(
                            self.storage._get_conn(),
                            source_id=request["source_id"],
                            target_id=request["target_id"],
                            relation_type=request["relation_type"],
                            metadata=request.get("metadata"),
                            valid_from=request.get("valid_from"),
                        )
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="add_relation",
                )
            except ValueError as e:
                return {"status": "error", "error": str(e)}
            except TimeoutError:
                return {"status": "error", "error": "add_relation operation timed out"}
            created = rel_id is not None
            return {"status": "ok", "relation_id": rel_id, "created": created}

        elif action == "remove_relation":
            for field in ("source_id", "target_id", "relation_type"):
                if field not in request:
                    return {"status": "error", "error": f"Missing required field: {field}"}

            from claude_engram.graph import remove_relation as graph_remove_relation

            try:
                removed = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_remove_relation(
                            self.storage._get_conn(),
                            source_id=request["source_id"],
                            target_id=request["target_id"],
                            relation_type=request["relation_type"],
                        )
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="remove_relation",
                )
            except TimeoutError:
                return {"status": "error", "error": "remove_relation operation timed out"}
            return {"status": "ok", "removed": removed}

        elif action == "get_relations":
            if "memory_id" not in request:
                return {"status": "error", "error": "Missing required field: memory_id"}

            from claude_engram.graph import get_relations as graph_get_relations

            try:
                rels = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_get_relations(
                            self.storage._get_conn(),
                            memory_id=request["memory_id"],
                            direction=request.get("direction", "both"),
                            relation_types=request.get("relation_types"),
                            include_memory_summary=request.get("include_memory_summary", False),
                        )
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="get_relations",
                )
            except TimeoutError:
                return {"status": "error", "error": "get_relations operation timed out"}
            return {"status": "ok", "relations": rels}

        elif action == "traverse_graph":
            if "start_id" not in request:
                return {"status": "error", "error": "Missing required field: start_id"}

            from claude_engram.graph import traverse as graph_traverse

            try:
                nodes = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_traverse(
                            self.storage._get_conn(),
                            start_id=request["start_id"],
                            depth=min(request.get("depth", 3), 5),
                            direction=request.get("direction", "outgoing"),
                            relation_types=request.get("relation_types"),
                        )
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="traverse_graph",
                )
            except TimeoutError:
                return {"status": "error", "error": "traverse_graph operation timed out"}
            return {"status": "ok", "nodes": nodes}

        elif action == "timeline":
            if "anchor_id" not in request:
                return {"status": "error", "error": "Missing required field: anchor_id"}

            from claude_engram.graph import timeline as graph_timeline

            try:
                result = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_timeline(
                            self.storage._get_conn(),
                            anchor_id=request["anchor_id"],
                            depth_before=min(request.get("depth_before", 5), 20),
                            depth_after=min(request.get("depth_after", 5), 20),
                            follow_edges=request.get("follow_edges", False),
                            relation_types=request.get("relation_types"),
                            detail_level=request.get("detail_level", "summary"),
                        )
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="timeline",
                )
            except TimeoutError:
                return {"status": "error", "error": "timeline operation timed out"}
            return {"status": "ok", **result}

        # ---------------------------------------------------------------
        # Decision→outcome tracking (Phase 13b)
        # ---------------------------------------------------------------

        elif action == "record_outcome":
            for field in ("decision_id", "outcome_id"):
                if field not in request:
                    return {"status": "error", "error": f"Missing required field: {field}"}

            from claude_engram.graph import record_outcome as graph_record_outcome

            try:
                rel_id = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_record_outcome(
                            self.storage._get_conn(),
                            decision_id=request["decision_id"],
                            outcome_id=request["outcome_id"],
                            confidence=request.get("confidence", 1.0),
                            measurement_method=request.get("measurement_method"),
                            impact_score=request.get("impact_score"),
                            measured_at=request.get("measured_at"),
                            causation_id=request.get("causation_id"),
                            correlation_id=request.get("correlation_id"),
                        )
                    ),
                    timeout=TIMEOUT_DB_WRITE,
                    label="record_outcome",
                )
            except ValueError as e:
                return {"status": "error", "error": str(e)}
            except TimeoutError:
                return {"status": "error", "error": "record_outcome operation timed out"}
            created = rel_id is not None
            return {"status": "ok", "relation_id": rel_id, "created": created}

        elif action == "decision_chain":
            if "start_id" not in request:
                return {"status": "error", "error": "Missing required field: start_id"}

            from claude_engram.graph import decision_chain as graph_decision_chain

            try:
                result = await with_timeout(
                    asyncio.to_thread(
                        lambda: graph_decision_chain(
                            self.storage._get_conn(),
                            start_id=request["start_id"],
                            depth=min(request.get("depth", 3), 5),
                            direction=request.get("direction", "outgoing"),
                            include_metadata=request.get("include_metadata", True),
                        )
                    ),
                    timeout=TIMEOUT_DB_READ,
                    label="decision_chain",
                )
            except TimeoutError:
                return {"status": "error", "error": "decision_chain operation timed out"}
            return {"status": "ok", **result}

        else:
            return {"status": "error", "error": f"Unknown action: {action}"}

    @staticmethod
    async def _socket_is_alive() -> bool:
        """Probe the existing socket to check if another daemon is listening."""
        try:
            _, writer = await asyncio.wait_for(
                asyncio.open_unix_connection(str(SOCKET_PATH)),
                timeout=2.0,
            )
            writer.close()
            await writer.wait_closed()
            return True
        except (TimeoutError, ConnectionRefusedError, FileNotFoundError, OSError):
            return False

    async def start(self) -> None:
        """Start the daemon server."""
        # Ensure socket path directory exists
        SOCKET_PATH.parent.mkdir(parents=True, exist_ok=True)

        # Probe existing socket — if another daemon is listening, abort
        if SOCKET_PATH.exists():
            if await self._socket_is_alive():
                logger.error(
                    "Another daemon is already listening on %s — aborting",
                    SOCKET_PATH,
                )
                sys.exit(1)
            # Stale socket (no listener) — safe to remove
            SOCKET_PATH.unlink(missing_ok=True)

        # Write PID file
        PID_FILE.parent.mkdir(parents=True, exist_ok=True)
        PID_FILE.write_text(str(os.getpid()))

        # Q6: Set umask before socket creation to prevent TOCTOU race
        old_umask = os.umask(0o177)
        try:
            self.server = await asyncio.start_unix_server(
                self.handle_client,
                path=str(SOCKET_PATH),
                limit=1_048_576,  # 1MB buffer limit to prevent unbounded memory on long lines
            )
        finally:
            os.umask(old_umask)
        # chmod is now redundant but kept for defense-in-depth
        os.chmod(SOCKET_PATH, 0o600)

        logger.info("Daemon started on %s", SOCKET_PATH)

        # Schedule periodic socket health check (detects orphan socket)
        self._socket_check_task = asyncio.create_task(self._periodic_socket_check())

        # H1: Preload embedding model at startup to avoid first-request latency
        try:
            from claude_engram.storage import _get_embedder

            await with_timeout(
                asyncio.to_thread(_get_embedder),
                timeout=TIMEOUT_EMBEDDING,
                label="embedding_preload",
            )
            logger.info("Embedding model loaded")
        except TimeoutError:
            logger.warning("Embedding model preload timed out (will load on first use)")
        except Exception:
            logger.warning("Failed to preload embedding model (will load on first use)")

        # Phase E2: Check if consolidation is overdue at startup
        await self._startup_consolidation_check()

        # Schedule periodic cleanup of expired memories
        self._cleanup_task = asyncio.create_task(self._periodic_cleanup())

        # Q4: Use get_running_loop() instead of deprecated get_event_loop()
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGTERM, signal.SIGINT):
            loop.add_signal_handler(sig, self._shutdown.set)

        # Wait for shutdown signal, then run cleanup
        await self._shutdown.wait()
        await self.stop()

    async def _auto_consolidate(self) -> None:
        """Run consolidation in the background after store threshold is reached.

        Non-blocking: called via asyncio.create_task() so the store response
        is sent before consolidation starts.
        """
        try:
            from .consolidation import consolidate_lessons

            report = await with_timeout(
                asyncio.to_thread(
                    consolidate_lessons,
                    storage=self.storage,
                    min_cluster_size=3,
                    min_samples=2,
                    dry_run=False,
                ),
                timeout=60.0,
                label="auto_consolidate",
            )
            logger.info(
                "Engram: auto-consolidated %d clusters, %d superseded",
                report.clusters_found,
                report.memories_consolidated,
            )
            # Update last_consolidated timestamp in DB
            self._update_consolidation_timestamp()
        except ImportError:
            logger.debug("Auto-consolidation skipped: scikit-learn not installed")
        except TimeoutError:
            logger.warning("Auto-consolidation timed out")
        except Exception:
            logger.exception("Auto-consolidation error")

    def _update_consolidation_timestamp(self) -> None:
        """Write last_consolidated timestamp to consolidation_meta table."""
        conn = self.storage._get_conn()  # noqa: SLF001
        now = datetime.now(tz=UTC).isoformat()
        conn.execute(
            "INSERT OR REPLACE INTO consolidation_meta (key, value) VALUES (?, ?)",
            ("last_consolidated", now),
        )
        conn.commit()

    def _consolidation_overdue(self) -> bool:
        """Check if consolidation hasn't run in over 24 hours."""
        conn = self.storage._get_conn()  # noqa: SLF001
        # Check if consolidation_meta table exists
        has_table = conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table' AND name='consolidation_meta'"
        ).fetchone()
        if not has_table:
            return True

        row = conn.execute(
            "SELECT value FROM consolidation_meta WHERE key = 'last_consolidated'"
        ).fetchone()
        if not row:
            return True

        try:
            last = datetime.fromisoformat(row["value"])
            elapsed = datetime.now(tz=UTC) - last
            return elapsed > timedelta(hours=self._consolidation_cooldown_hours)
        except (ValueError, TypeError):
            return True

    async def _startup_consolidation_check(self) -> None:
        """Run consolidation at startup if overdue (>24h since last run)."""
        try:
            overdue = await asyncio.to_thread(self._consolidation_overdue)
            if overdue:
                logger.info("Consolidation overdue (>24h) — running at startup")
                await self._auto_consolidate()
            else:
                logger.info("Consolidation up to date — skipping startup run")
        except Exception:
            logger.exception("Startup consolidation check failed")

    async def _periodic_cleanup(self) -> None:
        """Periodically clean up expired memories and checkpoint WAL."""
        while not self._shutdown.is_set():
            try:
                await asyncio.sleep(self._cleanup_interval)
                try:
                    count = await with_timeout(
                        asyncio.to_thread(self.storage.cleanup_expired),
                        timeout=TIMEOUT_DB_WRITE,
                        label="cleanup_expired",
                    )
                    if count > 0:
                        logger.info("Cleaned up %d expired memories", count)
                except TimeoutError:
                    logger.warning("cleanup_expired timed out")
                # Checkpoint WAL to prevent unbounded growth (audit 2026-02-05)
                try:
                    await with_timeout(
                        asyncio.to_thread(self.storage.checkpoint_wal),
                        timeout=TIMEOUT_DB_WRITE,
                        label="checkpoint_wal",
                    )
                except TimeoutError:
                    logger.warning("checkpoint_wal timed out")
            except asyncio.CancelledError:
                break
            except Exception:
                logger.exception("Cleanup error")

    def _check_socket_health(self) -> bool:
        """Check if socket file exists on filesystem.

        Returns True if socket path exists, False if deleted (orphan state).
        An orphan socket occurs when the socket file is deleted while the daemon
        is running - clients cannot connect even though the daemon is healthy.
        """
        return SOCKET_PATH.exists()

    async def _periodic_socket_check(self) -> None:
        """Periodically verify socket file exists and recreate if deleted.

        This detects "orphan socket" scenarios where something (tmpwatch, manual
        cleanup, WSL quirks) deletes /tmp/claude-engram.sock while daemon runs.
        Without this check, the daemon would continue running but be unreachable.
        """
        while not self._shutdown.is_set():
            try:
                await asyncio.sleep(self._socket_check_interval)
                if not self._check_socket_health():
                    logger.warning(
                        "Socket file %s was deleted - attempting recreation", SOCKET_PATH
                    )
                    await self._recreate_socket()
            except asyncio.CancelledError:
                break
            except Exception:
                logger.exception("Socket health check error")

    async def _recreate_socket(self) -> None:
        """Recreate the Unix socket after detecting it was deleted.

        This handles the orphan socket recovery: close old server, create new
        socket at the same path. Existing connections are dropped but new
        connections become possible again.
        """
        if self.server:
            self.server.close()
            await self.server.wait_closed()

        # Recreate socket with same security settings as start()
        old_umask = os.umask(0o177)
        try:
            self.server = await asyncio.start_unix_server(
                self.handle_client,
                path=str(SOCKET_PATH),
                limit=1_048_576,
            )
        finally:
            os.umask(old_umask)
        os.chmod(SOCKET_PATH, 0o600)
        logger.info("Socket recreated at %s", SOCKET_PATH)

    async def stop(self) -> None:
        """Stop the daemon."""
        logger.info("Shutting down...")
        # Q3: Check initialized attribute instead of hasattr
        if self._cleanup_task is not None:
            self._cleanup_task.cancel()
        if self._socket_check_task is not None:
            self._socket_check_task.cancel()
        self._shutdown.set()

        if self.server:
            self.server.close()
            await self.server.wait_closed()

        self.storage.close()

        # Cleanup
        if SOCKET_PATH.exists():
            SOCKET_PATH.unlink()
        if PID_FILE.exists():
            PID_FILE.unlink()


def is_running() -> int | None:
    """Check if daemon is running, return PID if so."""
    if not PID_FILE.exists():
        return None

    try:
        pid = int(PID_FILE.read_text().strip())
        # Check if process exists
        os.kill(pid, 0)
        return pid
    except (ValueError, ProcessLookupError, PermissionError):
        # Stale PID file
        PID_FILE.unlink(missing_ok=True)
        return None


def main() -> None:
    """Entry point for daemon."""
    # Check if already running
    if pid := is_running():
        logger.error("Daemon already running (PID: %d)", pid)
        sys.exit(1)

    daemon = MemoryDaemon()
    with contextlib.suppress(KeyboardInterrupt):
        asyncio.run(daemon.start())


if __name__ == "__main__":
    main()
