"""Memory engine - orchestrates event store, state, and operations."""

import json
import subprocess
import warnings
from datetime import datetime, timezone
from pathlib import Path

from .events import EventStore
from .models import Entity, MemoryEvent, Observation, Relation
from .state import GraphState, materialize, materialize_at, apply_event
from .timeutil import parse_time_reference

# --- Constants ---
# Default limits for queries
DEFAULT_QUERY_LIMIT = 10
DEFAULT_RECENT_LIMIT = 5
DEFAULT_SUGGESTION_LIMIT = 5
DEFAULT_VECTOR_SEARCH_LIMIT = 20

# Token budgets for tiered retrieval
SHALLOW_CONTEXT_TOKENS = 500
MEDIUM_CONTEXT_TOKENS = 2000
DEEP_CONTEXT_TOKENS = 5000

# Similarity and weight thresholds
DEFAULT_SIMILARITY_THRESHOLD = 0.7
DUPLICATE_DETECTION_THRESHOLD = 0.8
DUPLICATE_AUTO_BLOCK_THRESHOLD = 0.8  # Threshold for auto-blocking on create
WEAK_RELATION_THRESHOLD = 0.2
PRUNING_CANDIDATE_THRESHOLD = 0.1
SUGGEST_RELATION_CONFIDENCE_THRESHOLD = 0.4

# Health check limits
OVERLOADED_ENTITY_OBSERVATION_LIMIT = 15
HEALTH_REPORT_ITEM_LIMIT = 10

# Suggestion confidence scores
CO_OCCURRENCE_CONFIDENCE = 0.7
SHARED_RELATION_CONFIDENCE = 0.65

# Similarity calculation weights
SUBSTRING_SIMILARITY_WEIGHT = 0.9
TOKEN_SIMILARITY_WEIGHT = 0.8
EMBEDDING_SIMILARITY_WEIGHT = 0.8
AFFIX_MATCH_BONUS = 0.2

# --- Bootstrap Guide Data ---
# Seeded into empty global memory to teach agents how to use mnemograph
MNEMOGRAPH_GUIDE_SEED = {
    "topic": {
        "name": "topic/mnemograph-guide",
        "type": "entity",
        "observations": [
            "Living guide for using mnemograph effectively. Agents can add learnings here.",
            "This guide was auto-seeded on first run. Explore with: recall('mnemograph', depth='medium')",
        ],
    },
    "entities": [
        {
            "name": "mnemograph/core-workflow",
            "type": "pattern",
            "observations": [
                "Start every session with session_start() to get orientation and quick_start guide.",
                "Use recall(depth='shallow') for overview, 'medium' for task context, 'deep' for exploration.",
                "For large results, recall returns structure-only — use open_nodes() to expand specific entities.",
                "Store new knowledge with remember(name, type, observations, relations) — one atomic call.",
                "Add facts to existing entities with add_observations(entity, [...]).",
                "Run get_graph_health() periodically to find orphans, duplicates, and maintenance tasks.",
            ],
        },
        {
            "name": "mnemograph/scope-decision",
            "type": "decision",
            "observations": [
                "ALWAYS ask user on first setup: 'Should memory be project-local or global?'",
                "Project-local (.claude/memory): repo-specific architecture, decisions, patterns.",
                "Global (~/.claude/memory): cross-project learnings, personal preferences, universal patterns.",
                "Configure via MEMORY_PATH env var. CLI uses --global flag for global memory.",
                "This decision affects whether knowledge transfers between projects.",
            ],
        },
        {
            "name": "mnemograph/entity-types",
            "type": "concept",
            "observations": [
                "concept: Ideas, patterns, approaches (e.g., 'Repository pattern', 'Event sourcing').",
                "decision: Choices WITH RATIONALE (e.g., 'Chose SQLite for simplicity because...').",
                "project: Codebases, systems (e.g., 'auth-service', 'mnemograph').",
                "pattern: Recurring code patterns (e.g., 'Error handling with Result type').",
                "question: Open unknowns to revisit (e.g., 'Should we add real-time sync?').",
                "learning: Discoveries made together (e.g., 'pytest fixtures simplify test setup').",
                "entity: Generic catch-all (people, files, etc.).",
            ],
        },
        {
            "name": "mnemograph/topic-convention",
            "type": "pattern",
            "observations": [
                "Use topic/ prefix to create grouping entry points: topic/auth, topic/testing, topic/api-design.",
                "Link entities to topics with 'part_of' or 'relates_to' relations.",
                "This makes it easy to query 'what do we know about auth?' by exploring topic/auth.",
                "Standard topics: topic/projects, topic/decisions, topic/patterns, topic/learnings, topic/questions.",
            ],
        },
        {
            "name": "mnemograph/duplicate-prevention",
            "type": "pattern",
            "observations": [
                "Before creating entities, consider: does this already exist?",
                "find_similar(name) checks for duplicates — >80% similarity auto-blocks creation.",
                "If blocked, use add_observations() to extend the existing entity instead.",
                "merge_entities(source, target) consolidates duplicates — moves observations and redirects relations.",
                "Duplicates fragment knowledge and make recall harder. Prevention is better than cleanup.",
            ],
        },
        {
            "name": "mnemograph/retrieval-tiers",
            "type": "concept",
            "observations": [
                "shallow (~500 tokens): Summary stats, recent entities, hot topics. Use for orientation.",
                "medium (~2000 tokens): Semantic search + 1-hop neighbors. Use for task-specific context.",
                "deep (~5000 tokens): Multi-hop weighted subgraph. Use for exploration and understanding.",
                "For large results, recall() returns structure-only — use open_nodes() to expand.",
                "Token budgets help manage context windows. Request only what you need.",
                "Weights prioritize: 40% recency, 30% co-access (learned), 30% explicit importance.",
            ],
        },
        {
            "name": "mnemograph/recovery-tools",
            "type": "concept",
            "observations": [
                "reload(): Sync MCP server with disk after git operations or external edits.",
                "rewind(steps=1): Quick undo via git — fast but audit trail only in git history.",
                "restore_state_at(timestamp): Event-based restore with full audit trail in events.",
                "get_state_at(timestamp): Preview past state before restoring.",
                "diff_timerange(start, end): See what changed between two points in time.",
                "Event sourcing means nothing is truly lost — you can always recover.",
            ],
        },
        {
            "name": "mnemograph/maintenance-rhythm",
            "type": "pattern",
            "observations": [
                "Run get_graph_health() periodically (weekly or when graph feels cluttered).",
                "Fix orphan entities: connect them, merge them, or delete if irrelevant.",
                "Merge duplicates promptly — they fragment knowledge and confuse recall.",
                "Use get_weak_relations() to find pruning candidates (low-weight noise).",
                "Visualize with 'mg graph' to see structure and spot issues.",
            ],
        },
        {
            "name": "mnemograph/visualization",
            "type": "concept",
            "observations": [
                "Run 'mg graph' to open interactive D3.js viewer in browser.",
                "Use 'mg graph --watch' for live refresh mode with Refresh button.",
                "Layouts: force-directed (default), radial (hubs at center), clustered (by component).",
                "Color modes: by entity type, connected component, or degree centrality.",
                "Edge weight slider filters connections by strength — useful for large graphs.",
                "Visualization helps understand graph structure and spot orphans/clusters.",
            ],
        },
        # --- New patterns for knowledge quality ---
        {
            "name": "mnemograph/what-to-store",
            "type": "pattern",
            "observations": [
                "STORE: Decisions with rationale — 'Chose X because Y' is invaluable across sessions.",
                "STORE: Patterns discovered — recurring approaches that work well in this codebase.",
                "STORE: Open questions — unknowns to revisit, things to investigate later.",
                "STORE: Project structure insights — where things live, how components connect.",
                "SKIP: Ephemeral task details — 'fixed bug in line 42' is noise.",
                "SKIP: Obvious facts — things easily discoverable from code itself.",
                "SKIP: Temporary state — 'currently working on X' belongs in todo, not memory.",
                "Ask: 'Would future-me benefit from knowing this?' If yes, store it.",
            ],
        },
        {
            "name": "mnemograph/knowledge-structure",
            "type": "pattern",
            "observations": [
                "Entity names should be specific and searchable: 'auth/jwt-validation' not 'JWT stuff'.",
                "Use consistent naming: 'auth/X', 'api/X', 'pattern/X' for discoverability.",
                "Observations should be atomic facts, not paragraphs. One idea per observation.",
                "Relations should use active verbs: 'implements', 'uses', 'decided_for', 'contradicts'.",
                "Link to topics for browsability: every entity should connect to at least one topic.",
                "Decisions need rationale: 'Chose SQLite' is useless without 'because simplicity > scale'.",
            ],
        },
        {
            "name": "mnemograph/anti-patterns",
            "type": "pattern",
            "observations": [
                "AVOID: Creating entities for every file or function — that's what code search is for.",
                "AVOID: Storing conversation summaries — memory is for durable knowledge, not chat logs.",
                "AVOID: Vague entity names like 'stuff', 'misc', 'notes' — be specific.",
                "AVOID: Orphan entities — if it's not connected, it won't be found.",
                "AVOID: Duplicate creation without checking — always consider find_similar() first.",
                "AVOID: Over-observation — 15+ observations on one entity signals need to split.",
            ],
        },
        {
            "name": "mnemograph/good-relation-types",
            "type": "concept",
            "observations": [
                "implements: 'auth-service implements oauth2-pattern'",
                "uses: 'api-gateway uses rate-limiting'",
                "decided_for: 'sqlite-decision decided_for simplicity'",
                "part_of: 'jwt-validation part_of auth-service'",
                "relates_to: loose association when nothing else fits",
                "contradicts: 'microservices contradicts monolith' — capture tensions",
                "supersedes: 'new-approach supersedes old-approach' — track evolution",
                "depends_on: 'feature-x depends_on feature-y'",
            ],
        },
        {
            "name": "mnemograph/human-curation",
            "type": "concept",
            "observations": [
                "Humans can review agent changes: 'mg log --since yesterday'",
                "Humans can visualize structure: 'mg graph --watch'",
                "Humans can check health: 'mg health --fix' for interactive cleanup",
                "Humans can undo mistakes: 'mg rewind' or 'mg restore --to <timestamp>'",
                "Memory is a collaboration — agents propose, humans can curate.",
                "Periodic human review keeps memory focused and accurate.",
            ],
        },
    ],
    "relations": [
        # All entities part of the guide topic
        ("mnemograph/core-workflow", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/scope-decision", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/entity-types", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/topic-convention", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/duplicate-prevention", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/retrieval-tiers", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/recovery-tools", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/maintenance-rhythm", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/visualization", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/what-to-store", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/knowledge-structure", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/anti-patterns", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/good-relation-types", "topic/mnemograph-guide", "part_of"),
        ("mnemograph/human-curation", "topic/mnemograph-guide", "part_of"),
        # Cross-references within the guide
        ("mnemograph/core-workflow", "mnemograph/retrieval-tiers", "uses"),
        ("mnemograph/maintenance-rhythm", "mnemograph/duplicate-prevention", "reinforces"),
        ("mnemograph/maintenance-rhythm", "mnemograph/visualization", "uses"),
        ("mnemograph/what-to-store", "mnemograph/anti-patterns", "complements"),
        ("mnemograph/knowledge-structure", "mnemograph/good-relation-types", "uses"),
        ("mnemograph/knowledge-structure", "mnemograph/entity-types", "uses"),
        ("mnemograph/human-curation", "mnemograph/visualization", "uses"),
        ("mnemograph/human-curation", "mnemograph/recovery-tools", "uses"),
    ],
}


