"""Memory system — LanceDB backend with columnar experience schema."""

from __future__ import annotations

import threading
import time
from collections.abc import Sized
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from dataclasses import dataclass
from datetime import datetime
from math import exp, log
from pathlib import Path
from typing import Any, Callable

from loguru import logger

from bao.utils.db import ensure_table, get_db

# Schema with columnar experience fields + categorized long-term memory
_SAMPLE = [
    {
        "key": "_init_",
        "content": "",
        "type": "long_term",
        "category": "",
        "quality": 0,
        "uses": 0,
        "successes": 0,
        "outcome": "",
        "deprecated": False,
        "updated_at": "",
        "last_hit_at": "",
        "hit_count": 0,
    }
]

_ENV_KEY = "BAO_EMBEDDING_API_KEY"
_ENV_BASE = "BAO_EMBEDDING_BASE_URL"

_DEFAULT_EMBED_TIMEOUT_S = 15
_QUERY_EMBED_CACHE_TTL_S = 120.0
_QUERY_EMBED_CACHE_MAX = 256
_BACKFILL_SCAN_LIMIT = 20000
_MIGRATION_CHUNK_SIZE = 1000

# Quality -> retention period (days). Higher quality decays slower.
_RETENTION_DAYS = {5: 365, 4: 180, 3: 90, 2: 30, 1: 14}

# Long-term memory categories
MEMORY_CATEGORIES = ("preference", "personal", "project", "general")

MEMORY_CATEGORY_CAPS = {
    "preference": 400,
    "personal": 300,
    "project": 500,
    "general": 300,
}

_LOW_INFORMATION_QUERIES = frozenset(
    {
        "ok",
        "okay",
        "thanks",
        "thankyou",
        "thx",
        "gotit",
        "roger",
        "sure",
        "yes",
        "no",
        "hi",
        "hello",
        "收到",
        "知道了",
        "明白了",
        "好的",
        "好哦",
        "行",
        "可以",
        "继续",
        "谢谢",
        "多谢",
        "辛苦了",
        "嗯",
        "嗯嗯",
        "哦",
        "哦哦",
        "嗨",
        "你好",
        "好",
    }
)

# Category importance weights for rerank scoring (higher = more important to retain)
_CATEGORY_WEIGHTS: dict[str, float] = {
    "preference": 1.0,
    "project": 0.8,
    "personal": 0.6,
    "general": 0.4,
}


@dataclass(frozen=True)
class RecallBundle:
    long_term_context: str = ""
    related_memory: tuple[str, ...] = ()
    related_experience: tuple[str, ...] = ()


@dataclass(frozen=True)
class _RecallQueryContext:
    query: str
    query_terms: tuple[str, ...]
    query_term_set: frozenset[str]
    query_vectors: list[list[float]] | None = None