def _check_git_repo(memory_dir: Path) -> bool:
    """Check if memory directory is in a git repository.

    Returns True if in a git repo, False otherwise.
    Emits a warning if not in a git repo.
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--git-dir"],
            cwd=memory_dir,
            capture_output=True,
            text=True,
        )
        if result.returncode != 0:
            warnings.warn(
                f"Memory directory {memory_dir} is not in a git repository. "
                "Git-based features (rewind, compact safety) will not work. "
                "Consider initializing a git repo with 'git init'.",
                UserWarning,
                stacklevel=3,
            )
            return False
        return True
    except FileNotFoundError:
        warnings.warn(
            "git command not found. Git-based features (rewind, compact safety) "
            "will not work.",
            UserWarning,
            stacklevel=3,
        )
        return False


def _get_git_root(memory_dir: Path) -> Path | None:
    """Get the root directory of the git repository containing memory_dir.

    Returns None if not in a git repo.
    """
    try:
        result = subprocess.run(
            ["git", "rev-parse", "--show-toplevel"],
            cwd=memory_dir,
            capture_output=True,
            text=True,
        )
        if result.returncode == 0:
            return Path(result.stdout.strip())
        return None
    except FileNotFoundError:
        return None


class MemoryEngine:
    """Main entry point for memory operations.

    Thread-safety: MemoryEngine is designed for single-process use. Running
    multiple instances with the same MEMORY_PATH may cause data corruption,
    particularly in the co-access cache. For multi-process scenarios, use
    separate memory directories or implement external locking.

    Performance: Uses O(1) indices for entity lookups and incremental state
    updates. Entity operations are O(1) regardless of graph size. Relation
    operations are O(1) for creation, O(degree) for neighbor queries.
    """

    def __init__(self, memory_dir: Path, session_id: str):
        self.memory_dir = memory_dir
        self.session_id = session_id
        self.event_store = EventStore(memory_dir / "events.jsonl")
        self.state: GraphState = self._load_state()
        self._vector_index = None  # Lazy-loaded
        self._co_access_cache_path = memory_dir / "co_access_cache.json"
        self._load_co_access_cache()

        # Check git repo status (warns if not in git)
        self._in_git_repo = _check_git_repo(memory_dir)
        self._git_root = _get_git_root(memory_dir) if self._in_git_repo else None

    def _load_state(self) -> GraphState:
        """Load state from events."""
        events = self.event_store.read_all()
        return materialize(events)

    def _load_co_access_cache(self) -> None:
        """Load cached co-access scores into relations.

        Co-access scores are learned from usage patterns and cached
        separately from the event-sourced state.
        """
        if not self._co_access_cache_path.exists():
            return

        try:
            cache = json.loads(self._co_access_cache_path.read_text())
            for rel in self.state.relations:
                if rel.id in cache:
                    rel.co_access_score = cache[rel.id].get("co_access_score", 0.0)
                    rel.access_count = cache[rel.id].get("access_count", 0)
                    if "last_accessed" in cache[rel.id]:
                        rel.last_accessed = datetime.fromisoformat(cache[rel.id]["last_accessed"])
        except (json.JSONDecodeError, ValueError):
            # Corrupted cache, ignore
            pass

    def save_co_access_cache(self) -> None:
        """Save co-access scores to cache file.

        Call this periodically or on shutdown to persist learned scores.
        """
        cache = {}
        for rel in self.state.relations:
            if rel.co_access_score > 0 or rel.access_count > 0:
                cache[rel.id] = {
                    "co_access_score": rel.co_access_score,
                    "access_count": rel.access_count,
                    "last_accessed": rel.last_accessed.isoformat(),
                }

        self._co_access_cache_path.write_text(json.dumps(cache, indent=2))

    def _seed_guide(self) -> dict:
        """Seed the mnemograph usage guide into memory.

        Called on first run when memory is empty. Creates a living guide
        that teaches agents how to use mnemograph effectively.

        Returns:
            Summary of what was seeded.
        """
        guide = MNEMOGRAPH_GUIDE_SEED

        # Create topic entity
        topic_data = guide["topic"]
        topic_entity = Entity(
            name=topic_data["name"],
            type=topic_data["type"],
            observations=[
                Observation(text=obs, source=self.session_id)
                for obs in topic_data["observations"]
            ],
            created_by=self.session_id,
        )
        self._emit("create_entity", topic_entity.model_dump(mode="json"))

        # Create guide entities
        created_entities = [topic_data["name"]]
        for entity_data in guide["entities"]:
            entity = Entity(
                name=entity_data["name"],
                type=entity_data["type"],
                observations=[
                    Observation(text=obs, source=self.session_id)
                    for obs in entity_data["observations"]
                ],
                created_by=self.session_id,
            )
            self._emit("create_entity", entity.model_dump(mode="json"))
            created_entities.append(entity_data["name"])

        # Create relations
        created_relations = []
        for from_name, to_name, rel_type in guide["relations"]:
            from_id = self._resolve_entity(from_name)
            to_id = self._resolve_entity(to_name)
            if from_id and to_id:
                relation = Relation(
                    from_entity=from_id,
                    to_entity=to_id,
                    type=rel_type,
                    created_by=self.session_id,
                )
                self._emit("create_relation", relation.model_dump(mode="json"))
                created_relations.append(f"{from_name} --{rel_type}--> {to_name}")

        return {
            "seeded": True,
            "entities_created": len(created_entities),
            "relations_created": len(created_relations),
            "guide_topic": "topic/mnemograph-guide",
            "explore_with": "recall('mnemograph', depth='medium')",
        }

    def _emit(self, op: str, data: dict) -> MemoryEvent:
        """Emit an event and update local state incrementally.

        Uses apply_event() for O(1) updates instead of full replay.
        """
        event = MemoryEvent(
            op=op,  # type: ignore (we know op is valid EventOp)
            session_id=self.session_id,
            source="cc",
            data=data,
        )
        self.event_store.append(event)
        # Incremental update - O(1) instead of O(events)
        apply_event(self.state, event)
        return event

    # --- Entity operations ---

    def create_entities(
        self,
        entities: list[dict],
        force: bool = False,
    ) -> list[Entity | dict]:
        """Create multiple entities with auto-duplicate checking.

        Args:
            entities: List of entity dicts to create
            force: If True, bypass duplicate check (like create_entities_force)

        Returns:
            List of created Entity objects OR warning dicts for blocked entities.
            Warning dicts have status="duplicate_warning" and include similar entities.
        """
        results: list[Entity | dict] = []

        for entity_data in entities:
            name = entity_data["name"]

            # Auto-duplicate check (unless force=True)
            if not force:
                similar = self.find_similar(name, threshold=DUPLICATE_AUTO_BLOCK_THRESHOLD)
                if similar:
                    top = similar[0]
                    # Return warning instead of creating - entity NOT created
                    results.append({
                        "status": "duplicate_warning",
                        "name": name,
                        "warning": f"Similar entity exists: '{top['name']}' ({top['similarity']:.0%} match)",
                        "similar": similar,
                        "suggestion": f"Use add_observations('{top['name']}', ...) instead",
                        "override": "Use create_entities with force=True to create anyway",
                    })
                    continue

            # Build observations from input
            observations = [
                Observation(text=obs, source=self.session_id)
                for obs in entity_data.get("observations", [])
            ]

            entity = Entity(
                name=name,
                type=entity_data.get("entityType", entity_data.get("type", "entity")),
                observations=observations,
                created_by=self.session_id,
            )
            self._emit("create_entity", entity.model_dump(mode="json"))
            results.append(entity)

        return results

    def create_entities_force(self, entities: list[dict]) -> list[Entity]:
        """Create entities bypassing duplicate check.

        Use when you're certain the entity is distinct despite similar names.
        """
        results = self.create_entities(entities, force=True)
        # Filter to only Entity objects (no warnings when force=True)
        return [r for r in results if isinstance(r, Entity)]

    def delete_entities(self, names: list[str]) -> int:
        """Delete entities by name. Returns count of deleted."""
        deleted = 0
        for name in names:
            entity_id = self._resolve_entity(name)
            if entity_id:
                self._emit("delete_entity", {"id": entity_id})
                deleted += 1
        return deleted

    # --- Relation operations ---

    def create_relations(self, relations: list[dict]) -> dict:
        """Create multiple relations with detailed result.

        Returns dict with 'created' list, 'failed' list, and 'summary'.
        Failed items include reason for failure.
        """
        created = []
        failed = []

        for rel_data in relations:
            from_name = rel_data["from"]
            to_name = rel_data["to"]
            from_id = self._resolve_entity(from_name)
            to_id = self._resolve_entity(to_name)

            if not from_id:
                failed.append({
                    "from": from_name,
                    "to": to_name,
                    "relationType": rel_data.get("relationType", ""),
                    "reason": f"from_entity not found: {from_name}",
                })
                continue
            if not to_id:
                failed.append({
                    "from": from_name,
                    "to": to_name,
                    "relationType": rel_data.get("relationType", ""),
                    "reason": f"to_entity not found: {to_name}",
                })
                continue

            relation = Relation(
                from_entity=from_id,
                to_entity=to_id,
                type=rel_data["relationType"],
                created_by=self.session_id,
            )
            self._emit("create_relation", relation.model_dump(mode="json"))
            created.append(relation)

        return {
            "created": [r.model_dump(mode="json") for r in created],
            "failed": failed,
            "summary": f"Created {len(created)}, failed {len(failed)}",
        }

    def delete_relations(self, relations: list[dict]) -> int:
        """Delete relations. Returns count of deleted."""
        deleted = 0
        for rel_data in relations:
            from_id = self._resolve_entity(rel_data["from"])
            to_id = self._resolve_entity(rel_data["to"])
            if from_id and to_id:
                self._emit(
                    "delete_relation",
                    {
                        "from_entity": from_id,
                        "to_entity": to_id,
                        "type": rel_data["relationType"],
                    },
                )
                deleted += 1
        return deleted

    # --- Observation operations ---

    def add_observations(self, observations: list[dict]) -> list[dict]:
        """Add observations to existing entities."""
        results = []
        for obs_data in observations:
            entity_id = self._resolve_entity(obs_data["entityName"])
            if entity_id:
                added = []
                for content in obs_data.get("contents", []):
                    obs = Observation(text=content, source=self.session_id)
                    self._emit(
                        "add_observation",
                        {"entity_id": entity_id, "observation": obs.model_dump(mode="json")},
                    )
                    added.append(content)
                results.append({"entityName": obs_data["entityName"], "addedObservations": added})
        return results

    def delete_observations(self, deletions: list[dict]) -> int:
        """Delete specific observations from entities."""
        deleted = 0
        for deletion in deletions:
            entity_id = self._resolve_entity(deletion["entityName"])
            if entity_id and entity_id in self.state.entities:
                entity = self.state.entities[entity_id]
                for obs_text in deletion.get("observations", []):
                    # Find observation by text content
                    for obs in entity.observations:
                        if obs.text == obs_text:
                            self._emit(
                                "delete_observation",
                                {"entity_id": entity_id, "observation_id": obs.id},
                            )
                            deleted += 1
                            break
        return deleted

    # --- Query operations ---

    def read_graph(self) -> dict:
        """Return full graph state."""
        return {
            "entities": [e.model_dump(mode="json") for e in self.state.entities.values()],
            "relations": [r.model_dump(mode="json") for r in self.state.relations],
        }

    def get_structure(
        self,
        entity_ids: list[str] | None = None,
        include_neighbors: bool = True,
    ) -> dict:
        """Get graph structure (names, types, relations) without full observations.

        This is the lightweight alternative to search_graph/read_graph for initial
        exploration. Returns ~10x fewer tokens than full data.

        Args:
            entity_ids: Specific entity IDs to include (None = all)
            include_neighbors: If True, include 1-hop neighbors of specified entities

        Returns:
            Dict with 'entities' (summaries) and 'relations' (summaries)
        """
        if entity_ids is None:
            # All entities
            target_ids = set(self.state.entities.keys())
        else:
            target_ids = set(entity_ids)

            if include_neighbors:
                # Add 1-hop neighbors
                for eid in list(target_ids):
                    for rel in self.state.get_outgoing_relations(eid):
                        target_ids.add(rel.to_entity)
                    for rel in self.state.get_incoming_relations(eid):
                        target_ids.add(rel.from_entity)

        # Build lightweight entity summaries
        entities = []
        for eid in target_ids:
            if eid in self.state.entities:
                entity = self.state.entities[eid]
                entities.append({
                    "id": entity.id,
                    "name": entity.name,
                    "type": entity.type,
                    "observation_count": len(entity.observations),
                })

        # Build relation summaries
        relations = []
        for rel in self.state.relations:
            if rel.from_entity in target_ids or rel.to_entity in target_ids:
                from_entity = self.state.entities.get(rel.from_entity)
                to_entity = self.state.entities.get(rel.to_entity)
                relations.append({
                    "from": from_entity.name if from_entity else rel.from_entity,
                    "to": to_entity.name if to_entity else rel.to_entity,
                    "type": rel.type,
                    "weight": round(rel.weight, 2),
                })

        return {
            "entity_count": len(entities),
            "relation_count": len(relations),
            "entities": entities,
            "relations": relations,
        }

    def search_structure(self, query: str, limit: int = 20) -> dict:
        """Search for entities and return structure-only results.

        Combines text and semantic search, returns lightweight summaries.
        Use open_nodes() to get full data for specific entities.

        Args:
            query: Search query
            limit: Max entities to return

        Returns:
            Structure with matched entities, their relations, and token estimate
        """
        query_lower = query.lower()
        matched_ids: list[str] = []
        scores: dict[str, float] = {}

        # 1. Text search (exact matches score higher)
        for entity in self.state.entities.values():
            score = 0.0

            # Name match (highest)
            if query_lower in entity.name.lower():
                score = 1.0 if query_lower == entity.name.lower() else 0.9
            # Type match
            elif query_lower in entity.type.lower():
                score = 0.5
            # Observation match
            else:
                for obs in entity.observations:
                    if query_lower in obs.text.lower():
                        score = 0.6
                        break

            if score > 0:
                matched_ids.append(entity.id)
                scores[entity.id] = score

        # 2. Semantic search (adds more candidates)
        if self._vector_index is not None:
            try:
                vector_results = self.vector_index.search(query, limit=limit)
                for eid, vscore in vector_results:
                    if eid in self.state.entities:
                        if eid not in scores:
                            matched_ids.append(eid)
                            scores[eid] = vscore * 0.8  # Weight semantic slightly lower
                        else:
                            # Boost score if found by both methods
                            scores[eid] = min(1.0, scores[eid] + vscore * 0.2)
            except Exception:
                pass

        # Sort by score and limit
        matched_ids.sort(key=lambda x: scores.get(x, 0), reverse=True)
        matched_ids = matched_ids[:limit]

        # Get structure for matched entities
        structure = self.get_structure(matched_ids, include_neighbors=True)

        # Estimate tokens for full data
        total_obs = sum(
            len(self.state.entities[eid].observations)
            for eid in matched_ids
            if eid in self.state.entities
        )
        estimated_full_tokens = total_obs * 50  # ~50 tokens per observation avg

        return {
            "query": query,
            "matched_count": len(matched_ids),
            "structure": structure,
            "estimated_full_tokens": estimated_full_tokens,
            "hint": f"Use open_nodes([...]) to get full data for specific entities. "
                   f"Full data for all matches would be ~{estimated_full_tokens} tokens.",
        }

    def search_graph(self, query: str) -> dict:
        """Search entities by text across names, types, observations."""
        query_lower = query.lower()
        matching_entities = []
        matching_entity_ids = set()

        for entity in self.state.entities.values():
            matched = False
            if query_lower in entity.name.lower():
                matched = True
            elif query_lower in entity.type.lower():
                matched = True
            else:
                for obs in entity.observations:
                    if query_lower in obs.text.lower():
                        matched = True
                        break
            if matched:
                matching_entities.append(entity)
                matching_entity_ids.add(entity.id)

        # Track access for matched entities
        self._track_access(list(matching_entity_ids))

        # Include relations between matching entities
        matching_relations = [
            r for r in self.state.relations
            if r.from_entity in matching_entity_ids or r.to_entity in matching_entity_ids
        ]

        return {
            "entities": [e.model_dump(mode="json") for e in matching_entities],
            "relations": [r.model_dump(mode="json") for r in matching_relations],
        }

    def open_nodes(self, names: list[str]) -> dict:
        """Get specific entities by name, their relations, and neighbors."""
        entities = []
        entity_ids = set()

        for name in names:
            entity_id = self._resolve_entity(name)
            if entity_id and entity_id in self.state.entities:
                entities.append(self.state.entities[entity_id])
                entity_ids.add(entity_id)

        # Track access
        self._track_access(list(entity_ids))

        # Include relations involving these entities
        relations = [
            r for r in self.state.relations
            if r.from_entity in entity_ids or r.to_entity in entity_ids
        ]

        # Find neighbor IDs (entities connected via relations)
        neighbor_ids = set()
        for r in relations:
            if r.from_entity not in entity_ids:
                neighbor_ids.add(r.from_entity)
            if r.to_entity not in entity_ids:
                neighbor_ids.add(r.to_entity)

        neighbors = [
            self.state.entities[nid] for nid in neighbor_ids
            if nid in self.state.entities
        ]

        return {
            "entities": [e.model_dump(mode="json") for e in entities],
            "relations": [r.model_dump(mode="json") for r in relations],
            "neighbors": [n.model_dump(mode="json") for n in neighbors],
        }

    # --- Helpers ---

    def _resolve_entity(self, name_or_id: str) -> str | None:
        """Find entity by name or ID. O(1) lookup using indices."""
        # Check if it's an ID
        if name_or_id in self.state.entities:
            return name_or_id
        # Check name index
        return self.state.get_entity_id_by_name(name_or_id)

    def _track_access(self, entity_ids: list[str]) -> None:
        """Update access counts for retrieved entities (in-memory only, not persisted)."""
        now = datetime.now(timezone.utc)
        for entity_id in entity_ids:
            if entity_id in self.state.entities:
                entity = self.state.entities[entity_id]
                entity.access_count += 1
                entity.last_accessed = now

    # --- Phase 2: Richer queries ---

    def get_recent_entities(self, limit: int = DEFAULT_QUERY_LIMIT) -> list[Entity]:
        """Get most recently updated entities."""
        return sorted(
            self.state.entities.values(),
            key=lambda e: e.updated_at,
            reverse=True,
        )[:limit]

    def get_hot_entities(self, limit: int = DEFAULT_QUERY_LIMIT) -> list[Entity]:
        """Get most frequently accessed entities."""
        return sorted(
            self.state.entities.values(),
            key=lambda e: e.access_count,
            reverse=True,
        )[:limit]

    def get_entities_by_type(self, entity_type: str) -> list[Entity]:
        """Filter entities by type."""
        return [e for e in self.state.entities.values() if e.type == entity_type]

    def get_entity_neighbors(self, entity_id: str, include_entity: bool = True) -> dict:
        """Get entity and its directly connected neighbors via relations."""
        entity = self.state.entities.get(entity_id)
        if not entity:
            return {"entity": None, "neighbors": [], "outgoing": [], "incoming": []}

        outgoing = [r for r in self.state.relations if r.from_entity == entity_id]
        incoming = [r for r in self.state.relations if r.to_entity == entity_id]

        neighbor_ids = set(
            [r.to_entity for r in outgoing] + [r.from_entity for r in incoming]
        )
        neighbors = [self.state.entities[nid] for nid in neighbor_ids if nid in self.state.entities]

        # Track access
        accessed = [entity_id] + list(neighbor_ids)
        self._track_access(accessed)

        return {
            "entity": entity.model_dump(mode="json") if include_entity else None,
            "neighbors": [n.model_dump(mode="json") for n in neighbors],
            "outgoing": [r.model_dump(mode="json") for r in outgoing],
            "incoming": [r.model_dump(mode="json") for r in incoming],
        }

    # --- Phase 3: Vector search ---

    @property
    def vector_index(self):
        """Lazy-load vector index."""
        if self._vector_index is None:
            from .vectors import VectorIndex
            self._vector_index = VectorIndex(self.memory_dir / "vectors.db")
            # Index all existing entities
            self._vector_index.reindex_all(list(self.state.entities.values()))
        return self._vector_index

    def search_semantic(
        self,
        query: str,
        limit: int = DEFAULT_QUERY_LIMIT,
        type_filter: str | None = None
    ) -> dict:
        """Semantic search using embeddings."""
        results = self.vector_index.search(query, limit, type_filter)

        entities = []
        entity_ids = []
        for entity_id, score in results:
            if entity_id in self.state.entities:
                entity = self.state.entities[entity_id]
                entity_dict = entity.model_dump(mode="json")
                entity_dict["_score"] = score  # Attach similarity score
                entities.append(entity_dict)
                entity_ids.append(entity_id)

        # Track access
        self._track_access(entity_ids)

        # Include relations between matched entities
        entity_id_set = set(entity_ids)
        relations = [
            r.model_dump(mode="json") for r in self.state.relations
            if r.from_entity in entity_id_set or r.to_entity in entity_id_set
        ]

        return {
            "entities": entities,
            "relations": relations,
        }

    def ensure_indexed(self, entity_id: str) -> None:
        """Ensure a specific entity is indexed (call after mutations)."""
        if self._vector_index is not None and entity_id in self.state.entities:
            self._vector_index.index_entity(self.state.entities[entity_id])

    # --- Phase 4: Tiered retrieval ---

    def recall(
        self,
        depth: str = "shallow",
        query: str | None = None,
        focus: list[str] | None = None,
        max_tokens: int | None = None,
    ) -> dict:
        """Get memory context at varying depth levels.

        This is the PRIMARY retrieval tool. Returns structure-first for large results,
        with hints to use open_nodes() for full data on specific entities.

        Args:
            depth: 'shallow' (summary), 'medium' (search + neighbors), 'deep' (multi-hop)
            query: Search query for medium/deep depth (uses semantic search)
            focus: Entity names to focus on
            max_tokens: Token budget (defaults vary by depth)

        Returns:
            Dict with depth, tokens_estimate, content, entity_count, relation_count
            For medium/deep: may include 'structure_only' flag if results too large
        """
        from .retrieval import get_shallow_context, get_medium_context, get_deep_context

        if depth == "shallow":
            tokens = max_tokens or SHALLOW_CONTEXT_TOKENS
            result = get_shallow_context(self.state, tokens)
            return {
                "depth": result.depth,
                "tokens_estimate": result.tokens_estimate,
                "content": result.content,
                "entity_count": result.entity_count,
                "relation_count": result.relation_count,
            }

        elif depth == "medium":
            tokens = max_tokens or MEDIUM_CONTEXT_TOKENS

            # First, do a structure search to estimate size
            if query:
                structure_result = self.search_structure(query, limit=20)
                estimated_tokens = structure_result["estimated_full_tokens"]

                # If results would be too large, return structure-only
                if estimated_tokens > tokens:
                    self.save_co_access_cache()
                    return {
                        "depth": "medium",
                        "structure_only": True,
                        "reason": f"Full results would be ~{estimated_tokens} tokens (budget: {tokens})",
                        "matched_count": structure_result["matched_count"],
                        "content": self._format_structure(structure_result["structure"]),
                        "tokens_estimate": structure_result["structure"]["entity_count"] * 15,
                        "entity_count": structure_result["structure"]["entity_count"],
                        "relation_count": structure_result["structure"]["relation_count"],
                        "hint": "Use open_nodes(['entity1', 'entity2']) to get full data for specific entities",
                    }

            # Normal path: get vector search results
            vector_results = None
            if query:
                vector_results = self.vector_index.search(query, limit=DEFAULT_QUERY_LIMIT)
            result = get_medium_context(self.state, vector_results, focus, tokens)
            self.save_co_access_cache()

        elif depth == "deep":
            tokens = max_tokens or DEEP_CONTEXT_TOKENS

            # For deep, estimate first based on focus entities
            if focus:
                focus_ids = [self._resolve_entity(f) for f in focus]
                focus_ids = [fid for fid in focus_ids if fid]
                structure = self.get_structure(focus_ids, include_neighbors=True)
                total_obs = sum(
                    len(self.state.entities[eid].observations)
                    for eid in focus_ids
                    if eid in self.state.entities
                )
                estimated_tokens = total_obs * 50

                if estimated_tokens > tokens:
                    self.save_co_access_cache()
                    return {
                        "depth": "deep",
                        "structure_only": True,
                        "reason": f"Full results would be ~{estimated_tokens} tokens (budget: {tokens})",
                        "content": self._format_structure(structure),
                        "tokens_estimate": structure["entity_count"] * 15,
                        "entity_count": structure["entity_count"],
                        "relation_count": structure["relation_count"],
                        "hint": "Use open_nodes(['entity1', 'entity2']) to get full data for specific entities",
                    }

            result = get_deep_context(self.state, focus, tokens)
            self.save_co_access_cache()

        else:
            raise ValueError(f"Invalid depth: {depth}. Use 'shallow', 'medium', or 'deep'.")

        return {
            "depth": result.depth,
            "tokens_estimate": result.tokens_estimate,
            "content": result.content,
            "entity_count": result.entity_count,
            "relation_count": result.relation_count,
        }

    def _format_structure(self, structure: dict) -> str:
        """Format structure-only results as readable markdown."""
        lines = [
            f"## Graph Structure ({structure['entity_count']} entities, {structure['relation_count']} relations)",
            "",
            "### Entities",
        ]

        # Group entities by type
        by_type: dict[str, list[dict]] = {}
        for e in structure["entities"]:
            etype = e["type"]
            if etype not in by_type:
                by_type[etype] = []
            by_type[etype].append(e)

        for etype, entities in sorted(by_type.items()):
            lines.append(f"\n**{etype}** ({len(entities)})")
            for e in entities[:10]:  # Limit per type
                obs_hint = f" [{e['observation_count']} obs]" if e["observation_count"] > 0 else ""
                lines.append(f"- {e['name']}{obs_hint}")
            if len(entities) > 10:
                lines.append(f"  ... and {len(entities) - 10} more")

        # Relations summary
        if structure["relations"]:
            lines.append("\n### Key Relations")
            # Show strongest relations
            sorted_rels = sorted(structure["relations"], key=lambda r: r["weight"], reverse=True)
            for rel in sorted_rels[:15]:
                lines.append(f"- {rel['from']} --{rel['type']}--> {rel['to']}")
            if len(sorted_rels) > 15:
                lines.append(f"  ... and {len(sorted_rels) - 15} more relations")

        return "\n".join(lines)

    # --- Event Rewind ---

    def state_at(self, timestamp: str | datetime) -> GraphState:
        """Get graph state at a specific point in time.

        Args:
            timestamp: ISO datetime string, relative reference ("7 days ago"),
                       or datetime object (will be treated as UTC if naive)

        Returns:
            GraphState as it existed at that timestamp
        """
        if isinstance(timestamp, str):
            ts = parse_time_reference(timestamp)
        else:
            ts = timestamp
            # Ensure timezone-aware (assume UTC if naive)
            if ts.tzinfo is None:
                ts = ts.replace(tzinfo=timezone.utc)

        events = self.event_store.read_all()
        return materialize_at(events, ts)

    def events_between(
        self,
        start: str | datetime,
        end: str | datetime | None = None,
    ) -> list[MemoryEvent]:
        """Get events in a time range.

        Args:
            start: Start time (ISO string, relative reference, or datetime)
            end: End time (default: now)

        Returns:
            List of events in the range
        """
        if isinstance(start, str):
            start_ts = parse_time_reference(start)
        else:
            start_ts = start

        if end is None:
            end_ts = datetime.now(timezone.utc)
        elif isinstance(end, str):
            end_ts = parse_time_reference(end)
        else:
            end_ts = end

        events = self.event_store.read_all()
        return [e for e in events if start_ts <= e.ts <= end_ts]

    def diff_between(
        self,
        start: str | datetime,
        end: str | datetime | None = None,
    ) -> dict:
        """Compute what changed between two points in time.

        Args:
            start: Start time
            end: End time (default: now)

        Returns:
            Dict with entities (added/removed/modified) and relations (added/removed)
        """
        if isinstance(start, str):
            start_ts = parse_time_reference(start)
        else:
            start_ts = start

        if end is None:
            end_ts = datetime.now(timezone.utc)
        elif isinstance(end, str):
            end_ts = parse_time_reference(end)
        else:
            end_ts = end

        events = self.event_store.read_all()
        state_start = materialize_at(events, start_ts)
        state_end = materialize_at(events, end_ts)

        return {
            "start_timestamp": start_ts.isoformat(),
            "end_timestamp": end_ts.isoformat(),
            "entities": self._diff_entities(state_start, state_end),
            "relations": self._diff_relations(state_start, state_end),
            "event_count": len([e for e in events if start_ts < e.ts <= end_ts]),
        }

    def _diff_entities(self, old: GraphState, new: GraphState) -> dict:
        """Compute entity differences between two states."""
        old_ids = set(old.entities.keys())
        new_ids = set(new.entities.keys())

        added_ids = new_ids - old_ids
        removed_ids = old_ids - new_ids
        common_ids = old_ids & new_ids

        # Check for modifications in common entities
        modified = []
        for eid in common_ids:
            old_entity = old.entities[eid]
            new_entity = new.entities[eid]

            changes = self._entity_changes(old_entity, new_entity)
            if changes:
                modified.append({
                    "id": eid,
                    "name": new_entity.name,
                    "changes": changes,
                })

        return {
            "added": [new.entities[eid].to_summary() for eid in added_ids],
            "removed": [old.entities[eid].to_summary() for eid in removed_ids],
            "modified": modified,
        }

    def _entity_changes(self, old: Entity, new: Entity) -> dict:
        """Compute specific changes between two entity versions."""
        changes = {}

        if old.name != new.name:
            changes["name"] = {"old": old.name, "new": new.name}
        if old.type != new.type:
            changes["type"] = {"old": old.type, "new": new.type}

        # Check observations
        old_obs_ids = {o.id for o in old.observations}
        new_obs_ids = {o.id for o in new.observations}

        added_obs = new_obs_ids - old_obs_ids
        removed_obs = old_obs_ids - new_obs_ids

        if added_obs or removed_obs:
            changes["observations"] = {
                "added": len(added_obs),
                "removed": len(removed_obs),
            }

        return changes

    def _diff_relations(self, old: GraphState, new: GraphState) -> dict:
        """Compute relation differences between two states."""
        old_ids = {r.id for r in old.relations}
        new_ids = {r.id for r in new.relations}

        added_ids = new_ids - old_ids
        removed_ids = old_ids - new_ids

        # Build lookup maps
        new_by_id = {r.id: r for r in new.relations}
        old_by_id = {r.id: r for r in old.relations}

        return {
            "added": [new_by_id[rid].to_summary() for rid in added_ids],
            "removed": [old_by_id[rid].to_summary() for rid in removed_ids],
        }

    def get_entity_history(self, entity_name: str) -> list[dict]:
        """Get the history of changes to an entity.

        Returns list of events that affected this entity, oldest first.
        """
        entity_id = self._resolve_entity(entity_name)
        if not entity_id:
            return []

        events = self.event_store.read_all()

        relevant = [
            e for e in events
            if e.data.get("id") == entity_id
            or e.data.get("entity_id") == entity_id
        ]

        return [
            {
                "timestamp": e.ts.isoformat(),
                "operation": e.op,
                "session_id": e.session_id,
                "data": e.data,
            }
            for e in relevant
        ]

    # --- Edge Weights ---

    def get_relation_weight(self, relation_id: str) -> dict | None:
        """Get weight breakdown for a relation.

        Args:
            relation_id: ID of the relation (full ID or prefix)

        Returns:
            Weight breakdown dict, or None if relation not found
        """
        # Support prefix matching
        relation = None
        for r in self.state.relations:
            if r.id == relation_id or r.id.startswith(relation_id):
                relation = r
                break

        if not relation:
            return None

        # Get entity names for context
        from_entity = self.state.entities.get(relation.from_entity)
        to_entity = self.state.entities.get(relation.to_entity)

        return {
            "relation_id": relation.id,
            "from_entity": from_entity.name if from_entity else relation.from_entity,
            "to_entity": to_entity.name if to_entity else relation.to_entity,
            "relation_type": relation.type,
            "combined_weight": relation.weight,
            "components": {
                "recency": relation.recency_score,
                "co_access": relation.co_access_score,
                "explicit": relation.explicit_weight,
            },
            "metadata": {
                "access_count": relation.access_count,
                "last_accessed": relation.last_accessed.isoformat(),
                "created_at": relation.created_at.isoformat(),
            },
        }

    def set_relation_importance(self, relation_id: str, importance: float) -> dict | None:
        """Set explicit weight for a relation.

        Args:
            relation_id: ID of the relation (full ID or prefix)
            importance: Value from 0.0 (unimportant) to 1.0 (critical)

        Returns:
            Updated weight breakdown, or None if relation not found

        Raises:
            ValueError: If importance is not between 0.0 and 1.0
        """
        if not 0.0 <= importance <= 1.0:
            raise ValueError("Importance must be between 0.0 and 1.0")

        # Find the relation
        relation = None
        for r in self.state.relations:
            if r.id == relation_id or r.id.startswith(relation_id):
                relation = r
                break

        if not relation:
            return None

        old_weight = relation.explicit_weight

        # Emit event for replayability
        self._emit("update_weight", {
            "relation_id": relation.id,
            "weight_type": "explicit",
            "old_value": old_weight,
            "new_value": importance,
        })

        return self.get_relation_weight(relation.id)

    def get_strongest_connections(
        self,
        entity_name: str,
        limit: int = DEFAULT_QUERY_LIMIT,
    ) -> list[dict]:
        """Get an entity's strongest connections by weight.

        Args:
            entity_name: Name or ID of the entity
            limit: Maximum number of connections to return

        Returns:
            List of connection info dicts sorted by weight (strongest first)
        """
        from .weights import get_strongest_connections as _get_strongest

        entity_id = self._resolve_entity(entity_name)
        if not entity_id:
            return []

        connections = _get_strongest(self.state, entity_id, limit)

        result = []
        for relation, neighbor_id in connections:
            neighbor = self.state.entities.get(neighbor_id)
            result.append({
                "relation_id": relation.id,
                "connected_to": neighbor.name if neighbor else neighbor_id,
                "relation_type": relation.type,
                "weight": relation.weight,
                "components": {
                    "recency": relation.recency_score,
                    "co_access": relation.co_access_score,
                    "explicit": relation.explicit_weight,
                },
            })

        return result

    def get_weak_relations(
        self,
        max_weight: float = PRUNING_CANDIDATE_THRESHOLD,
        limit: int = DEFAULT_VECTOR_SEARCH_LIMIT,
    ) -> list[dict]:
        """Get relations below a weight threshold (candidates for pruning).

        Args:
            max_weight: Only include relations with weight <= this value
            limit: Maximum number to return

        Returns:
            List of weak relations sorted by weight ascending
        """
        weak = [r for r in self.state.relations if r.weight <= max_weight]
        weak.sort(key=lambda r: r.weight)

        result = []
        for r in weak[:limit]:
            from_entity = self.state.entities.get(r.from_entity)
            to_entity = self.state.entities.get(r.to_entity)
            result.append({
                "relation_id": r.id,
                "from_entity": from_entity.name if from_entity else r.from_entity,
                "to_entity": to_entity.name if to_entity else r.to_entity,
                "relation_type": r.type,
                "weight": r.weight,
            })

        return result

    # --- Universal Agent Tools ---

    def get_primer(self) -> dict:
        """Get oriented with this knowledge graph.

        Returns summary of what's available and how to use the tools.
        Call at session start to understand the knowledge graph context.
        """
        # Get type counts
        type_counts = {}
        for entity in self.state.entities.values():
            type_counts[entity.type] = type_counts.get(entity.type, 0) + 1

        # Get recent entities
        recent = self.get_recent_entities(limit=DEFAULT_RECENT_LIMIT)
        recent_summary = [
            {"name": e.name, "type": e.type}
            for e in recent
        ]

        return {
            "status": {
                "entity_count": len(self.state.entities),
                "relation_count": len(self.state.relations),
                "types": type_counts,
            },
            "recent_activity": recent_summary,
            "tools": {
                "retrieval": [
                    "recall(depth, query) - Get relevant context (shallow/medium/deep)",
                    "search_graph(query) - Text search across entities",
                    "search_semantic(query) - Meaning-based search with embeddings",
                    "open_nodes(names) - Get specific entities with relations",
                ],
                "creation": [
                    "remember(name, type, observations, relations) - Store knowledge atomically",
                    "create_entities(entities) - Batch entity creation",
                    "add_observations(observations) - Add facts to existing entities",
                    "create_relations(relations) - Link entities together",
                ],
                "history": [
                    "get_state_at(timestamp) - View graph at any point in time",
                    "diff_timerange(start, end) - See what changed",
                    "get_entity_history(name) - Full changelog for an entity",
                ],
            },
            "quick_start": "Call recall(depth='shallow') for a summary, or remember() to store new knowledge.",
        }

    def session_start(self, project_hint: str | None = None) -> dict:
        """Signal session start and get initial context.

        Args:
            project_hint: Optional project name or path for context

        Returns:
            Initial context to prime the session with quick_start guide.
            If memory is empty, seeds the mnemograph usage guide and
            suggests visualization.
        """
        # Check if this is first run (empty memory)
        first_run = len(self.state.entities) == 0
        bootstrap_result = None

        if first_run:
            # Seed the mnemograph guide
            bootstrap_result = self._seed_guide()

        # Get shallow context for session priming
        context_result = self.recall(depth="shallow")

        # Find project entity if hint provided
        project_entity = None
        if project_hint:
            project_id = self._resolve_entity(project_hint)
            if project_id:
                project_entity = self.state.entities.get(project_id)

        quick_start = """DAILY USE:
  recall(depth, query)             → PRIMARY RETRIEVAL (shallow/medium/deep)
  open_nodes(['name1', 'name2'])   → get FULL data for specific entities
  remember(name, type, obs, rels)  → store new knowledge (entity + relations)
  add_observations(entity, [...])  → add facts to existing entity

WORKFLOW:
  1. recall(depth='medium', query='topic') → returns structure if large
  2. If structure-only, use open_nodes() to expand specific entities

BEFORE CREATING:
  find_similar(name)               → check for duplicates (>80% blocks create)

CLEANUP (run periodically):
  get_graph_health()               → orphans, duplicates, overloaded entities
  find_orphans()                   → entities with no relations
  merge_entities(src, target)      → consolidate duplicates

HISTORY (when needed):
  get_state_at(timestamp)          → view graph at point in time
  diff_timerange(start, end)       → what changed between times
  get_entity_history(entity)       → changelog for one entity

UNDO / RECOVERY:
  reload()                         → sync MCP server with disk (after git ops)
  rewind(steps=1)                  → quick undo via git
  restore_state_at(timestamp)      → restore with full audit trail

WEIGHTS (rarely):
  get_relation_weight(id)          → see weight breakdown
  set_relation_importance(id, 0-1) → boost/demote relation
  get_strongest_connections(entity) → most important links
  get_weak_relations()             → pruning candidates

NAMING CONVENTION:
  Use topic/ prefix for grouping: topic/auth, topic/testing, topic/api-design
  This makes related entities easy to find and visualize together.

IMPORTANT — MEMORY SCOPE:
  If this is a new project, ASK the user: "Should memory be project-local or global?"
  - Project-local (.claude/memory): repo-specific decisions, architecture, patterns
  - Global (~/.claude/memory): cross-project learnings, personal preferences
  Configure via MEMORY_PATH env var or --global CLI flag.