class _LongTermMemoryDomain:
    def __init__(self, host: "MemoryStore"):
        self._host = host

    def read_long_term(self, category: str | None = None) -> str:
        with self._host._store_lock:
            try:
                if category:
                    return self._host._join_memory_facts(self._host._read_long_term_facts_locked(category))
                parts = []
                for cat in MEMORY_CATEGORIES:
                    content = self._host._join_memory_facts(self._host._read_long_term_facts_locked(cat))
                    if content:
                        parts.append(f"[{cat}] {content}")
                return "\n".join(parts)
            except Exception:
                return ""

    def list_long_term_entries(self) -> list[dict[str, str]]:
        return [
            {
                "key": str(item.get("key") or ""),
                "category": str(item.get("category") or "general"),
                "content": str(item.get("content") or ""),
            }
            for item in self.list_memory_categories()
            if str(item.get("content") or "").strip()
        ]

    def list_memory_categories(self) -> list[dict[str, Any]]:
        with self._host._store_lock:
            try:
                rows = self._host._read_long_term_rows_locked()
            except Exception:
                rows = []

        by_category: dict[str, list[dict[str, Any]]] = {category: [] for category in MEMORY_CATEGORIES}
        for row in rows:
            category = str(row.get("category") or "general")
            if category in by_category and str(row.get("content", "")).strip():
                by_category[category].append(dict(row))
        items: list[dict[str, Any]] = []
        for category in MEMORY_CATEGORIES:
            facts = [str(row.get("content", "")).strip() for row in by_category.get(category, [])]
            content = self._host._join_memory_facts(facts)
            latest = max((str(row.get("updated_at", "")) for row in by_category.get(category, [])), default="")
            preview = content.replace("\n", " ")[:160]
            if len(content.replace("\n", " ")) > 160:
                preview += "…"
            items.append(
                {
                    "key": f"long_term_{category}",
                    "category": category,
                    "content": content,
                    "preview": preview,
                    "updated_at": latest,
                    "char_count": len(content),
                    "line_count": len(facts),
                    "fact_count": len(facts),
                    "is_empty": not bool(content),
                }
            )
        return items

    def get_memory_category(self, category: str) -> dict[str, Any] | None:
        if category not in MEMORY_CATEGORIES:
            return None
        for item in self.list_memory_categories():
            if item.get("category") == category:
                return item
        return None

    def exists_long_term_key(self, key: str) -> bool:
        if not key:
            return False
        if key.startswith("long_term_"):
            category = key.removeprefix("long_term_")
            if category in MEMORY_CATEGORIES:
                return bool(self.read_long_term(category).strip())
        key_safe = key.replace("'", "''")
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where(f"type = 'long_term' AND key = '{key_safe}'")
                    .limit(1)
                    .to_list()
                )
                return bool(rows)
            except Exception:
                return False

    def delete_long_term_by_key(self, key: str) -> bool:
        if not key:
            return False
        if key.startswith("long_term_"):
            category = key.removeprefix("long_term_")
            if category in MEMORY_CATEGORIES:
                return self.clear_memory_category(category) is not None
        key_safe = key.replace("'", "''")
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where(f"type = 'long_term' AND key = '{key_safe}'")
                    .limit(1)
                    .to_list()
                )
                if not rows:
                    return False
                self._host._tbl.delete(f"type = 'long_term' AND key = '{key_safe}'")
                self._host._invalidate_retrieval_index()
                return True
            except Exception as e:
                logger.warning("⚠️ 按键删除失败 / delete by key failed: {}", e)
                return False

    def write_long_term(self, content: str, category: str = "general") -> None:
        if category not in MEMORY_CATEGORIES:
            category = "general"
        facts = self._host._normalize_memory_facts(
            content,
            max_chars=MEMORY_CATEGORY_CAPS.get(category, MEMORY_CATEGORY_CAPS["general"]),
        )
        with self._host._store_lock:
            changed = self._host._replace_long_term_facts_locked(category, facts)
        if changed:
            self._host._schedule_long_term_embedding()

    def append_memory_category(self, category: str, content: str) -> dict[str, Any] | None:
        if category not in MEMORY_CATEGORIES:
            return None
        self.remember(content, category)
        return self.get_memory_category(category)

    def clear_memory_category(self, category: str) -> dict[str, Any] | None:
        if category not in MEMORY_CATEGORIES:
            return None
        self.write_long_term("", category)
        return self.get_memory_category(category)

    def write_categorized_memory(self, updates: dict[str, Any]) -> None:
        changed_any = False
        with self._host._store_lock:
            for cat, content in updates.items():
                if cat not in MEMORY_CATEGORIES:
                    continue
                if content is not None and not isinstance(content, (str, list)):
                    continue
                facts = self._host._normalize_memory_facts(
                    content or "",
                    max_chars=MEMORY_CATEGORY_CAPS.get(cat, MEMORY_CATEGORY_CAPS["general"]),
                )
                if self._host._replace_long_term_facts_locked(cat, facts):
                    changed_any = True
        if changed_any:
            self._host._schedule_long_term_embedding()

    def get_memory_context(self, max_chars: int | None = None) -> str:
        parts = self._host._collect_long_term_parts()
        return self._host._format_long_term_parts(parts, max_chars=max_chars)

    def get_relevant_memory_context(
        self,
        query: str,
        max_chars: int | None = None,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> str:
        query_ctx = query_context or self._host._build_recall_query_context(
            query,
            include_vectors=False,
        )
        if query_ctx is None:
            return ""
        if not query_ctx.query_term_set:
            return ""
        parts = self._host._collect_long_term_parts(query_tokens=query_ctx.query_term_set)
        return self._host._format_long_term_parts(parts, max_chars=max_chars)

    def remember(self, content: str, category: str = "general") -> str:
        if category not in MEMORY_CATEGORIES:
            category = "general"
        new_facts = self._host._normalize_memory_facts(
            content,
            max_chars=MEMORY_CATEGORY_CAPS.get(category, MEMORY_CATEGORY_CAPS["general"]),
        )
        if not new_facts:
            return f"Remembered in [{category}]: "
        with self._host._store_lock:
            current_facts = self._host._read_long_term_facts_locked(category)
            merged = self._host._normalize_memory_facts(
                current_facts + new_facts,
                max_chars=MEMORY_CATEGORY_CAPS.get(category, MEMORY_CATEGORY_CAPS["general"]),
            )
            changed = self._host._replace_long_term_facts_locked(category, merged)
        if changed:
            self._host._embed_long_term_aggregate()
        return f"Remembered in [{category}]: {content[:80]}"

    def forget(self, query: str) -> str:
        removed = 0
        changed = False
        with self._host._store_lock:
            try:
                rows = self._host._read_long_term_rows_locked()
                query_lower = query.lower()
                for category in MEMORY_CATEGORIES:
                    facts = []
                    kept_facts = []
                    for row in rows:
                        if str(row.get("category") or "general") != category:
                            continue
                        fact = str(row.get("content") or "").strip()
                        if not fact:
                            continue
                        facts.append(fact)
                        if query_lower in fact.lower():
                            removed += 1
                            continue
                        kept_facts.append(fact)
                    if facts != kept_facts and self._host._replace_long_term_facts_locked(category, kept_facts):
                        changed = True
            except Exception as e:
                logger.warning("⚠️ 遗忘失败 / forget failed: {}", e)
        if changed:
            self._host._embed_long_term_aggregate()
        return f"Removed {removed} memory entries matching '{query[:40]}'."

    def update_memory(self, category: str, content: str) -> str:
        if category not in MEMORY_CATEGORIES:
            return f"Invalid category. Use one of: {', '.join(MEMORY_CATEGORIES)}"
        facts = self._host._normalize_memory_facts(
            content,
            max_chars=MEMORY_CATEGORY_CAPS.get(category, MEMORY_CATEGORY_CAPS["general"]),
        )
        with self._host._store_lock:
            changed = self._host._replace_long_term_facts_locked(category, facts)
        if changed:
            self._host._embed_long_term_aggregate()
        return f"Updated [{category}] memory."


class _ExperienceMemoryDomain:
    def __init__(self, host: "MemoryStore"):
        self._host = host

    def append_experience(
        self,
        task: str,
        outcome: str,
        lessons: str,
        quality: int = 3,
        category: str = "general",
        keywords: str = "",
        reasoning_trace: str = "",
    ) -> None:
        row_key = ""
        with self._host._store_lock:
            ts = datetime.now().isoformat()
            row_key = f"experience_{ts}"
            parts = [f"Task: {task}", f"Lessons: {lessons}"]
            if keywords:
                parts.append(f"Keywords: {keywords}")
            if reasoning_trace:
                parts.append(f"Trace: {reasoning_trace}")
            content = "\n".join(parts)
            self._host._tbl.add(
                [
                    self._host._make_row(
                        key=row_key,
                        content=content,
                        type_="experience",
                        category=category,
                        quality=quality,
                        outcome=outcome,
                        updated_at=ts,
                    )
                ]
            )
            self._host._invalidate_retrieval_index()
        self._host._embed_and_store(key=row_key, content=content, type_="experience")

    def search_experience(
        self,
        query: str,
        limit: int = 3,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> list[str]:
        query_ctx = query_context or self._host._build_recall_query_context(
            query,
            include_vectors=True,
        )
        if query_ctx is None:
            return []
        candidates = self._host._fetch_experience_candidates(query_ctx, limit * 5)
        now = datetime.now()
        positive: list[tuple[float, dict[str, Any], str, str, str]] = []
        warnings: list[tuple[float, dict[str, Any], str, str]] = []
        for row in candidates:
            if "quality" not in row and "outcome" not in row and "updated_at" not in row:
                continue
            if row.get("deprecated"):
                continue
            quality = row.get("quality", 3)
            days_old = self._host._days_since(row.get("updated_at", ""), now)
            decay = exp(-days_old / _RETENTION_DAYS.get(quality, 90))
            conf = self._host._confidence(row)
            score = quality * decay * conf
            content = row.get("content") or ""
            outcome = row.get("outcome", "")
            if outcome == "failed":
                warnings.append((score, row, content, row.get("category") or "general"))
            else:
                positive.append((score, row, content, row.get("category") or "general", outcome or "success"))
        positive.sort(key=lambda item: item[0], reverse=True)
        warnings.sort(key=lambda item: item[0], reverse=True)
        results: list[str] = []
        hit_rows: list[dict[str, Any]] = []
        seen_categories: dict[str, str] = {}
        for _, row, content, category, outcome_str in positive:
            if len(results) >= limit - 1:
                break
            previous = seen_categories.get(category)
            if previous and previous != outcome_str:
                content = f"\u26a1 CONFLICTING experience (category '{category}'):\n{content}"
            seen_categories.setdefault(category, outcome_str)
            results.append(content)
            hit_rows.append(row)
        if warnings:
            results.append(f"\u26a0\ufe0f WARNING from past failure:\n{warnings[0][2]}")
            hit_rows.append(warnings[0][1])
        final = results[:limit]
        if hit_rows:
            self._host._schedule_hit_stats_update(hit_rows)
        return final

    def list_experience_items(
        self,
        query: str = "",
        *,
        category: str = "",
        outcome: str = "",
        deprecated: bool | None = None,
        min_quality: int = 0,
        sort_by: str = "updated_desc",
        limit: int = 200,
    ) -> list[dict[str, Any]]:
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where("type = 'experience'")
                    .limit(max(limit * 2, 200))
                    .to_list()
                )
            except Exception:
                return []

        items = [self._host._experience_row_to_item(row) for row in rows]
        if query and not self._host.should_skip_retrieval(query):
            query_tokens = set(self._host._tokenize(query))
            if query_tokens:
                items = [
                    item
                    for item in items
                    if query_tokens
                    & set(
                        self._host._tokenize(
                            " ".join(
                                [
                                    str(item.get("task", "")),
                                    str(item.get("lessons", "")),
                                    str(item.get("keywords", "")),
                                    str(item.get("category", "")),
                                    str(item.get("outcome", "")),
                                ]
                            )
                        )
                    )
                ]
        if category:
            items = [item for item in items if item.get("category") == category]
        if outcome:
            items = [item for item in items if item.get("outcome") == outcome]
        if deprecated is not None:
            items = [item for item in items if bool(item.get("deprecated")) is deprecated]
        if min_quality > 0:
            items = [item for item in items if int(item.get("quality", 0)) >= min_quality]

        if sort_by == "quality_desc":
            items.sort(
                key=lambda item: (
                    int(item.get("quality", 0)),
                    int(item.get("uses", 0)),
                    str(item.get("updated_at", "")),
                ),
                reverse=True,
            )
        elif sort_by == "uses_desc":
            items.sort(
                key=lambda item: (
                    int(item.get("uses", 0)),
                    int(item.get("successes", 0)),
                    str(item.get("updated_at", "")),
                ),
                reverse=True,
            )
        else:
            items.sort(key=lambda item: str(item.get("updated_at", "")), reverse=True)
        return items[:limit]

    def get_experience_item(self, key: str) -> dict[str, Any] | None:
        if not key:
            return None
        key_safe = key.replace("'", "''")
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where(f"type = 'experience' AND key = '{key_safe}'")
                    .limit(1)
                    .to_list()
                )
            except Exception:
                return None
        if not rows:
            return None
        return self._host._experience_row_to_item(rows[0])

    def set_experience_deprecated(self, key: str, deprecated: bool) -> bool:
        item = self.get_experience_item(key)
        if item is None:
            return False
        key_safe = key.replace("'", "''")
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where(f"type = 'experience' AND key = '{key_safe}'")
                    .limit(1)
                    .to_list()
                )
                if not rows:
                    return False
                self._host._update_experience(rows[0], deprecated=deprecated)
                return True
            except Exception as e:
                logger.warning("⚠️ 更新经验停用状态失败 / set deprecated failed: {}", e)
                return False

    def delete_experience(self, key: str) -> bool:
        if not key:
            return False
        key_safe = key.replace("'", "''")
        with self._host._store_lock:
            try:
                rows = (
                    self._host._tbl.search()
                    .where(f"type = 'experience' AND key = '{key_safe}'")
                    .limit(1)
                    .to_list()
                )
                if not rows:
                    return False
                self._host._tbl.delete(f"type = 'experience' AND key = '{key_safe}'")
                self._host._invalidate_retrieval_index()
                self._host._delete_vector_by_key(key)
                return True
            except Exception as e:
                logger.warning("⚠️ 删除经验失败 / delete experience failed: {}", e)
                return False

    def promote_experience_to_memory(self, key: str, category: str = "project") -> dict[str, Any] | None:
        item = self.get_experience_item(key)
        if item is None or category not in MEMORY_CATEGORIES:
            return None
        task = str(item.get("task", "")).strip()
        lessons = str(item.get("lessons", "")).strip()
        keywords = str(item.get("keywords", "")).strip()
        if not (task or lessons):
            return None
        line = f"{task} — {lessons}" if task and lessons else (task or lessons)
        if keywords:
            line = f"{line} [{keywords}]"
        self._host._ensure_domains()
        return self._host._long_term_domain.append_memory_category(category, line)

    def deprecate_similar(self, task_desc: str) -> int:
        return self._host._mutate_experiences(
            task_desc,
            threshold=0.5,
            mutator=lambda _row: {"deprecated": True},
            action="🧹 标记过时 / experiences deprecated",
        )

    def boost_experience(self, task_desc: str, delta: int = 1) -> int:
        def _mutator(row: dict[str, Any]) -> dict[str, Any] | None:
            old_quality = row.get("quality", 3)
            new_quality = max(1, min(5, old_quality + delta))
            if new_quality == old_quality:
                return None
            return {"quality": new_quality}

        return self._host._mutate_experiences(
            task_desc,
            threshold=0.4,
            mutator=_mutator,
            action=f"🧠 提升经验 / experience boosted ({delta:+d})",
        )

    def record_reuse(self, task_desc: str, success: bool) -> int:
        def _mutator(row: dict[str, Any]) -> dict[str, Any] | None:
            new_uses = row.get("uses", 0) + 1
            new_successes = row.get("successes", 0) + (1 if success else 0)
            updates: dict[str, Any] = {"uses": new_uses, "successes": new_successes}
            if new_uses >= 3:
                confidence = new_successes / new_uses
                current_quality = row.get("quality", 3)
                if confidence >= 0.8:
                    updates["quality"] = min(5, current_quality + 1)
                elif confidence < 0.4:
                    updates["quality"] = max(1, current_quality - 1)
            return updates

        sign = "+" if success else "-"
        return self._host._mutate_experiences(
            task_desc,
            threshold=0.4,
            mutator=_mutator,
            action=f"📝 记录复用 / reuse recorded ({sign})",
        )

    def cleanup_stale(self, max_deprecated_days: int = 30, max_low_quality_days: int = 90) -> int:
        with self._host._store_lock:
            try:
                rows = self._host._tbl.search().where("type = 'experience'").limit(500).to_list()
                now = datetime.now()
                removed = 0
                for row in rows:
                    days_old = self._host._days_since(row.get("updated_at", ""), now)
                    is_deprecated = row.get("deprecated", False)
                    quality = row.get("quality", 3)
                    uses = row.get("uses", 0)
                    hit_count = row.get("hit_count", 0)
                    has_hit_tracking = bool(row.get("last_hit_at"))
                    if quality >= 5 and uses >= 3 and not is_deprecated:
                        continue
                    should_remove = (
                        (is_deprecated and days_old > max_deprecated_days)
                        or (quality <= 1 and days_old > max_low_quality_days)
                        or (has_hit_tracking and hit_count == 0 and days_old > 60 and quality <= 2)
                        or (has_hit_tracking and hit_count <= 1 and days_old > 120 and quality <= 3)
                    )
                    if should_remove and (key := row.get("key")):
                        self._host._tbl.delete(f"key = '{key}'")
                        self._host._delete_vector_by_key(key)
                        removed += 1
                if removed:
                    self._host._invalidate_retrieval_index()
                if removed:
                    logger.info("🧹 清理经验 / stale experiences cleaned: {}", removed)
                return removed
            except Exception as e:
                logger.warning("⚠️ 清理经验失败 / cleanup failed: {}", e)
                return 0

    def get_merge_candidates(self, min_count: int = 5) -> list[list[str]]:
        with self._host._store_lock:
            try:
                rows = self._host._tbl.search().where("type = 'experience'").limit(200).to_list()
                active = [row for row in rows if not row.get("deprecated")]
                if len(active) < min_count:
                    return []
                groups: dict[str, list[str]] = {}
                for row in active:
                    category = row.get("category") or "general"
                    groups.setdefault(category, []).append(row.get("content") or "")
                return [entries for entries in groups.values() if len(entries) >= 3]
            except Exception as e:
                logger.warning("⚠️ 候选检索失败 / merge candidates failed: {}", e)
                return []

    def replace_merged(
        self,
        old_entries: list[str],
        merged_content: str,
        *,
        category: str = "general",
        quality: int = 4,
    ) -> None:
        merged_key = ""
        with self._host._store_lock:
            try:
                rows = self._host._tbl.search().where("type = 'experience'").limit(200).to_list()
                content_to_key = {row.get("content"): row.get("key") for row in rows}
                for entry in old_entries:
                    if key := content_to_key.get(entry):
                        self._host._tbl.delete(f"key = '{key}'")
                        self._host._delete_vector_by_key(key)
                ts = datetime.now().isoformat()
                merged_key = f"experience_merged_{ts}"
                self._host._tbl.add(
                    [
                        self._host._make_row(
                            key=merged_key,
                            content=merged_content,
                            type_="experience",
                            category=category,
                            quality=quality,
                            updated_at=ts,
                        )
                    ]
                )
                self._host._invalidate_retrieval_index()
                logger.info("🔀 合并经验 / experiences merged: {} into 1", len(old_entries))
            except Exception as e:
                logger.warning("⚠️ 合并经验失败 / experience merge failed: {}", e)
                return
        self._host._embed_and_store(key=merged_key, content=merged_content, type_="experience")