TIP: Start with recall(depth='shallow') to see what's known, then remember() or add_observations()."""

        result = {
            "session_id": self.session_id,
            "memory_summary": {
                "entity_count": len(self.state.entities),
                "relation_count": len(self.state.relations),
            },
            "context": context_result["content"],
            "project": project_entity.name if project_entity else None,
            "quick_start": quick_start,
        }

        # Include bootstrap info on first run
        if bootstrap_result:
            result["first_run"] = True
            result["bootstrap"] = bootstrap_result
            result["onboarding"] = {
                "message": (
                    "Welcome to mnemograph! A usage guide has been seeded into memory. "
                    "Explore it with: recall('mnemograph', depth='medium')"
                ),
                "next_steps": [
                    "Run 'mg graph' in terminal to visualize the knowledge graph",
                    "Ask user: 'Should this project use local or global memory?'",
                    "Start adding project-specific knowledge with remember()",
                ],
                "visualization": "mg graph  # Opens interactive D3.js viewer in browser",
            }

        return result

    def session_end(self, summary: str | None = None) -> dict:
        """Signal session end, optionally save summary.

        Args:
            summary: Optional session summary to store as observation

        Returns:
            Session end acknowledgement
        """
        stored_summary = False

        if summary:
            # Try to find a project entity to attach the summary to
            project_entities = self.get_entities_by_type("project")

            if project_entities:
                # Attach to most recently accessed project
                project_entities.sort(key=lambda e: e.last_accessed or e.created_at, reverse=True)
                project = project_entities[0]

                # Add observation with session summary
                obs = Observation(
                    text=f"[Session {self.session_id}] {summary}",
                    source=self.session_id,
                )
                self._emit(
                    "add_observation",
                    {"entity_id": project.id, "observation": obs.model_dump(mode="json")},
                )
                stored_summary = True
            else:
                # Create a learning entity for the summary
                learning = Entity(
                    name=f"Session Summary ({self.session_id[:8]})",
                    type="learning",
                    observations=[Observation(text=summary, source=self.session_id)],
                    created_by=self.session_id,
                )
                self._emit("create_entity", learning.model_dump(mode="json"))
                stored_summary = True

        # Save co-access cache
        self.save_co_access_cache()

        return {
            "status": "session_ended",
            "summary_stored": stored_summary,
            "tip": "Key learnings can be stored with create_entities or add_observations anytime.",
        }

    def clear_graph(self, reason: str = "") -> dict:
        """Clear all entities and relations from the graph.

        This is event-sourced — you can rewind to before the clear using
        get_state_at() with a timestamp before the clear event.

        Args:
            reason: Optional reason for clearing (recorded in event)

        Returns:
            Confirmation with entity/relation counts cleared
        """
        counts = {
            "entities_cleared": len(self.state.entities),
            "relations_cleared": len(self.state.relations),
        }

        self._emit("clear_graph", {"reason": reason})

        return {
            "status": "cleared",
            **counts,
            "reason": reason if reason else None,
            "tip": "Use get_state_at(timestamp) to view graph before clear, or check 'mg log' for history",
            "undo_options": {
                "quick": "rewind() — uses git, fast, audit trail in git only",
                "audit": "restore_state_at('5 minutes ago') — preserves full audit trail in events",
            },
        }

    # --- Recovery Operations ---

    def reload(self) -> dict:
        """Reload graph state from events.jsonl on disk.

        Use this after:
        - Git operations (checkout, restore, revert)
        - External edits to events.jsonl
        - Any time MCP server seems out of sync with disk

        Returns:
            Current state after reload with entity/relation counts
        """
        events = self.event_store.read_all()
        self.state = materialize(events)
        self._load_co_access_cache()
        self._vector_index = None  # Force rebuild on next semantic search

        return {
            "status": "reloaded",
            "entities": len(self.state.entities),
            "relations": len(self.state.relations),
            "events_processed": len(events),
        }

    def rewind(self, steps: int = 1, to_commit: str | None = None) -> dict:
        """Rewind graph to a previous state using git.

        This restores events.jsonl from git history and reloads.
        Fast, but audit trail is only in git (not in events).

        For audit-preserving restore, use restore_state_at() instead.

        Args:
            steps: Go back N commits that touched events.jsonl (default: 1)
            to_commit: Or specify exact commit hash

        Returns:
            Status with entity/relation counts after rewind

        Raises:
            RuntimeError: If not in a git repository or git operation fails
        """
        if not self._in_git_repo:
            return {
                "status": "error",
                "error": "Not in a git repository. Cannot use git-based rewind.",
                "tip": "Use restore_state_at() for event-based restore instead.",
            }

        events_path = self.event_store.path
        relative_path = events_path.relative_to(self._git_root) if self._git_root else events_path

        if to_commit:
            commit = to_commit
        else:
            # Find the Nth commit that touched events.jsonl
            result = subprocess.run(
                ["git", "log", "--oneline", "--follow", "-n", str(steps + 1), "--", str(relative_path)],
                cwd=self._git_root,
                capture_output=True,
                text=True,
            )
            if result.returncode != 0:
                return {
                    "status": "error",
                    "error": f"Failed to get git history: {result.stderr}",
                }

            commits = result.stdout.strip().split("\n")
            if len(commits) <= steps:
                return {
                    "status": "error",
                    "error": f"Not enough history: only {len(commits)} commits found, requested {steps} steps back",
                }
            commit = commits[steps].split()[0]  # Get commit hash from "abc123 commit msg"

        # Git checkout the file at that commit
        result = subprocess.run(
            ["git", "checkout", commit, "--", str(relative_path)],
            cwd=self._git_root,
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            return {
                "status": "error",
                "error": f"Git restore failed: {result.stderr}",
            }

        # Reload state from restored file
        reload_result = self.reload()

        return {
            "status": "rewound",
            "restored_to_commit": commit,
            "entities": reload_result["entities"],
            "relations": reload_result["relations"],
            "note": "Audit trail in git only. Use restore_state_at() for event-based restore.",
        }

    def restore_state_at(self, timestamp: str, reason: str = "") -> dict:
        """Restore graph to state at a specific timestamp.

        Unlike rewind() which uses git, this:
        1. Materializes state at that timestamp
        2. Emits clear_graph event
        3. Emits events to recreate that state

        Full audit trail preserved — events show the restore happened.

        Args:
            timestamp: ISO format or relative ("2 hours ago", "yesterday")
            reason: Why restoring (recorded in clear event)

        Returns:
            Status with entity/relation counts after restore
        """
        # Parse timestamp
        ts = parse_time_reference(timestamp)

        # Get past state
        events = self.event_store.read_all()
        past_state = materialize_at(events, ts)

        if not past_state.entities:
            return {
                "status": "error",
                "error": f"No entities found at {timestamp}",
                "tip": "Use get_state_at() to preview state before restoring.",
            }

        # Clear current graph (recorded in events)
        clear_reason = f"Restoring to {timestamp}"
        if reason:
            clear_reason += f": {reason}"
        self._emit("clear_graph", {"reason": clear_reason})

        # Recreate entities from past state (recorded in events)
        for entity in past_state.entities.values():
            # Emit create_entity with original data
            entity_data = entity.model_dump(mode="json")
            self._emit("create_entity", entity_data)

        # Recreate relations from past state
        for relation in past_state.relations:
            relation_data = relation.model_dump(mode="json")
            self._emit("create_relation", relation_data)

        return {
            "status": "restored",
            "restored_to": timestamp,
            "resolved_timestamp": ts.isoformat(),
            "entities": len(self.state.entities),
            "relations": len(self.state.relations),
            "note": "Full audit trail preserved in events.",
        }

    # --- Graph Coherence Tools ---

    def find_similar(self, name: str, threshold: float = DEFAULT_SIMILARITY_THRESHOLD) -> list[dict]:
        """Find entities with similar names (potential duplicates).

        Uses combination of:
        - Substring containment (React in ReactJS)
        - Jaccard similarity on tokens
        - Prefix/suffix matching
        - Semantic similarity from embeddings

        Args:
            name: Entity name to check
            threshold: Similarity threshold 0-1 (default 0.7)

        Returns:
            List of similar entities with similarity scores, sorted by score
        """
        results = []
        name_lower = name.lower().strip()
        name_tokens = set(name_lower.split())

        for eid, entity in self.state.entities.items():
            entity_lower = entity.name.lower()

            # Skip exact matches
            if entity_lower == name_lower:
                continue

            entity_tokens = set(entity_lower.split())

            # 1. Substring containment (strong signal for "React" in "ReactJS")
            substring_score = 0.0
            if name_lower in entity_lower or entity_lower in name_lower:
                # Longer substring relative to total length = higher score
                shorter = min(len(name_lower), len(entity_lower))
                longer = max(len(name_lower), len(entity_lower))
                substring_score = shorter / longer  # e.g., "React"(5) in "ReactJS"(7) = 0.71

            # 2. Jaccard similarity on tokens (for multi-word names)
            intersection = len(name_tokens & entity_tokens)
            union = len(name_tokens | entity_tokens)
            jaccard = intersection / union if union > 0 else 0

            # 3. Prefix/suffix matching bonus
            prefix_match = name_lower.startswith(entity_lower) or entity_lower.startswith(name_lower)
            suffix_match = name_lower.endswith(entity_lower) or entity_lower.endswith(name_lower)
            affix_bonus = AFFIX_MATCH_BONUS if (prefix_match or suffix_match) else 0

            # 4. Embedding similarity (if vector index available)
            embedding_sim = 0.0
            if self._vector_index is not None:
                try:
                    search_results = self.vector_index.search(name, limit=DEFAULT_VECTOR_SEARCH_LIMIT)
                    for result_id, score in search_results:
                        if result_id == eid:
                            embedding_sim = score
                            break
                except Exception:
                    pass

            # Combined score - use max of different approaches
            # This handles different cases well:
            # - "React" vs "ReactJS": substring_score dominates
            # - "React Native" vs "React": jaccard + affix
            # - Completely different names: embedding_sim dominates
            similarity = max(
                substring_score * SUBSTRING_SIMILARITY_WEIGHT + affix_bonus,  # Substring-based
                jaccard * TOKEN_SIMILARITY_WEIGHT + affix_bonus,  # Token-based
                embedding_sim * EMBEDDING_SIMILARITY_WEIGHT,  # Semantic-based
            )

            if similarity >= threshold:
                results.append({
                    "id": eid,
                    "name": entity.name,
                    "type": entity.type,
                    "similarity": round(similarity, 2),
                    "observation_count": len(entity.observations),
                })

        return sorted(results, key=lambda x: x["similarity"], reverse=True)

    def find_orphans(self) -> list[dict]:
        """Find entities with no relations (likely incomplete).

        Uses the _connected_entities index for O(n) iteration without nested loops.

        Returns:
            List of orphan entities with metadata
        """
        orphans = []
        for eid, entity in self.state.entities.items():
            if self.state.is_orphan(eid):
                orphans.append({
                    "id": eid,
                    "name": entity.name,
                    "type": entity.type,
                    "observation_count": len(entity.observations),
                    "created_at": entity.created_at.isoformat(),
                    "last_accessed": entity.last_accessed.isoformat() if entity.last_accessed else None,
                })

        # Sort by creation date (oldest first - most likely to be stale)
        return sorted(orphans, key=lambda x: x["created_at"])

    def merge_entities(self, source: str, target: str, delete_source: bool = True) -> dict:
        """Merge source entity into target.

        - Source's observations are appended to target (with merge note)
        - Source's relations are redirected to target
        - Source is deleted (unless delete_source=False)

        Args:
            source: Entity name/ID to merge FROM
            target: Entity name/ID to merge INTO
            delete_source: Whether to delete source after (default True)

        Returns:
            Result with merged entity details
        """
        # Validate and resolve merge targets
        validation = self._validate_merge_targets(source, target)
        if "error" in validation:
            return validation

        source_id = validation["source_id"]
        target_id = validation["target_id"]
        source_entity = self.state.entities[source_id]
        target_entity = self.state.entities[target_id]

        # Perform merge operations
        observations_merged = self._merge_observations(source_id, target_id)
        relations_redirected = self._redirect_relations(source_id, target_id)

        if delete_source:
            self._emit("delete_entity", {"id": source_id})

        return {
            "status": "merged",
            "source": source_entity.name,
            "target": target_entity.name,
            "observations_merged": observations_merged,
            "relations_redirected": relations_redirected,
            "source_deleted": delete_source,
        }

    def _validate_merge_targets(self, source: str, target: str) -> dict:
        """Validate that merge source and target exist and are different.

        Returns:
            On success: {"source_id": str, "target_id": str}
            On failure: {"error": str}
        """
        source_id = self._resolve_entity(source)
        target_id = self._resolve_entity(target)

        if not source_id:
            return {"error": f"Source entity not found: {source}"}
        if not target_id:
            return {"error": f"Target entity not found: {target}"}
        if source_id == target_id:
            return {"error": "Source and target are the same entity"}
        return {"source_id": source_id, "target_id": target_id}

    def _merge_observations(self, source_id: str, target_id: str) -> int:
        """Copy observations from source to target with merge note."""
        source_entity = self.state.entities[source_id]
        count = 0
        for obs in source_entity.observations:
            merged_text = f"[Merged from {source_entity.name}] {obs.text}"
            new_obs = Observation(text=merged_text, source=self.session_id)
            self._emit(
                "add_observation",
                {"entity_id": target_id, "observation": new_obs.model_dump(mode="json")},
            )
            count += 1
        return count

    def _redirect_relations(self, source_id: str, target_id: str) -> int:
        """Redirect relations from source to target."""
        redirected = 0
        relations_to_delete = []

        for rel in self.state.relations:
            if rel.from_entity == source_id:
                if rel.to_entity != target_id:  # Avoid self-reference
                    new_rel = Relation(
                        from_entity=target_id,
                        to_entity=rel.to_entity,
                        type=rel.type,
                        created_by=self.session_id,
                    )
                    self._emit("create_relation", new_rel.model_dump(mode="json"))
                    redirected += 1
                relations_to_delete.append(rel)

            elif rel.to_entity == source_id:
                if rel.from_entity != target_id:  # Avoid self-reference
                    new_rel = Relation(
                        from_entity=rel.from_entity,
                        to_entity=target_id,
                        type=rel.type,
                        created_by=self.session_id,
                    )
                    self._emit("create_relation", new_rel.model_dump(mode="json"))
                    redirected += 1
                relations_to_delete.append(rel)

        # Delete old relations
        for rel in relations_to_delete:
            self._emit(
                "delete_relation",
                {"from_entity": rel.from_entity, "to_entity": rel.to_entity, "type": rel.type},
            )

        return redirected

    def get_graph_health(self) -> dict:
        """Assess overall health of the knowledge graph.

        Returns:
            Health report with issues and recommendations
        """
        # Collect issues
        orphans = self.find_orphans()
        duplicates = self._find_duplicate_groups()
        overloaded = self._find_overloaded_entities()
        weak_relations = self._find_weak_relations()
        cluster_count = self._count_connected_components()

        # Generate recommendations
        recommendations = self._generate_health_recommendations(
            orphans, duplicates, overloaded, weak_relations, cluster_count
        )

        return {
            "summary": {
                "total_entities": len(self.state.entities),
                "total_relations": len(self.state.relations),
                "orphan_count": len(orphans),
                "duplicate_groups": len(duplicates),
                "overloaded_count": len(overloaded),
                "weak_relation_count": len(weak_relations),
                "cluster_count": cluster_count,
            },
            "issues": {
                "orphans": orphans[:HEALTH_REPORT_ITEM_LIMIT],
                "potential_duplicates": duplicates[:HEALTH_REPORT_ITEM_LIMIT],
                "overloaded_entities": overloaded[:HEALTH_REPORT_ITEM_LIMIT],
                "weak_relations": weak_relations[:HEALTH_REPORT_ITEM_LIMIT],
            },
            "recommendations": recommendations,
        }

    def _find_duplicate_groups(self, threshold: float = DUPLICATE_DETECTION_THRESHOLD) -> list[dict]:
        """Find groups of similar entities that may be duplicates."""
        duplicates = []
        seen_ids: set[str] = set()

        for eid, entity in self.state.entities.items():
            if eid in seen_ids:
                continue

            similar = self.find_similar(entity.name, threshold=threshold)
            if similar:
                duplicates.append({
                    "entity": entity.name,
                    "similar_to": [s["name"] for s in similar],
                })
                seen_ids.add(eid)
                seen_ids.update(s["id"] for s in similar)

        return duplicates

    def _find_overloaded_entities(self, max_observations: int = OVERLOADED_ENTITY_OBSERVATION_LIMIT) -> list[dict]:
        """Find entities with too many observations (may need splitting)."""
        return [
            {"name": e.name, "observation_count": len(e.observations), "type": e.type}
            for e in self.state.entities.values()
            if len(e.observations) > max_observations
        ]

    def _find_weak_relations(self, min_weight: float = WEAK_RELATION_THRESHOLD) -> list[dict]:
        """Find relations with low weight (may be noise)."""
        weak = []
        for rel in self.state.relations:
            if rel.weight < min_weight:
                from_entity = self.state.entities.get(rel.from_entity)
                to_entity = self.state.entities.get(rel.to_entity)
                weak.append({
                    "from": from_entity.name if from_entity else rel.from_entity,
                    "to": to_entity.name if to_entity else rel.to_entity,
                    "type": rel.type,
                    "weight": round(rel.weight, 2),
                })
        return weak

    def _generate_health_recommendations(
        self,
        orphans: list,
        duplicates: list,
        overloaded: list,
        weak_relations: list,
        cluster_count: int,
    ) -> list[str]:
        """Generate actionable recommendations based on detected issues."""
        recommendations = []
        if orphans:
            recommendations.append(
                f"Connect {len(orphans)} orphan entities or consider merging/deleting them"
            )
        if duplicates:
            recommendations.append(
                f"Review {len(duplicates)} potential duplicate groups for merging"
            )
        if overloaded:
            recommendations.append(
                f"Consider splitting {len(overloaded)} overloaded entities into sub-concepts"
            )
        if weak_relations:
            recommendations.append(
                f"Review {len(weak_relations)} weak relations — may be noise or need strengthening"
            )
        if cluster_count > 1:
            recommendations.append(
                f"Graph has {cluster_count} disconnected clusters — consider linking them"
            )
        if not recommendations:
            recommendations.append("Graph looks healthy! Keep up the good knowledge hygiene.")
        return recommendations

    def _count_connected_components(self) -> int:
        """Count number of disconnected subgraphs.

        Uses relation indices for efficient neighbor lookup.
        """
        if not self.state.entities:
            return 0

        # BFS to find components using relation indices
        visited: set[str] = set()
        components = 0

        for start in self.state.entities:
            if start in visited:
                continue

            # BFS from this node
            queue = [start]
            while queue:
                node = queue.pop(0)
                if node in visited:
                    continue
                visited.add(node)

                # Get neighbors from indices
                for rel in self.state.get_outgoing_relations(node):
                    if rel.to_entity not in visited:
                        queue.append(rel.to_entity)
                for rel in self.state.get_incoming_relations(node):
                    if rel.from_entity not in visited:
                        queue.append(rel.from_entity)

            components += 1

        return components

    def suggest_relations(self, entity: str, limit: int = DEFAULT_SUGGESTION_LIMIT) -> list[dict]:
        """Suggest potential relations for an entity.

        Based on:
        - Semantic similarity to other entities
        - Co-occurrence in observations
        - Common patterns

        Args:
            entity: Entity name/ID
            limit: Max suggestions

        Returns:
            List of suggested relations with confidence
        """
        entity_id = self._resolve_entity(entity)
        if not entity_id:
            return [{"error": f"Entity not found: {entity}"}]

        entity_obj = self.state.entities[entity_id]

        # Get existing relations to avoid duplicates
        existing_targets = set()
        for rel in self.state.relations:
            if rel.from_entity == entity_id:
                existing_targets.add(rel.to_entity)
            elif rel.to_entity == entity_id:
                existing_targets.add(rel.from_entity)

        suggestions = []

        # 1. Semantic similarity via vector search
        if self._vector_index is not None:
            try:
                entity_text = f"{entity_obj.name} {' '.join(o.text for o in entity_obj.observations)}"
                search_results = self.vector_index.search(entity_text, limit=DEFAULT_VECTOR_SEARCH_LIMIT)

                for result_id, score in search_results:
                    if result_id == entity_id or result_id in existing_targets:
                        continue
                    if result_id not in self.state.entities:
                        continue

                    other = self.state.entities[result_id]
                    if score > SUGGEST_RELATION_CONFIDENCE_THRESHOLD:
                        rel_type = self._guess_relation_type(entity_obj, other)
                        suggestions.append({
                            "target": other.name,
                            "target_id": result_id,
                            "suggested_relation": rel_type,
                            "confidence": round(score, 2),
                            "reason": "semantic similarity",
                        })
            except Exception:
                pass

        # 2. Co-occurrence in observations (entity name mentioned in other's observations)
        entity_text_lower = " ".join(o.text.lower() for o in entity_obj.observations)

        for oid, other in self.state.entities.items():
            if oid == entity_id or oid in existing_targets:
                continue

            # Check if other entity's name appears in our observations
            if other.name.lower() in entity_text_lower:
                suggestions.append({
                    "target": other.name,
                    "target_id": oid,
                    "suggested_relation": "mentions",
                    "confidence": CO_OCCURRENCE_CONFIDENCE,
                    "reason": f"'{other.name}' mentioned in observations",
                })

            # Check if our name appears in other's observations
            other_text_lower = " ".join(o.text.lower() for o in other.observations)
            if entity_obj.name.lower() in other_text_lower:
                # Only add if not already suggested
                existing = [s for s in suggestions if s["target_id"] == oid]
                if not existing:
                    suggestions.append({
                        "target": other.name,
                        "target_id": oid,
                        "suggested_relation": "mentioned_by",
                        "confidence": SHARED_RELATION_CONFIDENCE,
                        "reason": f"mentioned in '{other.name}' observations",
                    })

        # Dedupe and sort by confidence
        seen = set()
        unique = []
        for s in sorted(suggestions, key=lambda x: x["confidence"], reverse=True):
            if s["target_id"] not in seen:
                seen.add(s["target_id"])
                unique.append(s)

        return unique[:limit]

    def _guess_relation_type(self, entity, other) -> str:
        """Guess appropriate relation type based on entity types."""
        type_map = {
            ("concept", "project"): "used_by",
            ("project", "concept"): "uses",
            ("decision", "concept"): "decides_on",
            ("concept", "decision"): "decided_by",
            ("pattern", "concept"): "applies_to",
            ("concept", "pattern"): "has_pattern",
            ("learning", "concept"): "about",
            ("question", "concept"): "about",
            ("project", "project"): "related_to",
        }
        return type_map.get((entity.type, other.type), "related_to")

    # --- High-Level Knowledge Creation ---

    def remember(
        self,
        name: str,
        entity_type: str,
        observations: list[str] | None = None,
        relations: list[dict] | None = None,
        force: bool = False,
    ) -> dict:
        """Store knowledge atomically — entity + observations + relations in one call.

        This is the primary tool for storing new knowledge. It creates an entity
        with observations and relations together, preventing orphan entities.

        Args:
            name: Entity name
            entity_type: One of: concept, decision, project, pattern, question, learning, entity
            observations: List of facts/observations about this entity
            relations: List of relations FROM this entity: [{"to": "Target", "type": "uses"}, ...]
            force: If True, bypass duplicate check

        Returns:
            On success: {"status": "created", "entity": {...}, "relations_created": N}
            On duplicate warning: {"status": "duplicate_warning", "similar": [...], ...}

        Example:
            remember(
                name="FastAPI",
                entity_type="concept",
                observations=["Async Python web framework", "Uses Pydantic for validation"],
                relations=[
                    {"to": "Python", "type": "uses"},
                    {"to": "Pydantic", "type": "depends_on"}
                ]
            )
        """
        observations = observations or []
        relations = relations or []

        # Auto-duplicate check (unless force=True)
        if not force:
            similar = self.find_similar(name, threshold=DUPLICATE_AUTO_BLOCK_THRESHOLD)
            if similar:
                top = similar[0]
                return {
                    "status": "duplicate_warning",
                    "name": name,
                    "warning": f"Similar entity exists: '{top['name']}' ({top['similarity']:.0%} match)",
                    "similar": similar,
                    "suggestion": f"Use add_observations('{top['name']}', ...) to add facts to existing entity",
                    "override": "Use remember(..., force=True) to create anyway",
                }

        # Create the entity
        entity = Entity(
            name=name,
            type=entity_type,
            observations=[
                Observation(text=obs, source=self.session_id)
                for obs in observations
            ],
            created_by=self.session_id,
        )
        self._emit("create_entity", entity.model_dump(mode="json"))

        # Index in vector store if available
        self.ensure_indexed(entity.id)

        # Create relations
        relations_created = 0
        relations_failed = []

        for rel in relations:
            to_name = rel.get("to")
            rel_type = rel.get("type", "related_to")

            if not to_name:
                relations_failed.append({"error": "Missing 'to' field", "relation": rel})
                continue

            to_id = self._resolve_entity(to_name)
            if not to_id:
                relations_failed.append({
                    "error": f"Target entity not found: {to_name}",
                    "relation": rel,
                    "suggestion": f"Create '{to_name}' first, or use create_relations later",
                })
                continue

            relation = Relation(
                from_entity=entity.id,
                to_entity=to_id,
                type=rel_type,
                created_by=self.session_id,
            )
            self._emit("create_relation", relation.model_dump(mode="json"))
            relations_created += 1

        result = {
            "status": "created",
            "entity": entity.model_dump(mode="json"),
            "relations_created": relations_created,
        }

        if relations_failed:
            result["relations_failed"] = relations_failed

        return result