class _RecallDomain:
    def __init__(self, host: "MemoryStore"):
        self._host = host

    def search_memory(
        self,
        query: str,
        limit: int = 5,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> list[str]:
        query_ctx = query_context or self._host._build_recall_query_context(
            query,
            include_vectors=True,
        )
        if query_ctx is None:
            return []
        fetch = max(limit * 3, 6)
        vec_enriched: list[dict[str, Any]] = []
        if self._host._embed_fn and self._host._vec_tbl and query_ctx.query_vectors:
            try:
                vec = query_ctx.query_vectors[0] if query_ctx.query_vectors else None
                if isinstance(vec, Sized) and len(vec) > 0:
                    with self._host._store_lock:
                        if not self._host._vec_tbl:
                            vec_rows = []
                        else:
                            vec_rows = (
                                self._host._vec_tbl.search(vec)
                                .where("type NOT IN ('experience', 'long_term')")
                                .limit(fetch)
                                .to_list()
                            )
                    if vec_rows:
                        vec_enriched = self._host._enrich_for_rerank(vec_rows)
            except Exception as e:
                logger.warning("⚠️ 语义检索失败 / semantic search failed: {}", e)

        text_candidates = self._host._fallback_text_candidates(
            query_ctx.query,
            limit=fetch,
            exclude_types=["experience", "long_term"],
            query_terms=query_ctx.query_terms,
        )
        candidates = self._host._merge_memory_candidates(vec_enriched, text_candidates)
        if candidates:
            reranked = self._host._rerank_candidates(
                candidates,
                limit=limit,
                has_vector_score=bool(vec_enriched),
            )
            if reranked:
                return [row["content"] for row in reranked if row.get("content")]
        return []

    def recall(
        self,
        query: str,
        *,
        related_limit: int = 5,
        experience_limit: int = 3,
        long_term_chars: int | None = None,
        include_long_term: bool = True,
    ) -> RecallBundle:
        query_ctx = self._host._build_recall_query_context(query, include_vectors=True)
        if query_ctx is None:
            return RecallBundle()
        return RecallBundle(
            long_term_context=(
                self._host.get_relevant_memory_context(
                    query,
                    max_chars=long_term_chars,
                    query_context=query_ctx,
                )
                if include_long_term
                else ""
            ),
            related_memory=tuple(
                self._host.search_memory(
                    query,
                    limit=related_limit,
                    query_context=query_ctx,
                )
            ),
            related_experience=tuple(
                self._host.search_experience(
                    query,
                    limit=experience_limit,
                    query_context=query_ctx,
                )
            ),
        )


class _GeminiEmbedding:
    """Thin wrapper using google-genai SDK (avoids legacy google-generativeai dependency)."""

    def __init__(self, model: str, api_key: str):
        from google import genai

        self._client = genai.Client(api_key=api_key)
        self._model = model

    def compute_source_embeddings(self, texts: list[str]) -> list[list[float]]:
        result = self._client.models.embed_content(
            model=self._model,
            contents=[str(t) for t in texts],
            config={"task_type": "RETRIEVAL_DOCUMENT"},
        )
        return [e.values or [] for e in (result.embeddings or [])]

    def compute_query_embeddings(self, query: str) -> list[list[float]]:
        result = self._client.models.embed_content(
            model=self._model,
            contents=[query],
            config={"task_type": "RETRIEVAL_QUERY"},
        )
        return [e.values or [] for e in (result.embeddings or [])]


class MemoryStore:
    _EMBED_EXECUTOR = ThreadPoolExecutor(max_workers=8, thread_name_prefix="Bao-embed")
    _MEMORY_BG_EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="Bao-memory-bg")

    def __init__(self, workspace: Path, embedding_config: Any | None = None):
        self._store_lock = threading.RLock()
        self._db: Any = get_db(workspace)
        self._tbl: Any = self._ensure_migrated_table()
        self._embed_fn = None
        self._vec_tbl: Any | None = None
        self._embed_timeout_s = _DEFAULT_EMBED_TIMEOUT_S
        self._query_embed_cache: dict[str, tuple[float, list[list[float]]]] = {}
        self._query_embed_cache_lock = threading.Lock()
        self._retrieval_revision = 0
        self._retrieval_index: dict[str, Any] | None = None
        self._pending_hit_updates: dict[str, dict[str, Any]] = {}
        self._hit_stats_lock = threading.Lock()
        self._hit_flush_inflight = False
        self._migrate_legacy(workspace)
        self._migrate_long_term_facts()
        self._init_domains()
        if embedding_config and getattr(embedding_config, "enabled", False):
            self._init_embedding(embedding_config)

    def close(self) -> None:
        with self._store_lock:
            self._vec_tbl = None
            self._embed_fn = None
            self._invalidate_retrieval_index()

    def _init_domains(self) -> None:
        self._long_term_domain = _LongTermMemoryDomain(self)
        self._experience_domain = _ExperienceMemoryDomain(self)
        self._recall_domain = _RecallDomain(self)

    def _ensure_domains(self) -> None:
        if not hasattr(self, "_long_term_domain"):
            self._init_domains()

    def _long_term(self) -> _LongTermMemoryDomain:
        self._ensure_domains()
        return self._long_term_domain

    def _experience(self) -> _ExperienceMemoryDomain:
        self._ensure_domains()
        return self._experience_domain

    def _recall(self) -> _RecallDomain:
        self._ensure_domains()
        return self._recall_domain

    def _ensure_migrated_table(self):
        """Ensure memory table exists with current schema, migrating if needed."""
        try:
            tbl = self._db.open_table("memory")
            probe = tbl.search().limit(1).to_list()
            if probe and "quality" not in probe[0]:
                return self._migrate_schema(tbl)
            # Check for newer columns added after columnar migration
            if probe and "hit_count" not in probe[0]:
                return self._backfill_new_columns(tbl)
            return tbl
        except Exception:
            return ensure_table(self._db, "memory", list(_SAMPLE))

    def _migrate_schema(self, old_tbl):
        """Migrate from text-based to columnar schema."""
        try:
            rows = old_tbl.search().to_list()
            # Clean up residual temp table from previous failed migration
            try:
                self._db.drop_table("memory_migrated")
            except Exception:
                pass

            self._db.create_table("memory_migrated", data=list(_SAMPLE))
            staged = self._db.open_table("memory_migrated")
            staged.delete("key = '_init_'")

            batch: list[dict[str, Any]] = []
            migrated_count = 0
            source_rows = rows or list(_SAMPLE)
            for r in source_rows:
                new_row = {
                    "key": r.get("key", ""),
                    "content": r.get("content", ""),
                    "type": r.get("type", ""),
                    "updated_at": r.get("updated_at", ""),
                    "category": "",
                    "quality": 0,
                    "uses": 0,
                    "successes": 0,
                    "outcome": "",
                    "deprecated": False,
                    "last_hit_at": "",
                    "hit_count": 0,
                }
                if r.get("type") == "long_term":
                    new_row["category"] = "general"
                    if new_row["key"] == "long_term":
                        new_row["key"] = "long_term_general"
                if r.get("type") == "experience":
                    content = str(r.get("content", ""))
                    new_row["deprecated"] = content.startswith("[Deprecated]")
                    if new_row["deprecated"]:
                        content = content[len("[Deprecated]") :].strip()
                    new_row["quality"] = self._parse_field_int(content, "Quality", 3)
                    new_row["uses"] = self._parse_field_int(content, "Uses", 0)
                    new_row["successes"] = self._parse_field_int(content, "Successes", 0)
                    new_row["category"] = self._parse_field_str(content, "Category") or "general"
                    new_row["outcome"] = self._parse_field_str(content, "Outcome") or ""
                    task = self._parse_field_str(content, "Task")
                    lessons = self._parse_field_str(content, "Lessons")
                    keywords = self._parse_field_str(content, "Keywords")
                    trace = self._parse_field_str(content, "Trace")
                    parts = []
                    if task:
                        parts.append(f"Task: {task}")
                    if lessons:
                        parts.append(f"Lessons: {lessons}")
                    if keywords:
                        parts.append(f"Keywords: {keywords}")
                    if trace:
                        parts.append(f"Trace: {trace}")
                    new_row["content"] = "\n".join(parts) if parts else content
                batch.append(new_row)
                if len(batch) >= _MIGRATION_CHUNK_SIZE:
                    staged.add(batch)
                    migrated_count += len(batch)
                    batch = []
            if batch:
                staged.add(batch)
                migrated_count += len(batch)

            staged_rows = staged.search().to_list()
            self._db.drop_table("memory")
            # LanceDB has no rename; recreate with correct name
            tbl = self._db.create_table("memory", data=staged_rows)
            self._db.drop_table("memory_migrated")
            # Drop stale vectors so _backfill_embeddings rebuilds with new content format
            try:
                self._db.drop_table("memory_vectors")
            except Exception:
                pass
            self._invalidate_retrieval_index()
            logger.info("🔀 迁移结构 / schema migrated: {} rows", migrated_count)
            return tbl
        except Exception as e:
            logger.error("❌ 迁移失败 / schema migration failed: {}", e)
            return old_tbl

    def _backfill_new_columns(self, tbl):
        """Backfill missing columns (last_hit_at, hit_count) for existing columnar tables."""
        try:
            rows = tbl.search().to_list()
            try:
                self._db.drop_table("memory_backfill")
            except Exception:
                pass

            self._db.create_table("memory_backfill", data=list(_SAMPLE))
            staged = self._db.open_table("memory_backfill")
            staged.delete("key = '_init_'")

            batch: list[dict[str, Any]] = []
            patched_count = 0
            source_rows = rows or list(_SAMPLE)
            for r in source_rows:
                row = dict(r)
                row.setdefault("last_hit_at", "")
                row.setdefault("hit_count", 0)
                row.pop("_distance", None)
                row.pop("_relevance_score", None)
                row.pop("vector", None)
                batch.append(row)
                if len(batch) >= _MIGRATION_CHUNK_SIZE:
                    staged.add(batch)
                    patched_count += len(batch)
                    batch = []
            if batch:
                staged.add(batch)
                patched_count += len(batch)

            staged_rows = staged.search().to_list()
            self._db.drop_table("memory")
            tbl = self._db.create_table("memory", data=staged_rows)
            self._db.drop_table("memory_backfill")
            self._invalidate_retrieval_index()
            logger.info("🔀 补齐新列 / backfilled new columns: {} rows", patched_count)
            return tbl
        except Exception as e:
            logger.error("❌ 补齐新列失败 / backfill failed: {}", e)
            return tbl

    def _init_embedding(self, cfg: Any) -> None:
        try:
            self._embed_timeout_s = max(
                1, int(getattr(cfg, "timeout_seconds", _DEFAULT_EMBED_TIMEOUT_S))
            )
            api_key = cfg.api_key.get_secret_value()
            model_lower = cfg.model.lower()
            if "gemini" in model_lower or "models/embedding" in model_lower:
                name = cfg.model if cfg.model.startswith("models/") else f"models/{cfg.model}"
                self._embed_fn = _GeminiEmbedding(model=name, api_key=api_key)
                backend = "gemini-genai"
            else:
                from lancedb.embeddings import get_registry

                registry = get_registry()
                backend, kwargs = self._resolve_embedding_backend(registry, cfg)
                self._embed_fn = registry.get(backend).create(**kwargs)
            # Probe actual dim with a test embedding (some backends report wrong ndims)
            probe = self._compute_source_embeddings(["dim probe"])
            first = probe[0] if probe else None
            ndim = len(first) if isinstance(first, Sized) else 0
            if ndim <= 0:
                raise ValueError("embedding dimension probe returned empty vector")
            self._vec_tbl = ensure_table(
                self._db,
                "memory_vectors",
                [{"key": "_init_", "content": "", "type": "long_term", "vector": [0.0] * ndim}],
            )
            if self._vector_table_needs_rebuild(expected_dim=ndim):
                self._rebuild_vector_table(ndim)
            logger.debug("Embedding enabled: {} via {} (dim={})", cfg.model, backend, ndim)
            self._backfill_embeddings()
        except Exception as e:
            logger.warning("⚠️ 向量初始化失败 / embedding init failed: {}", e)
            self._embed_fn = None
            self._vec_tbl = None

    @staticmethod
    def _run_with_timeout(timeout_s: int, fn: Callable[[], Any]) -> Any:
        fut = MemoryStore._EMBED_EXECUTOR.submit(fn)
        try:
            return fut.result(timeout=timeout_s)
        except FuturesTimeoutError as exc:
            fut.cancel()
            raise TimeoutError(f"Embedding request timed out after {timeout_s}s") from exc

    @staticmethod
    def _is_transient_embedding_error(exc: Exception) -> bool:
        if isinstance(exc, TimeoutError | FuturesTimeoutError):
            return True
        msg = str(exc).lower()
        retry_markers = (
            "timeout",
            "timed out",
            "rate limit",
            "too many requests",
            "429",
            "503",
            "connection",
            "temporarily unavailable",
            "service unavailable",
        )
        return any(marker in msg for marker in retry_markers)

    def _call_embedding(self, fn: Callable[[], Any], *, op: str) -> Any:
        try:
            return self._run_with_timeout(self._embed_timeout_s, fn)
        except Exception as exc:
            if self._is_transient_embedding_error(exc):
                logger.debug("Embedding {} failed without hidden retry: {}", op, exc)
            raise

    def _compute_source_embeddings(self, texts: list[str]) -> list[list[float]]:
        embed_fn = self._embed_fn
        if not embed_fn:
            return []
        assert embed_fn is not None
        return self._call_embedding(
            lambda: embed_fn.compute_source_embeddings(texts),
            op="source",
        )

    def _ensure_query_embed_cache(self) -> None:
        if not hasattr(self, "_query_embed_cache"):
            self._query_embed_cache = {}
        if not hasattr(self, "_query_embed_cache_lock"):
            self._query_embed_cache_lock = threading.Lock()

    def _ensure_retrieval_state(self) -> None:
        if not hasattr(self, "_retrieval_revision"):
            self._retrieval_revision = 0
        if not hasattr(self, "_retrieval_index"):
            self._retrieval_index = None

    def _ensure_hit_stats_state(self) -> None:
        if not hasattr(self, "_pending_hit_updates"):
            self._pending_hit_updates = {}
        if not hasattr(self, "_hit_stats_lock"):
            self._hit_stats_lock = threading.Lock()
        if not hasattr(self, "_hit_flush_inflight"):
            self._hit_flush_inflight = False

    def _invalidate_retrieval_index(self) -> None:
        self._ensure_retrieval_state()
        self._retrieval_revision += 1
        self._retrieval_index = None

    def _get_retrieval_index(self) -> dict[str, Any]:
        self._ensure_retrieval_state()
        with self._store_lock:
            cached = self._retrieval_index
            if isinstance(cached, dict) and cached.get("revision") == self._retrieval_revision:
                return cached

            rows = self._tbl.search().where("type != '_init_'").limit(_BACKFILL_SCAN_LIMIT).to_list()
            row_by_key: dict[str, dict[str, Any]] = {}
            row_by_content_type: dict[tuple[str, str], dict[str, Any]] = {}
            row_tokens_by_key: dict[str, tuple[str, ...]] = {}
            row_tokens_by_content_type: dict[tuple[str, str], tuple[str, ...]] = {}
            experience_by_content: dict[str, dict[str, Any]] = {}
            experience_tokens_by_key: dict[str, tuple[str, ...]] = {}
            long_term_rows_by_category: dict[str, list[tuple[str, str, str]]] = {
                category: [] for category in MEMORY_CATEGORIES
            }
            long_term_tokens_by_category: dict[str, set[str]] = {
                category: set() for category in MEMORY_CATEGORIES
            }
            all_rows: list[dict[str, Any]] = []
            memory_rows: list[dict[str, Any]] = []
            experience_rows: list[dict[str, Any]] = []

            for raw in rows:
                row = dict(raw)
                type_ = str(row.get("type") or "")
                if not type_ or type_ == "_init_":
                    continue
                key = str(row.get("key") or "")
                content = str(row.get("content") or "")
                tokens = tuple(self._tokenize(content)) if content else ()

                all_rows.append(row)
                if key:
                    row_by_key[key] = row
                    if tokens:
                        row_tokens_by_key[key] = tokens
                if content:
                    content_type = (content, type_)
                    row_by_content_type[content_type] = row
                    if tokens:
                        row_tokens_by_content_type[content_type] = tokens

                if type_ == "experience":
                    experience_rows.append(row)
                    if content:
                        experience_by_content[content] = row
                    if key and tokens:
                        experience_tokens_by_key[key] = tokens
                    continue

                if type_ == "long_term":
                    category = str(row.get("category") or "general")
                    text = content.strip()
                    if category in long_term_rows_by_category and text:
                        long_term_rows_by_category[category].append(
                            (str(row.get("updated_at", "")), key, text)
                        )
                        if tokens:
                            long_term_tokens_by_category[category].update(tokens)
                    continue

                memory_rows.append(row)

            long_term_parts = [
                (
                    category,
                    self._join_memory_facts(
                        [
                            text
                            for _, _, text in sorted(
                                long_term_rows_by_category[category],
                                key=lambda item: (item[0], item[1]),
                            )
                        ]
                    ),
                )
                for category in MEMORY_CATEGORIES
                if long_term_rows_by_category[category]
            ]
            index = {
                "revision": self._retrieval_revision,
                "all_rows": all_rows,
                "memory_rows": memory_rows,
                "experience_rows": experience_rows,
                "row_by_key": row_by_key,
                "row_by_content_type": row_by_content_type,
                "row_tokens_by_key": row_tokens_by_key,
                "row_tokens_by_content_type": row_tokens_by_content_type,
                "experience_by_content": experience_by_content,
                "experience_tokens_by_key": experience_tokens_by_key,
                "long_term_parts": long_term_parts,
                "long_term_tokens_by_category": long_term_tokens_by_category,
            }
            self._retrieval_index = index
            return index

    @staticmethod
    def _escape_where_value(value: str) -> str:
        return value.replace("'", "''")

    def _lookup_rows_by_keys(
        self,
        keys: list[str],
        *,
        type_filter: str | None = None,
    ) -> dict[str, dict[str, Any]]:
        found: dict[str, dict[str, Any]] = {}
        for key in dict.fromkeys(str(key or "") for key in keys):
            if not key:
                continue
            key_safe = self._escape_where_value(key)
            where = f"key = '{key_safe}'"
            if type_filter:
                where = f"type = '{type_filter}' AND {where}"
            rows = self._tbl.search().where(where).limit(1).to_list()
            if rows:
                found[key] = dict(rows[0])
        return found

    def _lookup_row_by_content_type(
        self,
        content: str,
        type_: str,
    ) -> dict[str, Any] | None:
        text = str(content or "")
        kind = str(type_ or "")
        if not text or not kind:
            return None
        text_safe = self._escape_where_value(text)
        type_safe = self._escape_where_value(kind)
        rows = (
            self._tbl.search()
            .where(f"type = '{type_safe}' AND content = '{text_safe}'")
            .limit(1)
            .to_list()
        )
        return dict(rows[0]) if rows else None

    def _resolve_index_rows(
        self,
        index: dict[str, Any],
        *,
        vec_rows: list[dict[str, Any]],
        content_map_key: str,
        type_filter: str | None = None,
    ) -> tuple[dict[str, dict[str, Any]], dict[Any, dict[str, Any]]]:
        key_map = dict(index["row_by_key"])
        content_map = dict(index[content_map_key])
        missing_keys = [
            key
            for key in (str(vr.get("key", "") or "") for vr in vec_rows)
            if key and key not in key_map
        ]
        if missing_keys:
            key_map.update(self._lookup_rows_by_keys(missing_keys, type_filter=type_filter))
        return key_map, content_map

    def _patch_cached_retrieval_row(self, row: dict[str, Any]) -> None:
        self._ensure_retrieval_state()
        cached = self._retrieval_index
        if not isinstance(cached, dict) or cached.get("revision") != self._retrieval_revision:
            return

        key = str(row.get("key") or "")
        if not key:
            return
        existing = cached["row_by_key"].get(key)
        if existing is None:
            return

        row_copy = dict(row)
        type_ = str(row_copy.get("type") or "")
        content = str(row_copy.get("content") or "")
        cached["row_by_key"][key] = row_copy
        if content:
            cached["row_by_content_type"][(content, type_)] = row_copy
            if type_ == "experience":
                cached["experience_by_content"][content] = row_copy

        for bucket_name in ("all_rows", "experience_rows", "memory_rows"):
            bucket = cached[bucket_name]
            for idx, item in enumerate(bucket):
                if str(item.get("key") or "") == key:
                    bucket[idx] = row_copy
                    break

    def _retrieval_rows(
        self,
        index: dict[str, Any],
        *,
        type_filter: str | None = None,
        exclude_types: list[str] | None = None,
        limit: int = 100,
    ) -> list[dict[str, Any]]:
        if type_filter == "experience":
            rows = index["experience_rows"]
        elif type_filter is not None:
            rows = [row for row in index["all_rows"] if str(row.get("type") or "") == type_filter]
        elif exclude_types:
            excluded = set(exclude_types)
            if excluded == {"experience", "long_term"}:
                rows = index["memory_rows"]
            else:
                rows = [
                    row for row in index["all_rows"] if str(row.get("type") or "") not in excluded
                ]
        else:
            rows = index["all_rows"]
        return rows[:limit]

    def _row_tokens_from_index(
        self,
        row: dict[str, Any],
        index: dict[str, Any],
    ) -> tuple[str, ...]:
        key = str(row.get("key") or "")
        if key and key in index["row_tokens_by_key"]:
            return index["row_tokens_by_key"][key]
        content = str(row.get("content") or "")
        type_ = str(row.get("type") or "")
        content_type = (content, type_)
        if content and content_type in index["row_tokens_by_content_type"]:
            return index["row_tokens_by_content_type"][content_type]
        return tuple(self._tokenize(content))

    def _compute_query_embeddings(self, query: str) -> list[list[float]]:
        embed_fn = self._embed_fn
        if not embed_fn:
            return []
        assert embed_fn is not None
        self._ensure_query_embed_cache()
        now = time.monotonic()
        with self._query_embed_cache_lock:
            cached = self._query_embed_cache.get(query)
            if cached and cached[0] > now:
                return cached[1]
            if cached:
                self._query_embed_cache.pop(query, None)

        vectors = self._call_embedding(
            lambda: embed_fn.compute_query_embeddings(query),
            op="query",
        )
        with self._query_embed_cache_lock:
            self._query_embed_cache[query] = (now + _QUERY_EMBED_CACHE_TTL_S, vectors)
            while len(self._query_embed_cache) > _QUERY_EMBED_CACHE_MAX:
                self._query_embed_cache.pop(next(iter(self._query_embed_cache)))
        return vectors

    def _build_recall_query_context(
        self,
        query: str,
        *,
        include_vectors: bool,
    ) -> _RecallQueryContext | None:
        if self.should_skip_retrieval(query):
            return None
        query_terms = tuple(self._tokenize(query))
        query_vectors: list[list[float]] | None = None
        if include_vectors and self._embed_fn and self._vec_tbl:
            try:
                query_vectors = self._compute_query_embeddings(query)
            except Exception as e:
                logger.warning("⚠️ 查询向量失败 / query embedding failed: {}", e)
                query_vectors = []
        return _RecallQueryContext(
            query=query,
            query_terms=query_terms,
            query_term_set=frozenset(query_terms),
            query_vectors=query_vectors,
        )

    @staticmethod
    def _log_background_exception(fut: Any) -> None:
        try:
            fut.result()
        except Exception as e:
            logger.debug("memory background task skipped: {}", e)

    def _schedule_hit_stats_update(self, rows: list[dict[str, Any]]) -> None:
        self._ensure_hit_stats_state()
        should_submit = False
        with self._hit_stats_lock:
            for row in rows:
                key = str(row.get("key") or "")
                if not key:
                    continue
                pending = self._pending_hit_updates.get(key)
                hit_delta = int((pending or {}).get("_hit_delta", 0) or 0) + 1
                merged = dict(pending or {})
                merged.update(dict(row))
                merged["key"] = key
                merged["_hit_delta"] = hit_delta
                self._pending_hit_updates[key] = merged
            if not self._pending_hit_updates or self._hit_flush_inflight:
                return
            self._hit_flush_inflight = True
            should_submit = True
        if not should_submit:
            return
        fut = self._MEMORY_BG_EXECUTOR.submit(self._flush_pending_hit_stats)
        fut.add_done_callback(self._log_background_exception)

    def _flush_pending_hit_stats(self) -> None:
        self._ensure_hit_stats_state()
        while True:
            with self._hit_stats_lock:
                if not self._pending_hit_updates:
                    self._hit_flush_inflight = False
                    return
                batch = list(self._pending_hit_updates.values())
                self._pending_hit_updates = {}
            self._update_hit_stats(batch)

    def _vector_table_needs_rebuild(self, *, expected_dim: int) -> bool:
        if not self._vec_tbl:
            return False
        try:
            rows = self._vec_tbl.search().limit(3).to_list()
        except Exception:
            return True
        if not rows:
            return False
        sample = rows[0]
        if "key" not in sample:
            return True
        vec = sample.get("vector")
        current_dim = len(vec) if isinstance(vec, Sized) else 0
        if current_dim <= 0:
            return True
        return current_dim != expected_dim

    def _rebuild_vector_table(self, ndim: int) -> None:
        self._db.drop_table("memory_vectors")
        self._vec_tbl = ensure_table(
            self._db,
            "memory_vectors",
            [{"key": "_init_", "content": "", "type": "long_term", "vector": [0.0] * ndim}],
        )
        logger.info("🔁 重建向量表 / vector table rebuilt (dim={})", ndim)

    @staticmethod
    def _resolve_embedding_backend(registry: Any, cfg: Any) -> tuple[str, dict[str, Any]]:
        # LanceDB requires `$var:` references for sensitive keys, resolved via registry
        registry.set_var(_ENV_KEY, cfg.api_key.get_secret_value())
        kwargs: dict[str, Any] = {"name": cfg.model, "api_key": f"$var:{_ENV_KEY}"}
        if cfg.base_url:
            registry.set_var(_ENV_BASE, cfg.base_url)
            kwargs["base_url"] = f"$var:{_ENV_BASE}"
        if getattr(cfg, "dim", 0) > 0:
            kwargs["dim"] = cfg.dim
        return "openai", kwargs

    def _backfill_embeddings(self) -> None:
        if not self._embed_fn or not self._vec_tbl:
            return
        try:
            with self._store_lock:
                rows = self._tbl.search().where("type != '_init_'").to_list()
            main_by_key: dict[str, dict[str, Any]] = {}
            for r in rows:
                key = r.get("key", "")
                content = r.get("content", "").strip()
                type_ = r.get("type", "")
                if key and content and type_:
                    main_by_key[key] = r
            with self._store_lock:
                vec_rows = self._vec_tbl.search().to_list()
            vec_by_key = {
                r.get("key", ""): r for r in vec_rows if r.get("key") and r.get("key") != "_init_"
            }

            count = 0
            refresh_count = 0
            for key, r in main_by_key.items():
                content = r.get("content", "")
                type_ = r.get("type", "")
                existing = vec_by_key.get(key)
                if not existing:
                    self._store_vector_for_row(key=key, content=content, type_=type_)
                    count += 1
                    continue
                if existing.get("content", "") != content or existing.get("type", "") != type_:
                    self._store_vector_for_row(key=key, content=content, type_=type_)
                    refresh_count += 1
            if count:
                logger.info("🧠 补全向量 / embeddings backfilled: {} records", count)
            if refresh_count:
                logger.info("♻️ 刷新向量 / embeddings refreshed: {} records", refresh_count)
        except Exception as e:
            logger.warning("⚠️ 向量补全失败 / embedding backfill failed: {}", e)

    def _delete_vector_by_key(self, key: str) -> None:
        if not self._vec_tbl or not key:
            return
        key_safe = key.replace("'", "''")
        with self._store_lock:
            if not self._vec_tbl:
                return
            self._vec_tbl.delete(f"key = '{key_safe}'")

    def _store_vector_for_row(self, *, key: str, content: str, type_: str) -> None:
        if not self._vec_tbl or not self._embed_fn or not key or not content.strip() or not type_:
            return
        vectors = self._compute_source_embeddings([content])
        vec = vectors[0] if vectors else None
        if not isinstance(vec, Sized) or len(vec) == 0:
            raise ValueError("embedding returned empty vector")
        with self._store_lock:
            if not self._vec_tbl:
                return
            self._delete_vector_by_key(key)
            self._vec_tbl.add([{"key": key, "content": content, "type": type_, "vector": vec}])

    def _migrate_legacy(self, workspace: Path) -> None:
        mem_file = workspace / "memory" / "MEMORY.md"
        hist_file = workspace / "memory" / "HISTORY.md"
        if mem_file.exists():
            existing = self._tbl.search().where("type = 'long_term'").limit(1).to_list()
            if not existing:
                self.write_long_term(mem_file.read_text(encoding="utf-8"))
            mem_file.rename(mem_file.with_suffix(".md.migrated"))
        if hist_file.exists():
            existing = self._tbl.search().where("type = 'history'").limit(1).to_list()
            if not existing:
                for entry in hist_file.read_text(encoding="utf-8").strip().split("\n\n"):
                    if entry.strip():
                        self.append_history(entry.strip())
            hist_file.rename(hist_file.with_suffix(".md.migrated"))

    def _make_row(self, *, key: str, content: str, type_: str, **extra) -> dict[str, Any]:
        """Build a row with all required columns, filling defaults."""
        row = {
            "key": key,
            "content": content,
            "type": type_,
            "category": "",
            "quality": 0,
            "uses": 0,
            "successes": 0,
            "outcome": "",
            "deprecated": False,
            "updated_at": extra.pop("updated_at", datetime.now().isoformat()),
            "last_hit_at": extra.pop("last_hit_at", ""),
            "hit_count": extra.pop("hit_count", 0),
        }
        row.update(extra)
        return row

    @staticmethod
    def _normalize_memory_facts(content: Any, *, max_chars: int) -> list[str]:
        if max_chars <= 0:
            return []
        if isinstance(content, list):
            raw_lines = [str(item) for item in content]
        else:
            raw_lines = str(content).splitlines()

        facts: list[str] = []
        seen: set[str] = set()
        used = 0
        for raw in raw_lines:
            fact = raw.strip()
            if not fact or fact in seen:
                continue
            prefix = 1 if facts else 0
            remaining = max_chars - used - prefix
            if remaining <= 0:
                break
            if len(fact) > remaining:
                if remaining <= 1:
                    break
                fact = fact[: remaining - 1].rstrip() + "…"
            if not fact or fact in seen:
                continue
            seen.add(fact)
            facts.append(fact)
            used += len(fact) + prefix
        return facts

    @staticmethod
    def _join_memory_facts(facts: list[str]) -> str:
        return "\n".join(facts)

    @staticmethod
    def _is_legacy_long_term_key(key: str, category: str) -> bool:
        normalized = key.strip()
        return normalized in {"", "long_term", f"long_term_{category}"}

    @staticmethod
    def _long_term_fact_key(category: str, updated_at: str, index: int) -> str:
        stamp = (updated_at or datetime.now().isoformat()).replace(":", "").replace("-", "")
        stamp = stamp.replace(".", "").replace("+", "").replace("T", "_")[:24]
        return f"long_term_{category}_{stamp}_{index:04d}"

    def _migrate_long_term_facts(self) -> None:
        with self._store_lock:
            try:
                rows = self._tbl.search().where("type = 'long_term'").limit(200).to_list()
            except Exception as e:
                logger.debug("long-term fact migration skipped: {}", e)
                return

            changed = False
            for row in rows:
                category = str(row.get("category") or "general")
                key = str(row.get("key") or "")
                if not self._is_legacy_long_term_key(key, category):
                    continue
                updated_at = str(row.get("updated_at") or datetime.now().isoformat())
                facts = self._normalize_memory_facts(
                    row.get("content", ""),
                    max_chars=MEMORY_CATEGORY_CAPS.get(category, MEMORY_CATEGORY_CAPS["general"]),
                )
                key_safe = key.replace("'", "''")
                self._tbl.delete(f"type = 'long_term' AND key = '{key_safe}'")
                changed = True
                if not facts:
                    continue
                self._tbl.add(
                    [
                        self._make_row(
                            key=self._long_term_fact_key(category, updated_at, idx),
                            content=fact,
                            type_="long_term",
                            category=category,
                            updated_at=updated_at,
                        )
                        for idx, fact in enumerate(facts, start=1)
                    ]
                )
            if changed:
                self._invalidate_retrieval_index()
                logger.info("🔀 长期记忆事实化 / migrated long-term memory to fact rows")

    def _update_hit_stats(self, rows: list[dict[str, Any]]) -> None:
        """Increment hit_count and update last_hit_at for retrieved rows (best-effort)."""
        if not rows:
            return
        now = datetime.now().isoformat()
        with self._store_lock:
            try:
                row_by_key = dict(self._get_retrieval_index()["row_by_key"])
                missing_keys = [
                    key
                    for key in (str(r.get("key") or "") for r in rows)
                    if key and key not in row_by_key
                ]
                if missing_keys:
                    row_by_key.update(self._lookup_rows_by_keys(missing_keys))
                updated_count = 0
                for r in rows:
                    key = str(r.get("key") or "")
                    hit_delta = int(r.get("_hit_delta", 1) or 1)
                    if not key or hit_delta <= 0:
                        continue
                    current = row_by_key.get(key)
                    if not current:
                        continue
                    key_safe = key.replace("'", "''")
                    try:
                        self._tbl.delete(f"key = '{key_safe}'")
                        row = self._make_row(
                            key=key,
                            content=current.get("content", ""),
                            type_=current.get("type", ""),
                            category=current.get("category", ""),
                            quality=current.get("quality", 0),
                            uses=current.get("uses", 0),
                            successes=current.get("successes", 0),
                            outcome=current.get("outcome", ""),
                            deprecated=current.get("deprecated", False),
                            updated_at=current.get("updated_at", now),
                            last_hit_at=now,
                            hit_count=int(current.get("hit_count", 0) or 0) + hit_delta,
                        )
                        try:
                            self._tbl.add([row])
                            self._patch_cached_retrieval_row(row)
                            updated_count += 1
                        except Exception:
                            try:
                                self._tbl.add(
                                    [
                                        self._make_row(
                                            key=key,
                                            content=current.get("content", ""),
                                            type_=current.get("type", ""),
                                            **{
                                                k: current.get(k, v)
                                                for k, v in {
                                                    "category": "",
                                                    "quality": 0,
                                                    "uses": 0,
                                                    "successes": 0,
                                                    "outcome": "",
                                                    "deprecated": False,
                                                    "updated_at": "",
                                                    "last_hit_at": "",
                                                    "hit_count": 0,
                                                }.items()
                                            },
                                        )
                                    ]
                                )
                            except Exception:
                                logger.warning("⚠️ hit_stats: failed to restore row key={}", key)
                    except Exception as e:
                        logger.debug("hit_stats update skipped for key={}: {}", key, e)
                if updated_count:
                    logger.debug("memory hit stats updated for {} rows", updated_count)
            except Exception as e:
                logger.debug("hit_stats batch update skipped: {}", e)

    def _rerank_candidates(
        self,
        candidates: list[dict[str, Any]],
        *,
        limit: int,
        has_vector_score: bool = False,
    ) -> list[dict[str, Any]]:
        """Multi-factor rule-based rerank. No external model needed.

        Factors: semantic distance (if available), recency, quality/importance, reliability.
        """
        now = datetime.now()
        scored: list[tuple[float, dict[str, Any]]] = []
        for r in candidates:
            if r.get("deprecated"):
                continue
            # Factor 1: semantic (vector distance — lower is better, invert to score)
            if has_vector_score and "_distance" in r:
                # LanceDB distance is L2; typical range 0~2. Normalize to 0~1 score.
                semantic = max(0.0, 1.0 - r["_distance"] / 2.0)
            else:
                semantic = 0.5  # neutral when no vector score

            text_score_raw = r.get("_text_score", 0.0)
            if isinstance(text_score_raw, (int, float)) and text_score_raw > 0:
                text_signal = float(text_score_raw) / (1.0 + float(text_score_raw))
            else:
                text_signal = 0.0

            # Factor 2: recency (exponential decay)
            days_old = self._days_since(r.get("updated_at", ""), now)
            recency = exp(-days_old / 90)

            # Factor 3: importance (category weight + quality)
            cat = r.get("category") or "general"
            cat_weight = _CATEGORY_WEIGHTS.get(cat, 0.4)
            quality = r.get("quality", 3) / 5.0
            importance = 0.5 * cat_weight + 0.5 * quality

            # Factor 4: reliability (Laplace-smoothed success rate)
            # For non-experience rows (uses=0, successes=0), this yields 0.5 (neutral),
            # which is intentional — reliability only differentiates experience rows.
            reliability = self._confidence(r)

            score = (
                0.30 * semantic
                + 0.20 * text_signal
                + 0.20 * recency
                + 0.20 * importance
                + 0.10 * reliability
            )
            scored.append((score, r))

        scored.sort(key=lambda x: x[0], reverse=True)
        return [r for _, r in scored[:limit]]

    def _enrich_for_rerank(self, vec_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Cross-reference vector results with main table to get columnar metadata for rerank."""
        try:
            index = self._get_retrieval_index()
            key_map, content_map = self._resolve_index_rows(
                index,
                vec_rows=vec_rows,
                content_map_key="row_by_content_type",
            )
            enriched = []
            for vr in vec_rows:
                key = str(vr.get("key", "") or "")
                vc = str(vr.get("content", "") or "")
                vt = str(vr.get("type", "") or "")
                matched = key_map.get(key) if key else content_map.get((vc, vt))
                if matched is None and not key:
                    matched = self._lookup_row_by_content_type(vc, vt)
                    if matched is not None:
                        content_map[(vc, vt)] = matched
                if matched:
                    merged = dict(matched)
                    if "_distance" in vr:
                        merged["_distance"] = vr["_distance"]
                    enriched.append(merged)
            return enriched
        except Exception:
            return vec_rows

    # ── Long-term memory (categorized) ──

    def read_long_term(self, category: str | None = None) -> str:
        return self._long_term().read_long_term(category)

    def _read_long_term_rows_locked(self) -> list[dict[str, Any]]:
        rows = self._tbl.search().where("type = 'long_term'").limit(200).to_list()
        rows.sort(
            key=lambda row: (
                str(row.get("category") or "general"),
                str(row.get("updated_at", "")),
                str(row.get("key", "")),
            )
        )
        return rows

    def _read_long_term_fact_rows_locked(self, category: str) -> list[dict[str, Any]]:
        return [
            row
            for row in self._read_long_term_rows_locked()
            if str(row.get("category") or "general") == category and str(row.get("content", "")).strip()
        ]

    def _read_long_term_facts_locked(self, category: str) -> list[str]:
        return [str(row.get("content", "")).strip() for row in self._read_long_term_fact_rows_locked(category)]

    def list_long_term_entries(self) -> list[dict[str, str]]:
        return self._long_term().list_long_term_entries()

    def list_memory_categories(self) -> list[dict[str, Any]]:
        return self._long_term().list_memory_categories()

    def get_memory_category(self, category: str) -> dict[str, Any] | None:
        return self._long_term().get_memory_category(category)

    def exists_long_term_key(self, key: str) -> bool:
        return self._long_term().exists_long_term_key(key)

    def delete_long_term_by_key(self, key: str) -> bool:
        deleted = self._long_term().delete_long_term_by_key(key)
        if deleted:
            self._schedule_long_term_embedding()
        return deleted

    def _replace_long_term_facts_locked(self, category: str, facts: list[str]) -> bool:
        current_facts = self._read_long_term_facts_locked(category)
        if current_facts == facts:
            return False

        self._tbl.delete(f"type = 'long_term' AND category = '{category}'")
        if facts:
            updated_at = datetime.now().isoformat()
            self._tbl.add(
                [
                    self._make_row(
                        key=self._long_term_fact_key(category, updated_at, idx),
                        content=fact,
                        type_="long_term",
                        category=category,
                        updated_at=updated_at,
                    )
                    for idx, fact in enumerate(facts, start=1)
                ]
            )
        self._invalidate_retrieval_index()
        return True

    def write_long_term(self, content: str, category: str = "general") -> None:
        self._long_term().write_long_term(content, category)

    def append_memory_category(self, category: str, content: str) -> dict[str, Any] | None:
        return self._long_term().append_memory_category(category, content)

    def clear_memory_category(self, category: str) -> dict[str, Any] | None:
        return self._long_term().clear_memory_category(category)

    def write_categorized_memory(self, updates: dict[str, Any]) -> None:
        self._long_term().write_categorized_memory(updates)

    def append_history(self, entry: str) -> None:
        cleaned = entry.rstrip()
        row_key = ""
        with self._store_lock:
            ts = datetime.now().isoformat()
            row_key = f"history_{ts}"
            self._tbl.add(
                [
                    self._make_row(
                        key=row_key,
                        content=cleaned,
                        type_="history",
                        updated_at=ts,
                    )
                ]
            )
            self._invalidate_retrieval_index()
        self._embed_and_store(key=row_key, content=cleaned, type_="history")

    def _embed_long_term_aggregate(self) -> None:
        """Rebuild long_term vector from all categories combined."""
        aggregated = self.read_long_term()  # all categories
        if aggregated:
            self._embed_and_store(
                key="long_term_aggregate",
                content=aggregated,
                type_="long_term",
            )
        elif self._vec_tbl:
            try:
                with self._store_lock:
                    rows = self._tbl.search().where("type = 'long_term'").limit(200).to_list()
                    has_content = any(r.get("content", "").strip() for r in rows) if rows else False
                    if not has_content and self._vec_tbl:
                        self._delete_vector_by_key("long_term_aggregate")
            except Exception as e:
                logger.warning("⚠️ 长期向量清理失败 / long-term vector clear failed: {}", e)

    def embed_long_term_aggregate(self) -> None:
        self._embed_long_term_aggregate()

    def _schedule_long_term_embedding(self) -> None:
        if not self._embed_fn or not self._vec_tbl:
            return

        fut = self._MEMORY_BG_EXECUTOR.submit(self._embed_long_term_aggregate)

        def _log_if_failed(future) -> None:
            try:
                future.result()
            except Exception as e:
                logger.warning("⚠️ 长期向量异步更新失败 / async long-term embedding failed: {}", e)

        fut.add_done_callback(_log_if_failed)

    def _embed_and_store(self, *, key: str, content: str, type_: str) -> None:
        if not self._embed_fn or not self._vec_tbl or not content.strip():
            return
        try:
            self._store_vector_for_row(key=key, content=content, type_=type_)
        except Exception as e:
            logger.warning("⚠️ 向量写入失败 / embedding store failed: {}", e)

    @staticmethod
    def _candidate_identity(row: dict[str, Any]) -> tuple[str, str] | tuple[str, str, str]:
        key = str(row.get("key", "")).strip()
        if key:
            return ("key", key)
        return (
            "content",
            str(row.get("type", "")),
            str(row.get("content", "")),
        )

    def _merge_memory_candidates(self, *groups: list[dict[str, Any]]) -> list[dict[str, Any]]:
        merged: list[dict[str, Any]] = []
        seen: dict[tuple[str, ...], dict[str, Any]] = {}
        for group in groups:
            for row in group:
                identity = self._candidate_identity(row)
                if identity in seen:
                    existing = seen[identity]
                    if "_text_score" in row and "_text_score" not in existing:
                        existing["_text_score"] = row["_text_score"]
                    if "_distance" in row and "_distance" not in existing:
                        existing["_distance"] = row["_distance"]
                    continue
                seen[identity] = row
                merged.append(row)
        return merged

    def _fallback_text_candidates(
        self,
        query: str,
        type_filter: str | None = None,
        limit: int = 5,
        exclude_types: list[str] | None = None,
        query_terms: tuple[str, ...] | None = None,
    ) -> list[dict[str, Any]]:
        try:
            index = self._get_retrieval_index()
            rows = self._retrieval_rows(
                index,
                type_filter=type_filter,
                exclude_types=exclude_types,
                limit=100,
            )
            if not rows:
                return []

            ranked_terms = list(query_terms) if query_terms is not None else self._tokenize(query)
            ranked = self._bm25_rank(
                query,
                rows,
                query_terms=ranked_terms,
                doc_tokens=[self._row_tokens_from_index(row, index) for row in rows],
            )
            if ranked:
                out: list[dict[str, Any]] = []
                for score, row in ranked[:limit]:
                    if not row.get("content"):
                        continue
                    item = dict(row)
                    item["_text_score"] = score
                    out.append(item)
                return out

            return [dict(r) for r in rows[:limit] if r.get("content")]
        except Exception as e:
            logger.warning("⚠️ 文本回退失败 / text fallback failed: {}", e)
            return []

    def search_memory(
        self,
        query: str,
        limit: int = 5,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> list[str]:
        return self._recall().search_memory(query, limit=limit, query_context=query_context)

    # ── Experience (columnar) ──
    def append_experience(
        self,
        task: str,
        outcome: str,
        lessons: str,
        quality: int = 3,
        category: str = "general",
        keywords: str = "",
        reasoning_trace: str = "",
    ) -> None:
        self._experience().append_experience(
            task,
            outcome,
            lessons,
            quality=quality,
            category=category,
            keywords=keywords,
            reasoning_trace=reasoning_trace,
        )

    def _confidence(self, row: dict[str, Any]) -> float:
        uses = row.get("uses", 0)
        successes = row.get("successes", 0)
        return (successes + 1) / (uses + 2)  # Laplace smoothing

    def search_experience(
        self,
        query: str,
        limit: int = 3,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> list[str]:
        return self._experience().search_experience(
            query,
            limit=limit,
            query_context=query_context,
        )

    @staticmethod
    def _experience_preview(task: str, lessons: str) -> str:
        base = f"{task} — {lessons}" if task and lessons else (task or lessons)
        cleaned = base.replace("\n", " ").strip()
        if len(cleaned) <= 180:
            return cleaned
        return cleaned[:179].rstrip() + "…"

    def _experience_row_to_item(self, row: dict[str, Any]) -> dict[str, Any]:
        content = str(row.get("content") or "")
        task = self._extract_field(content, "Task")
        lessons = self._extract_field(content, "Lessons")
        keywords = self._extract_field(content, "Keywords")
        trace = self._extract_field(content, "Trace")
        return {
            "key": str(row.get("key") or ""),
            "task": task,
            "lessons": lessons,
            "keywords": keywords,
            "trace": trace,
            "content": content,
            "preview": self._experience_preview(task, lessons),
            "category": str(row.get("category") or "general"),
            "outcome": str(row.get("outcome") or ""),
            "quality": int(row.get("quality", 0) or 0),
            "uses": int(row.get("uses", 0) or 0),
            "successes": int(row.get("successes", 0) or 0),
            "deprecated": bool(row.get("deprecated", False)),
            "updated_at": str(row.get("updated_at", "")),
            "hit_count": int(row.get("hit_count", 0) or 0),
            "last_hit_at": str(row.get("last_hit_at", "")),
        }

    def list_experience_items(
        self,
        query: str = "",
        *,
        category: str = "",
        outcome: str = "",
        deprecated: bool | None = None,
        min_quality: int = 0,
        sort_by: str = "updated_desc",
        limit: int = 200,
    ) -> list[dict[str, Any]]:
        return self._experience().list_experience_items(
            query,
            category=category,
            outcome=outcome,
            deprecated=deprecated,
            min_quality=min_quality,
            sort_by=sort_by,
            limit=limit,
        )

    def get_experience_item(self, key: str) -> dict[str, Any] | None:
        return self._experience().get_experience_item(key)

    def set_experience_deprecated(self, key: str, deprecated: bool) -> bool:
        return self._experience().set_experience_deprecated(key, deprecated)

    def delete_experience(self, key: str) -> bool:
        return self._experience().delete_experience(key)

    def promote_experience_to_memory(
        self, key: str, category: str = "project"
    ) -> dict[str, Any] | None:
        return self._experience().promote_experience_to_memory(key, category)

    def _fetch_experience_candidates(
        self,
        query_context: _RecallQueryContext,
        fetch: int,
    ) -> list[dict[str, Any]]:
        """Fetch experience candidates via vector or BM25 fallback."""
        if self._embed_fn and self._vec_tbl and query_context.query_vectors:
            try:
                vec = query_context.query_vectors[0] if query_context.query_vectors else None
                if not isinstance(vec, Sized) or len(vec) == 0:
                    raise ValueError("query embedding returned empty vector")
                with self._store_lock:
                    vec_rows = (
                        self._vec_tbl.search(vec)
                        .where("type = 'experience'")
                        .limit(fetch)
                        .to_list()
                    )
                if vec_rows:
                    if enriched := self._enrich_vector_results(vec_rows):
                        return enriched
            except Exception as e:
                logger.warning("⚠️ 经验检索失败 / experience search failed: {}", e)
        try:
            return self._fallback_text_candidates(
                query_context.query,
                type_filter="experience",
                limit=fetch,
                query_terms=query_context.query_terms,
            )
        except Exception as e:
            logger.warning("⚠️ 回退检索失败 / fallback search failed: {}", e)
            return []

    def _enrich_vector_results(self, vec_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Cross-reference vector results with main table to get columnar metadata."""
        try:
            index = self._get_retrieval_index()
            key_map, content_map = self._resolve_index_rows(
                index,
                vec_rows=vec_rows,
                content_map_key="experience_by_content",
                type_filter="experience",
            )
            enriched = []
            for vr in vec_rows:
                key = str(vr.get("key", "") or "")
                vc = str(vr.get("content", "") or "")
                matched = key_map.get(key) if key else content_map.get(vc)
                if matched is None and not key:
                    matched = self._lookup_row_by_content_type(vc, "experience")
                    if matched is not None:
                        content_map[vc] = matched
                if matched:
                    merged = dict(matched)
                    if "_distance" in vr:
                        merged["_distance"] = vr["_distance"]
                    enriched.append(merged)
            return enriched
        except Exception:
            return []

    @staticmethod
    def _extract_field(content: str, prefix: str) -> str:
        """Extract 'Value' from 'Prefix: Value' line in content."""
        for line in content.split("\n"):
            if line.startswith(f"{prefix}:"):
                return line.split(":", 1)[1].strip()
        return ""

    # ── Migration helpers (parse old [Field] format) ──
    @staticmethod
    def _parse_field_int(content: str, field: str, default: int = 0) -> int:
        for line in content.split("\n"):
            if line.startswith(f"[{field}]"):
                try:
                    return int(line.split("]", 1)[1].strip())
                except (ValueError, IndexError):
                    pass
        return default

    @staticmethod
    def _parse_field_str(content: str, field: str) -> str:
        for line in content.split("\n"):
            if line.startswith(f"[{field}]"):
                return line.split("]", 1)[1].strip()
        return ""

    @staticmethod
    def _days_since(ts: str, now: datetime) -> float:
        if not ts:
            return 30.0
        try:
            return max(0.0, (now - datetime.fromisoformat(ts)).total_seconds() / 86400)
        except (ValueError, TypeError):
            return 30.0

    # ── Experience mutation helpers (columnar) ──
    def _update_experience(self, r: dict[str, Any], **updates) -> None:
        """Delete old row and re-insert with updated columns."""
        if key := r.get("key"):
            self._tbl.delete(f"key = '{key}'")
            row = self._make_row(
                key=key,
                content=r.get("content", ""),
                type_="experience",
                category=r.get("category", ""),
                quality=r.get("quality", 3),
                uses=r.get("uses", 0),
                successes=r.get("successes", 0),
                outcome=r.get("outcome", ""),
                deprecated=r.get("deprecated", False),
                updated_at=datetime.now().isoformat(),
            )
            row.update(updates)
            self._tbl.add([row])
            self._invalidate_retrieval_index()

    def _match_experience_rows(self, task_desc: str, threshold: float) -> list[dict[str, Any]]:
        """Find experience rows matching task description by keyword overlap."""
        rows = self._tbl.search().where("type = 'experience'").limit(100).to_list()
        keywords = {w.lower() for w in task_desc.split() if len(w) >= 2}
        if not keywords:
            return []
        results = []
        for r in rows:
            if r.get("deprecated"):
                continue
            content = (r.get("content") or "").lower()
            hits = sum(1 for kw in keywords if kw in content)
            if hits >= len(keywords) * threshold:
                results.append(r)
        return results

    def _mutate_experiences(
        self,
        task_desc: str,
        threshold: float,
        mutator: Callable[[dict[str, Any]], dict[str, Any] | None],
        action: str,
    ) -> int:
        with self._store_lock:
            try:
                count = 0
                for r in self._match_experience_rows(task_desc, threshold=threshold):
                    updates = mutator(r)
                    if updates:
                        self._update_experience(r, **updates)
                        count += 1
                if count:
                    logger.info("📝 经验变更 / {}: {} for {}", action, count, task_desc[:60])
                return count
            except Exception as e:
                logger.warning("⚠️ 经验变更失败 / {} failed: {}", action, e)
                return 0

    def deprecate_similar(self, task_desc: str) -> int:
        return self._experience().deprecate_similar(task_desc)

    def boost_experience(self, task_desc: str, delta: int = 1) -> int:
        return self._experience().boost_experience(task_desc, delta=delta)

    def record_reuse(self, task_desc: str, success: bool) -> int:
        return self._experience().record_reuse(task_desc, success)

    def cleanup_stale(self, max_deprecated_days: int = 30, max_low_quality_days: int = 90) -> int:
        return self._experience().cleanup_stale(
            max_deprecated_days=max_deprecated_days,
            max_low_quality_days=max_low_quality_days,
        )

    def get_merge_candidates(self, min_count: int = 5) -> list[list[str]]:
        return self._experience().get_merge_candidates(min_count=min_count)

    def replace_merged(
        self,
        old_entries: list[str],
        merged_content: str,
        *,
        category: str = "general",
        quality: int = 4,
    ) -> None:
        self._experience().replace_merged(
            old_entries,
            merged_content,
            category=category,
            quality=quality,
        )

    @staticmethod
    def _tokenize(text: str) -> list[str]:
        """Split text into tokens. Handles both space-separated and CJK text.

        For space-separated languages: lowercase, strip punctuation, filter short tokens.
        For CJK characters: extract character bigrams per segment (no cross-boundary).
        For mixed tokens (e.g. 'Python编程'): extract both Latin and CJK parts.
        """
        tokens: list[str] = []
        for raw in text.split():
            if len(raw) < 2:
                continue
            w = raw.lower().strip(".,;:!?()[]{}\"'`~")
            if len(w) < 2:
                continue
            has_cjk = any(
                "\u4e00" <= c <= "\u9fff" or "\u3040" <= c <= "\u30ff" or "\uac00" <= c <= "\ud7af"
                for c in w
            )
            if not has_cjk:
                tokens.append(w)
                continue
            # Mixed/CJK token: extract both Latin and CJK parts per segment
            seg_cjk: list[str] = []
            latin_buf: list[str] = []
            for c in w:
                if (
                    "\u4e00" <= c <= "\u9fff"
                    or "\u3040" <= c <= "\u30ff"
                    or "\uac00" <= c <= "\ud7af"
                ):
                    if latin_buf:
                        latin = "".join(latin_buf)
                        if len(latin) >= 2:
                            tokens.append(latin)
                        latin_buf = []
                    seg_cjk.append(c)
                else:
                    latin_buf.append(c)
            if latin_buf:
                latin = "".join(latin_buf)
                if len(latin) >= 2:
                    tokens.append(latin)
            # Bigrams per segment (no cross-boundary with other tokens)
            for i in range(len(seg_cjk) - 1):
                tokens.append(seg_cjk[i] + seg_cjk[i + 1])
            if len(seg_cjk) <= 4:
                tokens.extend(seg_cjk)
        return tokens

    @staticmethod
    def _normalize_low_information_query(query: str) -> str:
        return (
            query.lower()
            .replace(" ", "")
            .replace("\n", "")
            .strip(".,;:!?()[]{}\"'`~。？！；：、，…")
        )

    def should_skip_retrieval(self, query: str) -> bool:
        normalized = self._normalize_low_information_query(query)
        return not normalized or normalized in _LOW_INFORMATION_QUERIES

    @staticmethod
    def _bm25_rank(
        query: str,
        docs: list[dict[str, Any]],
        *,
        k1: float = 1.2,
        b: float = 0.75,
        query_terms: list[str] | None = None,
        doc_tokens: list[list[str] | tuple[str, ...]] | None = None,
    ) -> list[tuple[float, dict[str, Any]]]:
        """Rank documents by BM25 score. Returns (score, row) pairs sorted desc."""
        query_terms = query_terms if query_terms is not None else MemoryStore._tokenize(query)
        if not query_terms or not docs:
            return []
        doc_tokens = (
            doc_tokens
            if doc_tokens is not None
            else [MemoryStore._tokenize(d.get("content") or "") for d in docs]
        )
        total_len = sum(len(t) for t in doc_tokens)
        avgdl = total_len / len(doc_tokens) if doc_tokens else 1.0
        n_docs = len(docs)
        df: dict[str, int] = {}
        for qt in query_terms:
            df[qt] = sum(1 for tokens in doc_tokens if qt in tokens)
        scored: list[tuple[float, dict[str, Any]]] = []
        for i, tokens in enumerate(doc_tokens):
            if not tokens:
                continue
            dl = len(tokens)
            tf_map: dict[str, int] = {}
            for t in tokens:
                tf_map[t] = tf_map.get(t, 0) + 1
            score = 0.0
            for qt in query_terms:
                tf = tf_map.get(qt, 0)
                if tf == 0:
                    continue
                n_qt = df.get(qt, 0)
                idf = log((n_docs - n_qt + 0.5) / (n_qt + 0.5) + 1.0)
                score += idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl))
            if score > 0:
                scored.append((score, docs[i]))
        scored.sort(key=lambda x: x[0], reverse=True)
        return scored

    def _fallback_text_search(
        self,
        query: str,
        type_filter: str | None = None,
        limit: int = 5,
        exclude_types: list[str] | None = None,
    ) -> list[str]:
        rows = self._fallback_text_candidates(
            query,
            type_filter=type_filter,
            limit=limit,
            exclude_types=exclude_types,
        )
        return [r["content"] for r in rows if r.get("content")]

    def _collect_long_term_parts(
        self, *, query_tokens: set[str] | None = None
    ) -> list[tuple[str, str]]:
        index = self._get_retrieval_index()
        parts = list(index["long_term_parts"])
        if query_tokens is None:
            return parts
        tokens_by_category = index["long_term_tokens_by_category"]
        return [
            (category, content)
            for category, content in parts
            if query_tokens & tokens_by_category.get(category, set())
        ]

    @staticmethod
    def _format_long_term_parts(parts: list[tuple[str, str]], max_chars: int | None = None) -> str:
        if not parts:
            return ""

        header = "## Long-term Memory\n"
        if max_chars is None:
            result = "\n".join(f"[{cat}] {content}" for cat, content in parts)
            return f"{header}{result}"

        if max_chars <= len(header):
            return ""

        body_budget = max_chars - len(header)
        prefix_overhead = sum(len(f"[{cat}] ") for cat, _ in parts) + max(0, len(parts) - 1)
        usable = body_budget - prefix_overhead
        if usable <= 0:
            return ""

        total_raw = sum(len(content) for _, content in parts)
        budgeted: list[str] = []
        for cat, content in parts:
            if total_raw <= usable:
                budgeted.append(f"[{cat}] {content}")
                continue
            share = int(usable * len(content) / total_raw)
            if share <= 0:
                continue
            if len(content) > share:
                if share <= 1:
                    continue
                content = content[: share - 1] + "…"
            budgeted.append(f"[{cat}] {content}")

        result = "\n".join(budgeted)
        return f"{header}{result}" if budgeted else ""

    def get_memory_context(self, max_chars: int | None = None) -> str:
        return self._long_term().get_memory_context(max_chars=max_chars)

    def get_relevant_memory_context(
        self,
        query: str,
        max_chars: int | None = None,
        *,
        query_context: _RecallQueryContext | None = None,
    ) -> str:
        return self._long_term().get_relevant_memory_context(
            query,
            max_chars=max_chars,
            query_context=query_context,
        )

    # ── Explicit memory operations (for tools) ──

    def remember(self, content: str, category: str = "general") -> str:
        return self._long_term().remember(content, category)

    def forget(self, query: str) -> str:
        return self._long_term().forget(query)

    def update_memory(self, category: str, content: str) -> str:
        return self._long_term().update_memory(category, content)

    def recall(
        self,
        query: str,
        *,
        related_limit: int = 5,
        experience_limit: int = 3,
        long_term_chars: int | None = None,
        include_long_term: bool = True,
    ) -> RecallBundle:
        return self._recall().recall(
            query,
            related_limit=related_limit,
            experience_limit=experience_limit,
            long_term_chars=long_term_chars,
            include_long_term=include_long_term,
        )
