"""认知数据库 — 独立 cognitive.db，并发安全

与 Database (knowledge.db) 完全独立：
- 独立文件、独立连接、独立生命周期
- WAL + busy_timeout + RLock 三层并发保障
- 手动 FTS5 同步（含 CJK 分词）
"""

from __future__ import annotations

import json
import logging
import re
import sqlite3
import threading
from pathlib import Path

# raw_events payload 截断上限（#145 Phase A error-aware 策略）
MAX_PAYLOAD_SIZE = 4096       # 默认 4KB
MAX_ERROR_PAYLOAD_SIZE = 8192  # 错误堆栈 8KB

from ..db import _SUPPORTS_CONTENTLESS_DELETE, _blob_to_floats, _floats_to_blob, tokenize_for_fts5
from ..resilience import safe_db_write, safe_db_transaction

logger = logging.getLogger(__name__)


# ── 分层配置 — category → 层级 + 默认衰减率 ──
#
# L3b: 世界观层 — 永不衰减的约束和偏好
# L3a: 战略模式 — 跨场景归纳的规律，月级衰减
# L2:  战术记忆 — 具体问题的解法，周级衰减（访问越多衰减越慢）
#
# 衰减公式（Ebbinghaus）: confidence * EXP(-decay_rate * factor / (1 + access_count))
# 来源: MemoryBank 验证有效 — 访问越多，S 越大，衰减越慢

LAYER_CONFIG: dict[str, dict] = {
    # L3b 世界观（不衰减）
    "constraint":   {"decay_rate": 0.0,   "layer": "L3b"},
    "preference":   {"decay_rate": 0.0,   "layer": "L3b"},
    "worldview":    {"decay_rate": 0.0,   "layer": "L3b"},
    # L3a 战略模式（月级衰减）
    "pattern":      {"decay_rate": 0.002, "layer": "L3a"},
    # L2 战术记忆
    "decision":     {"decay_rate": 0.005, "layer": "L2"},
    "root_cause":   {"decay_rate": 0.005, "layer": "L2"},
    "tradeoff":     {"decay_rate": 0.005, "layer": "L2"},
    "revert":       {"decay_rate": 0.008, "layer": "L2"},
    "code":         {"decay_rate": 0.01,  "layer": "L2"},
    "architecture": {"decay_rate": 0.01,  "layer": "L2"},
    "config":       {"decay_rate": 0.01,  "layer": "L2"},
}

# 兼容集合: 所有 LAYER_CONFIG 的 key + 历史遗留 category
VALID_CATEGORIES = frozenset(LAYER_CONFIG.keys())


def get_layer(category: str) -> str:
    """返回 category 对应的层级（L3b/L3a/L2），默认 L2"""
    cfg = LAYER_CONFIG.get(category)
    return cfg["layer"] if cfg else "L2"


def get_effective_layer(fact: dict) -> str:
    """返回 fact 的有效层级 — 独立 layer 字段优先，否则从 category 推导

    layer 字段解耦了 "是什么"(category) 和 "多重要"(layer)：
    - NULL: 从 LAYER_CONFIG[category] 推导（默认行为）
    - 有值: 已被 TAU_PROMOTE 升级，使用升级后的 layer
    """
    layer = fact.get("layer")
    if layer:
        return layer
    return get_layer(fact.get("category", "code"))


def get_default_decay_rate(category: str) -> float:
    """返回 category 对应的默认衰减率"""
    cfg = LAYER_CONFIG.get(category)
    return cfg["decay_rate"] if cfg else 0.01


# SQL 片段：计算 effective layer（从 LAYER_CONFIG 动态生成，保持 Python/SQL 同步）
def _build_effective_layer_sql() -> str:
    """从 LAYER_CONFIG 动态生成 CASE WHEN SQL，避免硬编码与 LAYER_CONFIG 不同步"""
    # 按 layer 分组 category
    layer_cats: dict[str, list[str]] = {}
    for cat, cfg in LAYER_CONFIG.items():
        layer_cats.setdefault(cfg["layer"], []).append(cat)

    whens = ["WHEN layer IS NOT NULL THEN layer"]
    for layer_name in ("L3b", "L3a"):  # 只需要非默认层
        cats = layer_cats.get(layer_name, [])
        if cats:
            in_list = ", ".join(f"'{c}'" for c in cats)
            whens.append(f"WHEN category IN ({in_list}) THEN '{layer_name}'")
    whens.append("ELSE 'L2'")

    return "CASE\n        " + "\n        ".join(whens) + "\n    END"

_EFFECTIVE_LAYER_SQL = _build_effective_layer_sql()

# ── 预编译正则（summarize_raw_event 辅助函数用，避免每次调用重编译）──
_RE_DEF_CLASS = re.compile(r'(?:def|class)\s+(\w+)')
_RE_IMPORT = re.compile(r'(?:from|import)\s+([\w.]+)')
_RE_ASSIGN = re.compile(r'^(\w+)\s*=', re.MULTILINE)
_RE_PASSED = re.compile(r'(\d+)\s*passed')
_RE_FAILED = re.compile(r'(\d+)\s*failed')
_RE_ERRORS = re.compile(r'(\d+)\s*error', re.IGNORECASE)


def _is_error_output(text: str) -> bool:
    """检测是否为错误堆栈/失败输出

    注意: 优先级高于 _is_test_output — truncate_payload 先检查 error 再检查 test。
    关键词使用带分隔符的模式避免误匹配（如 "error_handler.py", "No errors found"）。
    """
    head = text[:200].lower()
    return any(kw in head for kw in (
        "traceback", "exception", "failed:", "fatal",
        "panic", "segfault", "errno",
        "error:", "error ", "error\n",
    ))


def _is_test_output(text: str) -> bool:
    """检测是否为测试结果输出

    注意: 优先级低于 _is_error_output — truncate_payload 先检查 error。
    测试输出包含 "failed" 时，若同时有 traceback/exception 会被 error 分支捕获。
    """
    head = text[:500].lower()
    return any(kw in head for kw in (
        "passed", "failed", "errors", "pytest", "test result",
        "tests run", "failures",
    ))


def _truncate_head_tail(text: str, head: int, tail: int) -> str:
    """保留头部和尾部，中间截断"""
    if len(text) <= head + tail:
        return text
    return text[:head] + f"\n... ({len(text) - head - tail} chars truncated) ...\n" + text[-tail:]


def truncate_payload(payload: dict) -> str:
    """Error-aware 智能截断 payload (#145 Phase A)

    截断策略：
    - 错误堆栈：保留完整（≤8KB）
    - 测试输出：head 2KB + tail 2KB
    - 其他：前 4KB（默认行为）

    tool_response 统一转为 str 后再判断截断，
    确保存储类型一致（始终为 str）。
    保留 tool_name/tool_input 完整（通常 <1KB）。
    """
    raw = payload.get("tool_response", "")
    text = str(raw) if not isinstance(raw, str) else raw
    # 统一存储为 str — 消除 list/dict/int 等非字符串类型歧义
    payload = {**payload, "tool_response": text}

    if len(text) <= MAX_PAYLOAD_SIZE:
        pass  # 不需要截断
    elif _is_error_output(text):
        # 错误堆栈：保留更多（8KB），超出仍截断
        if len(text) > MAX_ERROR_PAYLOAD_SIZE:
            payload["tool_response"] = text[:MAX_ERROR_PAYLOAD_SIZE]
            payload["_truncated"] = True
            payload["_original_size"] = len(text)
    elif _is_test_output(text):
        # 测试输出：head + tail（头部有摘要，尾部有失败详情）
        payload["tool_response"] = _truncate_head_tail(text, 2048, 2048)
        payload["_truncated"] = True
        payload["_original_size"] = len(text)
    else:
        # 默认：前 4KB
        payload["tool_response"] = text[:MAX_PAYLOAD_SIZE]
        payload["_truncated"] = True
        payload["_original_size"] = len(text)

    return json.dumps(payload, ensure_ascii=False)


def summarize_raw_event(tool_name: str | None, payload: dict) -> str:
    """L0 纯规则生成事件摘要 — token bag + identifiers (#145 Phase A)

    格式: keyword-dense，保留最小语法（→ 表变更方向）
    用途: FTS5 索引、session_review 事件索引、breadcrumbs
    """
    if not tool_name:
        # UserPromptSubmit: payload 含 prompt 字段
        prompt = payload.get("prompt", "") if isinstance(payload, dict) else ""
        if prompt:
            return prompt[:200]
        return ""

    parts: list[str] = []

    if tool_name in ("Edit", "MultiEdit"):
        fp = payload.get("tool_input", {}).get("file_path", "")
        if fp:
            parts.append(fp.split("/")[-1] if "/" in fp else fp)
        old = payload.get("tool_input", {}).get("old_string", "")
        new = payload.get("tool_input", {}).get("new_string", "")
        if old and new:
            # 提取变更的标识符（def/class/import）
            _add_diff_identifiers(parts, old, new)
    elif tool_name == "Write":
        fp = payload.get("tool_input", {}).get("file_path", "")
        if fp:
            fname = fp.split("/")[-1] if "/" in fp else fp
            parts.append(f"+{fname}")
        content = payload.get("tool_input", {}).get("content", "")
        if content:
            _add_content_identifiers(parts, content)
    elif tool_name == "Bash":
        cmd = payload.get("tool_input", {}).get("command", "")
        if cmd:
            parts.append(cmd[:80])
        resp = str(payload.get("tool_response", ""))
        if _is_error_output(resp):
            parts.append("[ERROR]")
        elif _is_test_output(resp):
            # 提取测试结果摘要
            _add_test_summary(parts, resp)
    elif tool_name == "Read":
        fp = payload.get("tool_input", {}).get("file_path", "")
        if fp:
            parts.append(f"read:{fp.split('/')[-1] if '/' in fp else fp}")
    elif tool_name in ("Grep", "Glob"):
        query = payload.get("tool_input", {}).get("pattern", "")
        if query:
            parts.append(f"search:{query[:60]}")

    return " ".join(parts)[:500]


def _add_diff_identifiers(parts: list[str], old: str, new: str) -> None:
    """从 old/new 提取变更的 def/class/import 标识符"""
    # 提取函数/类定义
    old_defs = set(_RE_DEF_CLASS.findall(old))
    new_defs = set(_RE_DEF_CLASS.findall(new))
    for d in new_defs - old_defs:
        parts.append(f"+{d}")
    for d in old_defs - new_defs:
        parts.append(f"-{d}")
    # 提取 import 变更
    old_imports = set(_RE_IMPORT.findall(old))
    new_imports = set(_RE_IMPORT.findall(new))
    for i in new_imports - old_imports:
        parts.append(f"+import:{i}")
    # 提取变量/常量赋值
    old_assigns = set(_RE_ASSIGN.findall(old))
    new_assigns = set(_RE_ASSIGN.findall(new))
    for a in new_assigns - old_assigns:
        parts.append(f"+{a}")


def _add_content_identifiers(parts: list[str], content: str) -> None:
    """从新文件 content 提取顶层 def/class"""
    for m in _RE_DEF_CLASS.findall(content[:2000]):
        parts.append(m)


_RE_FAILED_TEST = re.compile(r'FAILED\s+(\S+?)(?:::|$)', re.MULTILINE)


def _add_test_summary(parts: list[str], output: str) -> None:
    """从测试输出提取通过/失败数 + 首个失败测试名"""
    m = _RE_PASSED.search(output)
    if m:
        parts.append(f"{m.group(1)}passed")
    m = _RE_FAILED.search(output)
    if m:
        parts.append(f"{m.group(1)}failed")
    m = _RE_ERRORS.search(output)
    if m:
        parts.append(f"{m.group(1)}errors")
    # 提取首个失败测试名（如 "FAILED tests/test_foo.py::test_bar"）
    m = _RE_FAILED_TEST.search(output[:1000])
    if m:
        parts.append(f"fail:{m.group(1)}")


class CogDatabase:
    """认知数据库 — 独立 SQLite 文件，并发安全

    并发模型：
    - MCP Server 进程：长驻连接，Claude 调 cog_save_fact 时写入
    - observe hook 进程：短连接，async 后台写入
    - maintenance 进程：独立进程，session 结束后批量写入
    """

    # 常见 embedding 模型维度白名单（L1 校验用）
    _KNOWN_DIMS = frozenset({128, 256, 384, 512, 768, 1024, 1536, 3072, 4096})

    def __init__(self, db_path: Path | str, embedding_dim: int = 768):
        self._db_path = str(db_path)
        if "Config(" in self._db_path:
            raise ValueError(f"db_path 收到了 Config 对象而非路径: {self._db_path[:80]}...")
        # ── L1 事前校验：embedding_dim 合理性 ──
        if not isinstance(embedding_dim, int) or embedding_dim <= 0:
            logger.error(
                "embedding_dim 非法 (%s, type=%s)，回退到 384",
                embedding_dim, type(embedding_dim).__name__,
            )
            embedding_dim = 384
        if embedding_dim not in self._KNOWN_DIMS:
            logger.warning(
                "embedding_dim=%d 不在已知维度列表 %s，可能配置错误",
                embedding_dim, sorted(self._KNOWN_DIMS),
            )
        self._embedding_dim = embedding_dim
        self._conn: sqlite3.Connection | None = None
        self._vec_loaded = False
        self._conn_lock = threading.RLock()

    def connect(self) -> None:
        """打开数据库连接并初始化"""
        parent = Path(self._db_path).parent
        try:
            parent.mkdir(parents=True, exist_ok=True)
        except OSError:
            pass

        self._conn = sqlite3.connect(
            self._db_path, timeout=10.0, check_same_thread=False,
        )
        self._conn.row_factory = sqlite3.Row
        self._conn.isolation_level = "IMMEDIATE"  # 写事务立即获取锁，busy_timeout 才生效
        self._conn.execute("PRAGMA journal_mode=WAL")
        self._conn.execute("PRAGMA foreign_keys=ON")
        self._conn.execute("PRAGMA busy_timeout=15000")
        self._conn.execute("PRAGMA synchronous=NORMAL")

        self._load_vec_extension()
        self._init_schema()

    def _load_vec_extension(self) -> None:
        """加载 sqlite-vec 扩展"""
        if self._conn is None:
            return
        try:
            import sqlite_vec
            self._conn.enable_load_extension(True)
            sqlite_vec.load(self._conn)
            self._conn.enable_load_extension(False)
            self._vec_loaded = True
        except Exception:
            self._vec_loaded = False

    def _init_schema(self) -> None:
        """初始化认知表 — 幂等"""
        if self._conn is None:
            return

        contentless_opt = "contentless_delete=1," if _SUPPORTS_CONTENTLESS_DELETE else ""

        with self._conn:
            # 认知事实表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS facts (
                    id              INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id      TEXT,
                    collection      TEXT NOT NULL,
                    assertion       TEXT NOT NULL,
                    category        TEXT DEFAULT 'code',
                    scope_files     TEXT DEFAULT '[]',
                    scope_modules   TEXT DEFAULT '[]',
                    scope_entities  TEXT DEFAULT '[]',
                    confidence      REAL NOT NULL DEFAULT 0.9,
                    decay_rate      REAL NOT NULL DEFAULT 0.01,
                    source          TEXT DEFAULT 'claude',
                    scope           TEXT DEFAULT 'project',
                    status          TEXT NOT NULL DEFAULT 'active',
                    superseded_by   INTEGER,
                    invalidated_reason TEXT,
                    access_count    INTEGER DEFAULT 0,
                    last_accessed   TEXT,
                    last_validated  TEXT,
                    embedding       BLOB,
                    created_at      TEXT DEFAULT (datetime('now')),
                    updated_at      TEXT DEFAULT (datetime('now'))
                )
            """)

            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_facts_collection ON facts(collection)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_facts_status ON facts(status)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_facts_session ON facts(session_id)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_facts_no_embedding "
                "ON facts(collection) WHERE embedding IS NULL AND status='active'"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(scope)"
            )

            # FTS5 全文索引（contentless + 手动同步，支持 CJK 分词）
            fts_exists = self._conn.execute(
                "SELECT 1 FROM sqlite_master WHERE type='table' AND name='facts_fts'"
            ).fetchone()
            if not fts_exists:
                if _SUPPORTS_CONTENTLESS_DELETE:
                    self._conn.execute("""
                        CREATE VIRTUAL TABLE facts_fts USING fts5(
                            assertion, scope_files, scope_modules,
                            content='',
                            contentless_delete=1
                        )
                    """)
                else:
                    self._conn.execute("""
                        CREATE VIRTUAL TABLE facts_fts USING fts5(
                            assertion, scope_files, scope_modules,
                            content=''
                        )
                    """)

            # 心智模型表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS mental_models (
                    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
                    collection          TEXT NOT NULL,
                    subject             TEXT NOT NULL,
                    scope               TEXT NOT NULL,
                    understanding_level REAL DEFAULT 0.0,
                    components          TEXT DEFAULT '[]',
                    causal_chains       TEXT DEFAULT '[]',
                    known_gaps          TEXT DEFAULT '[]',
                    open_questions      TEXT DEFAULT '[]',
                    based_on_facts      TEXT DEFAULT '[]',
                    depends_on_models   TEXT DEFAULT '[]',
                    revision            INTEGER DEFAULT 1,
                    status              TEXT DEFAULT 'active',
                    created_at          TEXT DEFAULT (datetime('now')),
                    updated_at          TEXT DEFAULT (datetime('now')),
                    UNIQUE(collection, scope)
                )
            """)

            # 策略表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS tactics (
                    id                  INTEGER PRIMARY KEY AUTOINCREMENT,
                    collection          TEXT,
                    trigger_pattern     TEXT NOT NULL,
                    trigger_keywords    TEXT DEFAULT '[]',
                    steps               TEXT NOT NULL,
                    times_used          INTEGER DEFAULT 0,
                    times_succeeded     INTEGER DEFAULT 0,
                    success_rate        REAL DEFAULT 0.0,
                    specificity         TEXT DEFAULT 'project',
                    status              TEXT DEFAULT 'active',
                    embedding           BLOB,
                    created_at          TEXT DEFAULT (datetime('now')),
                    updated_at          TEXT DEFAULT (datetime('now'))
                )
            """)

            # 会话表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS sessions (
                    id              INTEGER PRIMARY KEY AUTOINCREMENT,
                    external_id     TEXT UNIQUE,
                    collection      TEXT,
                    session_count   INTEGER DEFAULT 0,
                    created_at      TEXT DEFAULT (datetime('now')),
                    updated_at      TEXT DEFAULT (datetime('now'))
                )
            """)

            # 苏醒指引表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS awakening_briefs (
                    id              INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id      TEXT,
                    collection      TEXT NOT NULL,
                    brief_json      TEXT NOT NULL,
                    priority_action TEXT,
                    created_at      TEXT DEFAULT (datetime('now'))
                )
            """)

            # 因果关系表
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS fact_edges (
                    from_fact   INTEGER NOT NULL REFERENCES facts(id),
                    to_fact     INTEGER NOT NULL REFERENCES facts(id),
                    relation    TEXT NOT NULL,
                    confidence  REAL DEFAULT 0.8,
                    source      TEXT DEFAULT 'rule',
                    created_at  TEXT DEFAULT (datetime('now')),
                    PRIMARY KEY (from_fact, to_fact, relation)
                )
            """)
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_edges_from ON fact_edges(from_fact)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_edges_to ON fact_edges(to_fact)"
            )

            # 原始事件表 — Event Buffer (#124 Phase 1)
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS raw_events (
                    event_id     INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id   TEXT NOT NULL,
                    timestamp    TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
                    hook_event   TEXT NOT NULL,
                    tool_name    TEXT,
                    collection   TEXT NOT NULL,
                    payload      TEXT NOT NULL,
                    processed    INTEGER NOT NULL DEFAULT 0,
                    processed_at TEXT,
                    batch_id     TEXT
                )
            """)
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_raw_unprocessed "
                "ON raw_events(processed, timestamp) WHERE processed = 0"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_raw_session "
                "ON raw_events(session_id, event_id)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_raw_collection "
                "ON raw_events(collection, processed)"
            )

            # observations 表（#241 — 结构化事件记录，给人类阅读）
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS observations (
                    id           INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id   TEXT,
                    collection   TEXT NOT NULL,
                    obs_type     TEXT NOT NULL,
                    title        TEXT NOT NULL,
                    narrative    TEXT,
                    concepts     TEXT DEFAULT '[]',
                    files        TEXT DEFAULT '[]',
                    provider     TEXT,
                    model        TEXT,
                    source       TEXT DEFAULT 'observer',
                    timestamp    TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now'))
                )
            """)
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_obs_collection "
                "ON observations(collection)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_obs_session "
                "ON observations(session_id)"
            )
            # idx_obs_timestamp 在迁移区域创建（兼容旧 DB 的 created_at → timestamp 重命名）

            # 扩展列（幂等 — 已存在时静默跳过）
            for col_sql in [
                "ALTER TABLE facts ADD COLUMN context_json TEXT DEFAULT '{}'",
                "ALTER TABLE facts ADD COLUMN scope TEXT DEFAULT 'project'",
                "ALTER TABLE sessions ADD COLUMN current_task TEXT DEFAULT ''",
                "ALTER TABLE sessions ADD COLUMN active_files TEXT DEFAULT '[]'",
                "ALTER TABLE sessions ADD COLUMN pending_actions TEXT DEFAULT '{}'",
                "ALTER TABLE sessions ADD COLUMN event_summary TEXT DEFAULT ''",
                "ALTER TABLE facts ADD COLUMN invalidated_at TEXT",
                "ALTER TABLE facts ADD COLUMN layer TEXT",
                # raw_events 扩展列（#145 Phase A）
                "ALTER TABLE raw_events ADD COLUMN summary TEXT DEFAULT ''",
                "ALTER TABLE raw_events ADD COLUMN importance REAL DEFAULT 0.0",
                "ALTER TABLE raw_events ADD COLUMN signals_json TEXT DEFAULT '{}'",
                # engagement boost 扩展列（#190）
                "ALTER TABLE facts ADD COLUMN return_count INTEGER DEFAULT 0",
                # observations 关联列（#241）
                "ALTER TABLE facts ADD COLUMN observation_id INTEGER",
                # worldview_sources 扩展列（早期表缺 mtime/file_size）
                "ALTER TABLE worldview_sources ADD COLUMN mtime REAL NOT NULL DEFAULT 0",
                "ALTER TABLE worldview_sources ADD COLUMN file_size INTEGER NOT NULL DEFAULT 0",
            ]:
                try:
                    self._conn.execute(col_sql)
                except sqlite3.OperationalError:
                    pass  # duplicate column — 正常

            # 数据回填迁移（#190: return_count 历史数据与 access_count 同步）
            _BACKFILL_SQLS = [
                "UPDATE facts SET return_count=access_count WHERE return_count=0 AND access_count>0",
            ]
            for sql in _BACKFILL_SQLS:
                try:
                    self._conn.execute(sql)
                except sqlite3.OperationalError:
                    pass

            # observations 列重命名 + 格式统一迁移（#241 补丁）
            # created_at → timestamp，格式统一为 %Y-%m-%dT%H:%M:%f（和 raw_events 一致）
            try:
                self._conn.execute(
                    "ALTER TABLE observations RENAME COLUMN created_at TO timestamp"
                )
                # 旧数据格式转换：'2026-02-15 14:30:25' → '2026-02-15T14:30:25.000'
                self._conn.execute(
                    "UPDATE observations SET timestamp = strftime('%Y-%m-%dT%H:%M:%f', timestamp)"
                    " WHERE timestamp NOT LIKE '%T%'"
                )
                self._conn.execute("DROP INDEX IF EXISTS idx_obs_created")
            except sqlite3.OperationalError:
                pass  # 已经重命名过 或 新建 DB（timestamp 列已存在）
            # 无论迁移是否执行，确保索引存在
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_obs_timestamp "
                "ON observations(timestamp DESC)"
            )

            # sqlite-vec 向量索引（含维度迁移检测）
            if self._vec_loaded:
                try:
                    vec_exists = self._conn.execute(
                        "SELECT 1 FROM sqlite_master WHERE type='table' AND name='facts_vec'"
                    ).fetchone()
                    if vec_exists:
                        self._migrate_vec_dim_if_needed()
                    else:
                        self._conn.execute(f"""
                            CREATE VIRTUAL TABLE facts_vec
                            USING vec0(embedding float[{self._embedding_dim}])
                        """)
                except Exception as e:
                    logger.warning("创建 facts_vec 失败: %s", e)
                    self._vec_loaded = False

            # Collection centroid embeddings (#153 Phase 3 Task 7)
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS collection_centroids (
                    collection  TEXT PRIMARY KEY,
                    centroid    BLOB NOT NULL,
                    fact_count  INTEGER NOT NULL DEFAULT 0,
                    updated_at  TEXT DEFAULT (datetime('now'))
                )
            """)
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS worldview_sources (
                    file_path       TEXT PRIMARY KEY,
                    hash            TEXT NOT NULL,
                    mtime           REAL NOT NULL DEFAULT 0,
                    file_size       INTEGER NOT NULL DEFAULT 0,
                    collection      TEXT NOT NULL DEFAULT 'default',
                    last_extracted  TEXT DEFAULT (datetime('now')),
                    fact_count      INTEGER NOT NULL DEFAULT 0
                )
            """)

            # 持久化 Observer 队列（#257 — SQLite 替代内存 queue，crash-safe）
            self._conn.execute("""
                CREATE TABLE IF NOT EXISTS pending_observations (
                    id           INTEGER PRIMARY KEY AUTOINCREMENT,
                    session_id   TEXT NOT NULL,
                    collection   TEXT NOT NULL,
                    tool_name    TEXT NOT NULL,
                    tool_input   TEXT NOT NULL DEFAULT '{}',
                    tool_output  TEXT NOT NULL DEFAULT '',
                    status       TEXT NOT NULL DEFAULT 'pending',
                    retry_count  INTEGER NOT NULL DEFAULT 0,
                    created_at   TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%f', 'now')),
                    claimed_at   TEXT,
                    processed_at TEXT,
                    error        TEXT
                )
            """)
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_pending_status "
                "ON pending_observations(status, created_at)"
            )
            self._conn.execute(
                "CREATE INDEX IF NOT EXISTS idx_pending_session "
                "ON pending_observations(session_id)"
            )

    def _migrate_vec_dim_if_needed(self) -> None:
        """检测 facts_vec 维度是否与当前配置匹配

        三层防御（#254 facts_vec 数据丢失教训）：
        - L1 事前：有数据时拒绝破坏性迁移，适配已有维度
        - L2 事中：无数据重建后验证新表正确性
        - L3 事后：结构化日志（old_dim/new_dim/rows/action）
        """
        try:
            row = self._conn.execute(
                "SELECT sql FROM sqlite_master WHERE name='facts_vec'"
            ).fetchone()
            if not row or not row[0]:
                return

            m = re.search(r'float\[(\d+)\]', row[0])
            if not m:
                return

            existing_dim = int(m.group(1))
            if existing_dim == self._embedding_dim:
                return

            # ── L1 事前校验：检查已有数据量 ──
            existing_rows = 0
            try:
                existing_rows = self._conn.execute(
                    "SELECT COUNT(*) FROM facts_vec"
                ).fetchone()[0]
            except Exception as e:
                logger.debug("facts_vec COUNT 查询失败 (视为 0 行): %s", e)

            if existing_rows > 0:
                # ── L1 拒绝：有数据时不允许破坏性迁移 ──
                logger.error(
                    "facts_vec 维度不匹配 (DB=%d, 请求=%d) 且存在 %d 条向量数据，"
                    "拒绝自动迁移以保护数据。适配为已有维度 %d。"
                    "调用方应传入正确的 embedding_dim 或用 maintenance 重建",
                    existing_dim, self._embedding_dim, existing_rows, existing_dim,
                )
                # 适配已有维度，保护数据
                self._embedding_dim = existing_dim
                return

            # ── 无数据时安全重建 ──
            logger.warning(
                "facts_vec 维度变更: %d → %d（表为空，安全重建）",
                existing_dim, self._embedding_dim,
            )
            self._conn.execute("DROP TABLE facts_vec")
            self._conn.execute(
                f"CREATE VIRTUAL TABLE facts_vec USING vec0(embedding float[{self._embedding_dim}])"
            )
            self._conn.execute("UPDATE facts SET embedding = NULL")
            self._conn.commit()

            # ── L2 事中验证：确认重建成功 ──
            verify = self._conn.execute(
                "SELECT sql FROM sqlite_master WHERE name='facts_vec'"
            ).fetchone()
            if not verify or f"float[{self._embedding_dim}]" not in (verify[0] or ""):
                logger.error(
                    "facts_vec 重建后验证失败: expected float[%d], got %s",
                    self._embedding_dim, verify[0] if verify else "None",
                )
        except Exception as e:
            logger.warning("facts_vec 维度迁移检测失败: %s", e)

    @property
    def conn(self) -> sqlite3.Connection:
        with self._conn_lock:
            if self._conn is None:
                self.connect()
            if self._conn is None:
                raise RuntimeError("认知数据库连接失败")
            return self._conn

    def close(self) -> None:
        with self._conn_lock:
            if self._conn:
                try:
                    self._conn.close()
                except Exception:
                    pass
                self._conn = None

    # ── Facts CRUD ──

    def insert_fact(self, fact: dict) -> int | None:
        """插入一条 fact，手动同步 FTS5 + vec。失败返回 None。

        decay_rate 自动根据 category 从 LAYER_CONFIG 推导，除非显式提供。
        assertion 截断到 300 字符（防止链式增长 — #128 自投毒修复）。
        """
        # 截断 assertion — 防止链式投毒导致的文本膨胀
        fact["assertion"] = (fact.get("assertion") or "")[:300]

        vec_loaded = self._vec_loaded
        category = fact.get("category", "code")
        decay_rate = fact.get("decay_rate") if "decay_rate" in fact else get_default_decay_rate(category)

        layer = fact.get("layer")  # None = 从 category 推导（默认行为）

        def _do(conn: sqlite3.Connection) -> int:
            cursor = conn.execute("""
                INSERT INTO facts
                    (session_id, collection, assertion, category,
                     scope_files, scope_modules, scope_entities,
                     confidence, decay_rate, source, scope, status, embedding,
                     context_json, layer, observation_id)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)
            """, [
                fact.get("session_id"),
                fact["collection"],
                fact["assertion"],
                category,
                fact.get("scope_files", "[]"),
                fact.get("scope_modules", "[]"),
                fact.get("scope_entities", "[]"),
                fact.get("confidence", 0.9),
                decay_rate,
                fact.get("source", "claude"),
                fact.get("scope", "project"),
                fact.get("embedding"),
                fact.get("context_json", "{}"),
                layer,
                fact.get("observation_id"),
            ])
            fact_id = cursor.lastrowid

            # FTS5 手动同步（含 CJK 分词）
            conn.execute(
                "INSERT INTO facts_fts(rowid, assertion, scope_files, scope_modules) "
                "VALUES (?, ?, ?, ?)",
                (
                    fact_id,
                    tokenize_for_fts5(fact["assertion"]),
                    fact.get("scope_files", "[]"),
                    fact.get("scope_modules", "[]"),
                ),
            )

            # Vec 同步
            emb = fact.get("embedding")
            if emb and vec_loaded:
                emb_blob = emb if isinstance(emb, bytes) else _floats_to_blob(emb)
                try:
                    conn.execute(
                        "INSERT INTO facts_vec(rowid, embedding) VALUES (?, ?)",
                        (fact_id, emb_blob),
                    )
                except Exception as e:
                    logger.warning("Vec 索引失败 (fact_id=%d): %s", fact_id, e)

            return fact_id

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else None

    def get_fact(self, fact_id: int) -> dict | None:
        row = self.conn.execute(
            "SELECT * FROM facts WHERE id = ?", [fact_id]
        ).fetchone()
        return dict(row) if row else None

    def update_fact(self, fact_id: int, **updates) -> None:
        """更新 fact 字段"""
        if not updates:
            return
        allowed = {
            "assertion", "category", "confidence", "decay_rate",
            "scope_files", "scope_modules", "scope_entities", "scope",
            "status", "context_json", "invalidated_at", "layer",
        }
        sets = ["updated_at=datetime('now')"]
        values = []
        for key, val in updates.items():
            if key in allowed:
                sets.append(f"{key}=?")
                values.append(val)
        if len(sets) == 1:
            return
        values.append(fact_id)

        old = self.get_fact(fact_id)
        sql = f"UPDATE facts SET {', '.join(sets)} WHERE id=?"

        def _do(conn: sqlite3.Connection) -> None:
            conn.execute(sql, values)
            # FTS5 同步：删旧 + 插新
            if old and "assertion" in updates:
                self._fts5_delete_fact(fact_id, old, conn=conn)
                row = conn.execute(
                    "SELECT * FROM facts WHERE id = ?", [fact_id]
                ).fetchone()
                if row:
                    new = dict(row)
                    conn.execute(
                        "INSERT INTO facts_fts(rowid, assertion, scope_files, scope_modules) "
                        "VALUES (?, ?, ?, ?)",
                        (
                            fact_id,
                            tokenize_for_fts5(new["assertion"]),
                            new["scope_files"],
                            new["scope_modules"],
                        ),
                    )

        safe_db_transaction(self.conn, _do)

    def invalidate_fact(self, fact_id: int, reason: str = "") -> None:
        safe_db_write(self.conn, """
            UPDATE facts
            SET status='invalidated', invalidated_reason=?,
                invalidated_at=datetime('now'),
                updated_at=datetime('now')
            WHERE id=?
        """, [reason, fact_id])

    def validate_fact(self, fact_id: int) -> None:
        safe_db_write(self.conn, """
            UPDATE facts
            SET confidence=MIN(1.0, confidence + 0.1),
                last_validated=datetime('now'),
                updated_at=datetime('now')
            WHERE id=?
        """, [fact_id])

    def supersede_fact(self, old_id: int, new_id: int) -> None:
        safe_db_write(self.conn, """
            UPDATE facts
            SET status='superseded', superseded_by=?,
                invalidated_at=datetime('now'),
                updated_at=datetime('now')
            WHERE id=?
        """, [new_id, old_id])

    def fact_exists(self, assertion: str, session_id: str | None,
                    collection: str) -> bool:
        row = self.conn.execute("""
            SELECT 1 FROM facts
            WHERE assertion=? AND session_id IS ? AND collection=?
            LIMIT 1
        """, [assertion, session_id, collection]).fetchone()
        return row is not None

    def count_facts_by_session(
        self, session_id: str, collection: str, threshold: int = 15,
    ) -> list[tuple[str, int]]:
        """检测单 session 异常 fact 产出 — 返回超过阈值的 (category, count) 列表"""
        try:
            rows = self.conn.execute(
                "SELECT category, COUNT(*) as cnt FROM facts "
                "WHERE session_id = ? AND collection = ? AND status = 'active' "
                "GROUP BY category HAVING cnt > ?",
                (session_id, collection, threshold),
            ).fetchall()
            return [(row[0], row[1]) for row in rows]
        except Exception:
            return []

    def touch_fact(self, fact_id: int) -> None:
        safe_db_write(self.conn, """
            UPDATE facts
            SET access_count=access_count+1,
                last_accessed=datetime('now')
            WHERE id=?
        """, [fact_id])

    def batch_touch_facts(self, fact_ids: list[int]) -> None:
        """批量更新 access_count + return_count — 单次事务，减少锁竞争"""
        if not fact_ids:
            return

        def _do(conn: sqlite3.Connection) -> None:
            for fid in fact_ids:
                conn.execute("""
                    UPDATE facts
                    SET access_count=access_count+1,
                        return_count=return_count+1,
                        last_accessed=datetime('now')
                    WHERE id=?
                """, [fid])

        safe_db_transaction(self.conn, _do)

    def promote_to_universal(self, fact_id: int) -> bool:
        """提升 fact 为 universal scope — 清空项目特定的 scope_files"""
        old = self.get_fact(fact_id)
        if not old or old["status"] != "active" or old["scope"] != "project":
            return True  # 不需要提升，但不算失败

        def _do(conn: sqlite3.Connection) -> None:
            conn.execute("""
                UPDATE facts
                SET scope='universal',
                    scope_files='[]',
                    updated_at=datetime('now')
                WHERE id=? AND status='active' AND scope='project'
            """, [fact_id])
            # FTS5 同步：scope_files 变更需要删旧插新
            self._fts5_delete_fact(fact_id, old, conn=conn)
            conn.execute(
                "INSERT INTO facts_fts(rowid, assertion, scope_files, scope_modules) "
                "VALUES (?, ?, ?, ?)",
                (
                    fact_id,
                    tokenize_for_fts5(old["assertion"]),
                    "[]",
                    old.get("scope_modules", "[]"),
                ),
            )
        ok, _ = safe_db_transaction(self.conn, _do)
        return ok

    def get_universal_candidates(
        self, collection: str,
        min_confidence: float = 0.85,
        min_access_count: int = 3,
    ) -> list[dict]:
        """查找适合提升为 universal 的 facts（L0 纯规则）"""
        try:
            rows = self.conn.execute("""
                SELECT * FROM facts
                WHERE collection=?
                  AND status='active'
                  AND scope='project'
                  AND confidence >= ?
                  AND access_count >= ?
                  AND category IN ('decision', 'root_cause', 'architecture',
                                   'config', 'tradeoff')
                ORDER BY confidence DESC, access_count DESC
                LIMIT 20
            """, [collection, min_confidence, min_access_count]).fetchall()
            return [dict(r) for r in rows]
        except Exception as e:
            logger.warning("查询 universal 候选失败: %s", e)
            return []

    # ── Facts Search ──

    def fts_search_facts(self, query: str, collection: str | None,
                         limit: int = 10,
                         include_universal: bool = False) -> list[dict]:
        """BM25 全文搜索 facts

        Args:
            collection: 限定集合，None 时搜索所有集合
            include_universal: 是否同时搜索 scope='universal' 的跨项目知识
        """
        import re
        cleaned = re.sub(r'[(){}*"^~:\-]', " ", tokenize_for_fts5(query))
        cleaned = re.sub(r"\b(AND|OR|NOT|NEAR)\b", " ", cleaned, flags=re.IGNORECASE)
        tokens = cleaned.split()
        if not tokens:
            return []
        fts5_query = " OR ".join(f'"{t}"' for t in tokens if t.strip())
        if not fts5_query:
            return []

        # collection=None → 搜全部集合（A1 策略）
        if collection is None:
            collection_filter = ""
            params: list = [fts5_query, limit]
        elif include_universal:
            collection_filter = "AND (f.collection=? OR f.scope='universal')"
            params = [fts5_query, collection, limit]
        else:
            collection_filter = "AND f.collection=?"
            params = [fts5_query, collection, limit]

        try:
            rows = self.conn.execute(f"""
                SELECT f.*, bm25(facts_fts) as rank
                FROM facts_fts
                JOIN facts f ON f.id = facts_fts.rowid
                WHERE facts_fts MATCH ?
                  {collection_filter}
                  AND f.status='active'
                ORDER BY rank
                LIMIT ?
            """, params).fetchall()
            return [dict(r) for r in rows]
        except sqlite3.OperationalError as e:
            logger.warning("FTS5 搜索失败: %s | query=%r", e, fts5_query)
            return []

    def vec_search_facts(self, query_embedding: list[float], collection: str | None,
                         limit: int = 10,
                         include_universal: bool = False) -> list[dict]:
        """向量搜索 facts

        Args:
            collection: 限定集合，None 时搜索所有集合
            include_universal: 是否同时搜索 scope='universal' 的跨项目知识
        """
        if not self._vec_loaded:
            return []

        # collection=None → 搜全部集合（A1 策略）
        # filter_params 只含 collection 过滤参数，limit 作为 k=? 单独传入
        if collection is None:
            collection_filter = "AND f.status='active'"
            filter_params: list = []
        elif include_universal:
            collection_filter = "AND (f.collection=? OR f.scope='universal') AND f.status='active'"
            filter_params = [collection]
        else:
            collection_filter = "AND f.collection=? AND f.status='active'"
            filter_params = [collection]

        try:
            emb_blob = _floats_to_blob(query_embedding)
            # sqlite-vec KNN: MATCH + k 传入查询向量，再 JOIN facts 过滤
            rows = self.conn.execute(f"""
                SELECT f.*, v.distance
                FROM facts_vec v
                JOIN facts f ON f.id = v.rowid
                WHERE v.embedding MATCH ? AND k = ?
                  {collection_filter}
                ORDER BY v.distance
            """, [emb_blob, limit, *filter_params]).fetchall()
            return [dict(r) for r in rows]
        except Exception as e:
            logger.warning("向量搜索 facts 失败: %s", e)
            return []

    def get_facts_missing_file_ref(self, collection: str,
                                   limit: int = 200) -> list[tuple[int, str, str]]:
        """获取 context_json 中缺少 file 字段的 active facts — (id, assertion, context_json)"""
        rows = self.conn.execute("""
            SELECT id, assertion, COALESCE(context_json, '{}') FROM facts
            WHERE collection=?
              AND status='active'
              AND (context_json IS NULL
                   OR context_json = '{}'
                   OR json_extract(context_json, '$.file') IS NULL)
            ORDER BY created_at DESC
            LIMIT ?
        """, [collection, limit]).fetchall()
        return [(r[0], r[1], r[2]) for r in rows]

    def get_facts_with_file_ref(self, collection: str) -> list[tuple[int, str, float]]:
        """获取 context_json 中有 file 字段的 active facts — (id, file_path, confidence)"""
        rows = self.conn.execute("""
            SELECT id, json_extract(context_json, '$.file'), confidence
            FROM facts
            WHERE collection=?
              AND status='active'
              AND json_extract(context_json, '$.file') IS NOT NULL
        """, [collection]).fetchall()
        return [(r[0], r[1], r[2]) for r in rows]

    def get_facts_missing_embedding(self, collection: str,
                                    limit: int = 50) -> list[tuple[int, str, str | None]]:
        """获取缺失 embedding 的 facts — (id, assertion, context_json)"""
        rows = self.conn.execute("""
            SELECT id, assertion, context_json FROM facts
            WHERE collection=?
              AND embedding IS NULL
              AND status='active'
            ORDER BY created_at DESC
            LIMIT ?
        """, [collection, limit]).fetchall()
        return [(r[0], r[1], r[2]) for r in rows]

    def update_fact_embedding(self, fact_id: int, embedding: list[float]) -> None:
        """更新单条 fact 的 embedding"""
        emb_blob = _floats_to_blob(embedding)
        vec_loaded = self._vec_loaded

        def _do(conn: sqlite3.Connection) -> None:
            conn.execute(
                "UPDATE facts SET embedding=?, updated_at=datetime('now') WHERE id=?",
                [emb_blob, fact_id],
            )
            if vec_loaded:
                try:
                    conn.execute(
                        "INSERT OR REPLACE INTO facts_vec(rowid, embedding) VALUES (?, ?)",
                        (fact_id, emb_blob),
                    )
                except Exception as e:
                    logger.warning("Vec 索引更新失败 (fact_id=%d): %s", fact_id, e)

        safe_db_transaction(self.conn, _do)

    def get_session_facts(self, session_id: str,
                          collection: str) -> list[dict]:
        rows = self.conn.execute("""
            SELECT * FROM facts
            WHERE session_id=? AND collection=?
            ORDER BY created_at
        """, [session_id, collection]).fetchall()
        return [dict(r) for r in rows]

    def get_top_facts(self, collection: str,
                      min_confidence: float = 0.7,
                      limit: int = 5,
                      include_universal: bool = False) -> list[dict]:
        if include_universal:
            collection_filter = "(collection=? OR scope='universal')"
        else:
            collection_filter = "collection=?"
        rows = self.conn.execute(f"""
            SELECT * FROM facts
            WHERE {collection_filter}
              AND status='active'
              AND confidence >= ?
            ORDER BY confidence DESC, access_count DESC
            LIMIT ?
        """, [collection, min_confidence, limit]).fetchall()
        return [dict(r) for r in rows]

    def get_worldview_facts(self, collection: str,
                            limit: int = 10) -> list[dict]:
        """三观查询 — 读取 L3b 层记忆（constraint/preference）

        零 schema 变更替代方案：直接从 facts 表查询，
        不需要独立 worldview 表。
        """
        rows = self.conn.execute("""
            SELECT id, assertion, category, source, confidence, access_count
            FROM facts
            WHERE collection = ?
              AND category IN ('constraint', 'preference', 'worldview')
              AND status = 'active'
            ORDER BY confidence DESC, access_count DESC
            LIMIT ?
        """, [collection, limit]).fetchall()
        return [dict(r) for r in rows]

    def find_similar_facts(self, collection: str,
                           threshold: float = 0.95) -> list[tuple]:
        """查找相似 facts 对（用于合并去重）"""
        if not self._vec_loaded:
            logger.debug("向量扩展未加载，跳过相似 facts 查找")
            return []
        active = self.conn.execute("""
            SELECT id, assertion, confidence, embedding FROM facts
            WHERE collection=? AND status='active' AND embedding IS NOT NULL
        """, [collection]).fetchall()

        pairs = []
        seen = set()
        for fact in active:
            if fact["id"] in seen:
                continue
            try:
                neighbors = self.conn.execute("""
                    SELECT rowid, distance FROM facts_vec
                    WHERE distance < ?
                    ORDER BY distance
                    LIMIT 5
                """, [1 - threshold]).fetchall()
                for n in neighbors:
                    nid = n[0]
                    if nid != fact["id"] and nid not in seen:
                        other = self.get_fact(nid)
                        if other and other["collection"] == collection:
                            pairs.append((dict(fact), other, 1 - n[1]))
                            seen.add(nid)
            except Exception as e:
                logger.warning("查找相似 facts 失败 (id=%d): %s", fact["id"], e)
                break
        return pairs

    # ── Mental Models ──

    def upsert_mental_model(self, collection: str, scope: str,
                            subject: str, **kwargs) -> int | None:
        """创建/更新心智模型。失败返回 None。"""
        def _do(conn: sqlite3.Connection) -> int:
            existing = conn.execute("""
                SELECT id, revision FROM mental_models
                WHERE collection=? AND scope=?
            """, [collection, scope]).fetchone()

            if existing:
                sets = ["subject=?", "updated_at=datetime('now')",
                        "revision=revision+1"]
                values: list = [subject]
                for key in ("components", "causal_chains", "known_gaps",
                            "open_questions", "understanding_level",
                            "based_on_facts", "depends_on_models"):
                    if key in kwargs:
                        sets.append(f"{key}=?")
                        values.append(kwargs[key])
                values.append(existing["id"])
                conn.execute(
                    f"UPDATE mental_models SET {', '.join(sets)} WHERE id=?",
                    values,
                )
                return existing["id"]
            else:
                cursor = conn.execute("""
                    INSERT INTO mental_models
                        (collection, scope, subject, understanding_level,
                         components, causal_chains, known_gaps, open_questions)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
                """, [
                    collection, scope, subject,
                    kwargs.get("understanding_level", 0.5),
                    kwargs.get("components", "[]"),
                    kwargs.get("causal_chains", "[]"),
                    kwargs.get("known_gaps", "[]"),
                    kwargs.get("open_questions", "[]"),
                ])
                return cursor.lastrowid

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else None

    def get_mental_model(self, scope: str, collection: str) -> dict | None:
        row = self.conn.execute("""
            SELECT * FROM mental_models
            WHERE scope=? AND collection=? AND status='active'
        """, [scope, collection]).fetchone()
        return dict(row) if row else None

    def get_active_models(self, collection: str) -> list[dict]:
        rows = self.conn.execute("""
            SELECT * FROM mental_models
            WHERE collection=? AND status='active'
        """, [collection]).fetchall()
        return [dict(r) for r in rows]

    # ── Tactics ──

    def insert_tactic(self, trigger_pattern: str, steps: str,
                      collection: str | None = None, **kwargs) -> int | None:
        """插入策略。失败返回 None。"""
        return safe_db_write(self.conn, """
            INSERT INTO tactics (trigger_pattern, steps, collection,
                                 trigger_keywords)
            VALUES (?, ?, ?, ?)
        """, [
            trigger_pattern, steps, collection,
            kwargs.get("trigger_keywords", "[]"),
        ])

    def update_tactic_outcome(self, tactic_id: int, success: bool) -> None:
        safe_db_write(self.conn, """
            UPDATE tactics
            SET times_used=times_used+1,
                times_succeeded=times_succeeded+?,
                success_rate=CAST(times_succeeded+? AS REAL) / (times_used+1),
                updated_at=datetime('now')
            WHERE id=?
        """, [int(success), int(success), tactic_id])

    def search_tactics(self, query: str, collection: str | None = None,
                       limit: int = 3) -> list[dict]:
        rows = self.conn.execute("""
            SELECT * FROM tactics
            WHERE (collection=? OR collection IS NULL)
              AND status='active'
              AND (trigger_pattern LIKE ? OR trigger_keywords LIKE ?)
            ORDER BY success_rate DESC, times_used DESC
            LIMIT ?
        """, [collection, f"%{query}%", f"%{query}%", limit]).fetchall()
        return [dict(r) for r in rows]

    # ── Sessions ──

    def get_or_create_session(self, external_id: str | None,
                              collection: str) -> dict | None:
        """获取或创建会话。失败返回 None。"""
        def _do(conn: sqlite3.Connection) -> dict:
            if external_id:
                existing = conn.execute("""
                    SELECT * FROM sessions WHERE external_id=?
                """, [external_id]).fetchone()
                if existing:
                    conn.execute("""
                        UPDATE sessions
                        SET session_count=session_count+1,
                            updated_at=datetime('now')
                        WHERE id=?
                    """, [existing["id"]])
                    result = dict(existing)
                    result["session_count"] += 1
                    return result

            cursor = conn.execute("""
                INSERT INTO sessions (external_id, collection, session_count)
                VALUES (?, ?, 1)
            """, [external_id, collection])
            return {
                "id": cursor.lastrowid,
                "external_id": external_id,
                "collection": collection,
                "session_count": 1,
            }

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else None

    def update_session_context(
        self, external_id: str, **updates,
    ) -> None:
        """更新会话上下文（current_task / active_files / pending_actions）"""
        if not updates or not external_id:
            return
        allowed = {"current_task", "active_files", "pending_actions", "event_summary"}
        sets = ["updated_at=datetime('now')"]
        values: list = []
        for key, val in updates.items():
            if key in allowed:
                sets.append(f"{key}=?")
                values.append(val)
        if len(sets) == 1:
            return
        values.append(external_id)
        safe_db_write(self.conn, f"""
            UPDATE sessions SET {', '.join(sets)}
            WHERE external_id=?
        """, values)

    def get_session_context(self, external_id: str) -> dict | None:
        """获取会话上下文（current_task / active_files / pending_actions）"""
        if not external_id:
            return None
        row = self.conn.execute("""
            SELECT current_task, active_files, pending_actions
            FROM sessions WHERE external_id=?
        """, [external_id]).fetchone()
        if not row:
            return None
        result = dict(row)
        # 解析 JSON 字段
        try:
            result["active_files"] = json.loads(result.get("active_files") or "[]")
        except (json.JSONDecodeError, TypeError):
            result["active_files"] = []
        try:
            result["pending_actions"] = json.loads(result.get("pending_actions") or "{}")
        except (json.JSONDecodeError, TypeError):
            result["pending_actions"] = {}
        return result

    # ── Fact Edges (因果关系) ──

    def insert_edge(
        self, from_fact: int, to_fact: int, relation: str,
        confidence: float = 0.8, source: str = "rule",
    ) -> bool:
        """插入因果关系边（重复则忽略）"""
        return safe_db_write(self.conn, """
            INSERT OR IGNORE INTO fact_edges (from_fact, to_fact, relation, confidence, source)
            VALUES (?, ?, ?, ?, ?)
        """, [from_fact, to_fact, relation, confidence, source]) is not None

    def get_fact_edges(self, fact_id: int) -> list[dict]:
        """获取某个 fact 的所有因果关系（双向）"""
        rows = self.conn.execute("""
            SELECT fe.from_fact, fe.to_fact, fe.relation, fe.confidence, fe.source,
                   f1.assertion AS from_assertion, f2.assertion AS to_assertion
            FROM fact_edges fe
            JOIN facts f1 ON f1.id = fe.from_fact
            JOIN facts f2 ON f2.id = fe.to_fact
            WHERE fe.from_fact = ? OR fe.to_fact = ?
        """, [fact_id, fact_id]).fetchall()
        return [dict(r) for r in rows]

    def trace_facts(self, fact_id: int, max_depth: int = 5) -> list[dict]:
        """BFS 递归遍历 fact_edges，返回因果链

        返回列表中每个元素: {fact_id, assertion, relation, direction, depth}
        """
        visited: set[int] = {fact_id}
        queue: list[tuple[int, int]] = [(fact_id, 0)]  # (id, depth)
        trace: list[dict] = []

        while queue:
            current_id, depth = queue.pop(0)
            if depth >= max_depth:
                continue

            rows = self.conn.execute("""
                SELECT fe.from_fact, fe.to_fact, fe.relation, fe.confidence,
                       f.id AS fact_id, f.assertion, f.category, f.confidence AS fact_conf
                FROM fact_edges fe
                JOIN facts f ON f.id = CASE
                    WHEN fe.from_fact = ? THEN fe.to_fact
                    ELSE fe.from_fact END
                WHERE (fe.from_fact = ? OR fe.to_fact = ?)
                  AND f.status = 'active'
            """, [current_id, current_id, current_id]).fetchall()

            for row in rows:
                r = dict(row)
                next_id = r["fact_id"]
                if next_id in visited:
                    continue
                visited.add(next_id)

                direction = "downstream" if r["from_fact"] == current_id else "upstream"
                trace.append({
                    "fact_id": next_id,
                    "assertion": r["assertion"],
                    "category": r["category"],
                    "relation": r["relation"],
                    "direction": direction,
                    "confidence": r["confidence"],
                    "depth": depth + 1,
                })
                queue.append((next_id, depth + 1))

        return trace

    def delete_edges_by_fact(self, fact_id: int) -> int:
        """删除某个 fact 相关的所有边"""
        def _do(conn: sqlite3.Connection) -> int:
            cursor = conn.execute(
                "DELETE FROM fact_edges WHERE from_fact = ? OR to_fact = ?",
                [fact_id, fact_id],
            )
            return cursor.rowcount
        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else 0

    # ── Awakening Briefs ──

    def save_brief(self, session_id: str | None, collection: str,
                   brief_json: str, priority_action: str | None = None) -> None:
        safe_db_write(self.conn, """
            INSERT INTO awakening_briefs
                (session_id, collection, brief_json, priority_action)
            VALUES (?, ?, ?, ?)
        """, [session_id, collection, brief_json, priority_action])

    def get_latest_brief(self, collection: str) -> dict | None:
        row = self.conn.execute("""
            SELECT * FROM awakening_briefs
            WHERE collection=?
            ORDER BY id DESC
            LIMIT 1
        """, [collection]).fetchone()
        if not row:
            return None
        result = dict(row)
        try:
            result["brief"] = json.loads(result["brief_json"])
        except (json.JSONDecodeError, TypeError):
            result["brief"] = {}
        return result

    # ── Maintenance ──

    def decay_facts(self, collection: str, stale_days: int = 7,
                     beta: float = 0.0) -> int:
        """分层 Ebbinghaus 衰减 — 返回受影响行数

        保护因子（beta 控制）:
        - beta=0 (默认): LN 对数衰减 — 1 + LN(1 + access_count)
        - beta>0: 幂律衰减 — EXP(beta * LN(1 + access_count))，即 (1+n)^beta

        层级行为（使用 effective layer，支持 layer 字段升级）:
        - L3b: 完全跳过衰减（constraint/preference 或已升级到 L3b）
        - L3a: stale_days × 4 才触发，decay_rate × 0.1
        - L2: 正常衰减
        """
        # 保护因子 SQL：beta=0 用 LN，beta>0 用 power law
        # edge_count 加权：被引用多的 fact 衰减更慢（系数 0.3，低于 access_count）
        edge_count_sql = "(SELECT COUNT(*) FROM fact_edges WHERE from_fact = facts.id OR to_fact = facts.id)"
        if beta > 0:
            protection_sql = f"EXP({beta} * LN(1.0 + COALESCE(access_count, 0))) + 0.3 * LN(1.0 + {edge_count_sql})"
        else:
            protection_sql = f"1.0 + LN(1.0 + COALESCE(access_count, 0)) + 0.3 * LN(1.0 + {edge_count_sql})"

        eff_layer = _EFFECTIVE_LAYER_SQL.strip()

        def _do(conn: sqlite3.Connection) -> int:
            total = 0

            # L2: 正常衰减
            cursor = conn.execute(f"""
                UPDATE facts
                SET confidence = MAX(0.05,
                    confidence * EXP(
                        -decay_rate / ({protection_sql})
                    )
                ),
                    updated_at = datetime('now')
                WHERE collection = ?
                  AND status = 'active'
                  AND ({eff_layer}) = 'L2'
                  AND julianday('now') - julianday(
                      COALESCE(last_validated, created_at, datetime('now'))) > ?
            """, [collection, stale_days])
            total += cursor.rowcount

            # L3a: 极慢衰减（stale_days × 4 才触发）
            cursor = conn.execute(f"""
                UPDATE facts
                SET confidence = MAX(0.05,
                    confidence * EXP(
                        -decay_rate * 0.1 / ({protection_sql})
                    )
                ),
                    updated_at = datetime('now')
                WHERE collection = ?
                  AND status = 'active'
                  AND ({eff_layer}) = 'L3a'
                  AND julianday('now') - julianday(
                      COALESCE(last_validated, created_at, datetime('now'))) > ?
            """, [collection, stale_days * 4])
            total += cursor.rowcount

            # L3b: 完全跳过（不执行 UPDATE）
            return total

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else 0

    def garbage_collect(self, collection: str,
                        min_confidence: float = 0.1,
                        stale_days: int = 90) -> int:
        """垃圾回收 — 返回受影响行数"""
        def _do(conn: sqlite3.Connection) -> int:
            total = 0
            cursor = conn.execute("""
                UPDATE facts
                SET status='invalidated',
                    invalidated_reason='low_confidence_gc',
                    invalidated_at=datetime('now')
                WHERE collection=?
                  AND status='active'
                  AND confidence < ?
                  AND access_count=0
            """, [collection, min_confidence])
            total += cursor.rowcount

            cursor = conn.execute("""
                UPDATE facts
                SET status='invalidated',
                    invalidated_reason='stale_gc',
                    invalidated_at=datetime('now')
                WHERE collection=?
                  AND status='active'
                  AND julianday('now') - julianday(
                      COALESCE(last_accessed, created_at)) > ?
            """, [collection, stale_days])
            total += cursor.rowcount
            return total

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else 0

    # ── Stats ──

    def get_stats(self, collection: str | None = None) -> dict:
        where = "WHERE collection=?" if collection else ""
        params = [collection] if collection else []

        total = self.conn.execute(
            f"SELECT COUNT(*) FROM facts {where}", params
        ).fetchone()[0]

        active = self.conn.execute(
            f"SELECT COUNT(*) FROM facts {where}"
            + (" AND" if where else " WHERE") + " status='active'",
            params,
        ).fetchone()[0]

        by_source: dict[str, int] = {}
        for row in self.conn.execute(
            f"SELECT source, COUNT(*) as cnt FROM facts {where} GROUP BY source",
            params,
        ).fetchall():
            by_source[row["source"]] = row["cnt"]

        models_count = self.conn.execute(
            f"SELECT COUNT(*) FROM mental_models {where}"
            + (" AND" if where else " WHERE") + " status='active'",
            params,
        ).fetchone()[0]

        return {
            "facts_total": total,
            "facts_active": active,
            "facts_by_source": by_source,
            "mental_models": models_count,
            "vec_loaded": self._vec_loaded,
        }

    # ── Raw Events (#124) ──

    def insert_observation(self, obs: dict) -> int | None:
        """插入一条 observation（#241 — 结构化事件记录）。失败返回 None。

        observations 是给人类阅读的事件记录（title + narrative），
        与 facts 通过 observation_id 关联。后端 recall 不依赖此表。
        """
        def _do(conn: sqlite3.Connection) -> int:
            cursor = conn.execute("""
                INSERT INTO observations
                    (session_id, collection, obs_type, title, narrative,
                     concepts, files, provider, model, source)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """, [
                obs.get("session_id"),
                obs["collection"],
                obs.get("obs_type", "change"),
                obs.get("title", ""),
                obs.get("narrative"),
                obs.get("concepts", "[]"),
                obs.get("files", "[]"),
                obs.get("provider"),
                obs.get("model"),
                obs.get("source", "observer"),
            ])
            return cursor.lastrowid

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else None

    def insert_raw_event(
        self,
        session_id: str,
        hook_event: str,
        collection: str,
        payload: dict,
        tool_name: str | None = None,
        importance: float = 0.0,
        signals_json: str = "{}",
    ) -> int | None:
        """插入原始事件（写入时截断 payload + 生成 summary）。失败返回 None。

        #145 Phase A: summary 由 L0 纯规则 summarize_raw_event() 生成。
        #145 Phase B: importance + signals_json 由 caller 计算后传入。
        """
        truncated = truncate_payload(payload)
        summary = summarize_raw_event(tool_name, payload)
        return safe_db_write(self.conn, """
            INSERT INTO raw_events
                (session_id, hook_event, tool_name, collection, payload,
                 summary, importance, signals_json)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
        """, [session_id, hook_event, tool_name, collection, truncated,
              summary, importance, signals_json])

    def mark_event_processed(self, event_id: int) -> bool:
        """标记 raw_event 已处理（#242 — L0/L2 去重协调）。

        observe() 成功写入 facts（L0 或 L2）后调用。
        piggyback 恢复通过 processed=0 检测孤儿事件。
        """
        result = safe_db_write(self.conn, """
            UPDATE raw_events SET processed = 1, processed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now')
            WHERE event_id = ?
        """, [event_id])
        return result is not None

    def get_recent_raw_events(
        self,
        session_id: str,
        collection: str | None = None,
        limit: int = 20,
    ) -> list[dict]:
        """获取最近 raw_events — 用于 SessionContext 重建 / breadcrumbs

        利用现有索引 idx_raw_session (session_id, event_id) — 查询 ~1ms。
        #145 Phase A: 增加 summary 字段用于 breadcrumbs 注入。
        """
        if collection:
            rows = self.conn.execute("""
                SELECT event_id, tool_name, collection, payload, summary,
                       importance, signals_json, timestamp
                FROM raw_events
                WHERE session_id = ? AND collection = ?
                ORDER BY event_id DESC LIMIT ?
            """, [session_id, collection, limit]).fetchall()
        else:
            rows = self.conn.execute("""
                SELECT event_id, tool_name, collection, payload, summary,
                       importance, signals_json, timestamp
                FROM raw_events
                WHERE session_id = ?
                ORDER BY event_id DESC LIMIT ?
            """, [session_id, limit]).fetchall()
        return [dict(r) for r in rows]

    def count_session_collections(self, session_id: str) -> dict[str, int]:
        """统计 session 内各 collection 的事件数 — 用于 Session Affinity

        ~1ms，利用 idx_raw_session 索引。
        """
        try:
            rows = self.conn.execute(
                "SELECT collection, COUNT(*) FROM raw_events "
                "WHERE session_id = ? GROUP BY collection",
                [session_id],
            ).fetchall()
            return {r[0]: r[1] for r in rows}
        except Exception:
            return {}

    def estimate_collection_density(
        self, fts_query: str, exclude: str | None = None, top_n: int = 3,
    ) -> list[tuple[str, int]]:
        """ReDDE-lite: FTS5 COUNT(*) 估算各 collection 匹配密度

        返回 [(collection, hit_count)] 降序，排除 exclude collection。
        用 facts_fts 全文索引，~1ms。
        """
        try:
            if exclude:
                rows = self.conn.execute(
                    "SELECT f.collection, COUNT(*) as cnt "
                    "FROM facts_fts ff "
                    "JOIN facts f ON ff.rowid = f.id "
                    "WHERE facts_fts MATCH ? AND f.status = 'active' "
                    "AND f.collection != ? "
                    "GROUP BY f.collection ORDER BY cnt DESC LIMIT ?",
                    [fts_query, exclude, top_n],
                ).fetchall()
            else:
                rows = self.conn.execute(
                    "SELECT f.collection, COUNT(*) as cnt "
                    "FROM facts_fts ff "
                    "JOIN facts f ON ff.rowid = f.id "
                    "WHERE facts_fts MATCH ? AND f.status = 'active' "
                    "GROUP BY f.collection ORDER BY cnt DESC LIMIT ?",
                    [fts_query, top_n],
                ).fetchall()
            return [(r[0], r[1]) for r in rows]
        except Exception:
            return []

    # ── Collection Centroids (#153 Phase 3 Task 7) ──

    def upsert_centroid(
        self, collection: str, centroid: list[float], fact_count: int,
    ) -> None:
        """更新或插入集合质心向量"""
        emb_blob = _floats_to_blob(centroid)
        safe_db_write(
            self.conn,
            "INSERT INTO collection_centroids(collection, centroid, fact_count, updated_at) "
            "VALUES (?, ?, ?, datetime('now')) "
            "ON CONFLICT(collection) DO UPDATE SET "
            "centroid=excluded.centroid, fact_count=excluded.fact_count, "
            "updated_at=datetime('now')",
            [collection, emb_blob, fact_count],
        )

    def get_all_centroids(self, dim: int) -> dict[str, tuple[list[float], int]]:
        """获取所有集合的质心 — {collection: (centroid_vec, fact_count)}

        返回空 dict 时表示无质心数据（首次运行或所有集合无 embedding）。
        """
        try:
            rows = self.conn.execute(
                "SELECT collection, centroid, fact_count FROM collection_centroids"
            ).fetchall()
            result: dict[str, tuple[list[float], int]] = {}
            for r in rows:
                try:
                    vec = _blob_to_floats(r[1], dim)
                    result[r[0]] = (vec, r[2])
                except Exception:
                    logger.debug("质心维度不匹配，跳过: %s", r[0])
                    continue
            return result
        except Exception as e:
            logger.warning("读取 collection_centroids 失败: %s", e)
            return {}

    def delete_centroid(self, collection: str) -> None:
        """删除集合质心（集合无 active facts 时清理）"""
        safe_db_write(
            self.conn,
            "DELETE FROM collection_centroids WHERE collection=?",
            [collection],
        )

    def gc_raw_events(self, retention_days: int = 7) -> int:
        """清理过期 raw_events — 返回删除行数"""
        def _do(conn: sqlite3.Connection) -> int:
            cursor = conn.execute(
                "DELETE FROM raw_events WHERE timestamp < datetime('now', ?)",
                [f"-{retention_days} days"],
            )
            return cursor.rowcount

        ok, result = safe_db_transaction(self.conn, _do)
        return result if ok else 0

    def get_previous_session_record(
        self, collection: str, exclude_session_id: str | None = None,
    ) -> dict | None:
        """获取上一次 session_record — 用于跨会话连续性 (#145 Phase C)

        从 facts 表查 category='session_record' 的最新条目。
        如果指定 exclude_session_id，排除当前 session 的记录。
        """
        if exclude_session_id:
            row = self.conn.execute("""
                SELECT context_json FROM facts
                WHERE collection = ? AND category = 'session_record'
                      AND session_id != ?
                ORDER BY created_at DESC LIMIT 1
            """, [collection, exclude_session_id]).fetchone()
        else:
            row = self.conn.execute("""
                SELECT context_json FROM facts
                WHERE collection = ? AND category = 'session_record'
                ORDER BY created_at DESC LIMIT 1
            """, [collection]).fetchone()

        if not row or not row[0]:
            return None
        try:
            return json.loads(row[0])
        except (json.JSONDecodeError, TypeError):
            return None

    def get_last_session_id(
        self, collection: str, exclude_current: str | None = None,
    ) -> str | None:
        """获取最近一个 session 的 external_id — 用于异常中断检测 (#223)

        从 sessions 表按 id 倒序取第一条。
        如果指定 exclude_current，排除当前 session。
        """
        try:
            if exclude_current:
                row = self.conn.execute("""
                    SELECT external_id FROM sessions
                    WHERE collection = ? AND external_id != ?
                    ORDER BY id DESC LIMIT 1
                """, [collection, exclude_current]).fetchone()
            else:
                row = self.conn.execute("""
                    SELECT external_id FROM sessions
                    WHERE collection = ?
                    ORDER BY id DESC LIMIT 1
                """, [collection]).fetchone()
            return row[0] if row else None
        except Exception:
            return None

    # ── 内部工具 ──

    def _fts5_delete_fact(
        self, fact_id: int, fact: dict,
        conn: sqlite3.Connection | None = None,
    ) -> None:
        """从 FTS5 删除单条 fact"""
        c = conn or self.conn
        if _SUPPORTS_CONTENTLESS_DELETE:
            c.execute(
                "DELETE FROM facts_fts WHERE rowid=?", (fact_id,)
            )
        else:
            try:
                c.execute(
                    "INSERT INTO facts_fts(facts_fts, rowid, assertion, scope_files, scope_modules) "
                    "VALUES('delete', ?, ?, ?, ?)",
                    (
                        fact_id,
                        fact.get("assertion", ""),
                        fact.get("scope_files", "[]"),
                        fact.get("scope_modules", "[]"),
                    ),
                )
            except sqlite3.OperationalError:
                try:
                    c.execute(
                        "INSERT INTO facts_fts(facts_fts) VALUES('rebuild')"
                    )
                except sqlite3.OperationalError as e:
                    logger.warning("FTS5 rebuild 失败: %s", e)

    # ── pending_observations 持久化队列（#257）─────────────────────

    def enqueue_observation(
        self,
        session_id: str,
        collection: str,
        tool_name: str,
        tool_input: str,
        tool_output: str,
    ) -> int | None:
        """入队 observation payload → pending_observations 表。

        Returns:
            插入的行 ID，失败返回 None
        """
        return safe_db_write(
            self.conn,
            "INSERT INTO pending_observations "
            "(session_id, collection, tool_name, tool_input, tool_output) "
            "VALUES (?, ?, ?, ?, ?)",
            (session_id, collection, tool_name, tool_input, tool_output),
        )

    def claim_observation(self) -> dict | None:
        """原子获取一条 pending 消息（CAS 风格 — 先 SELECT 再 UPDATE）。

        Returns:
            消息 dict（含 id, session_id, collection, tool_name, tool_input, tool_output），
            无消息返回 None
        """
        try:
            conn = self.conn  # property: lazy connect
        except RuntimeError:
            return None
        try:
            with self._conn_lock:
                row = conn.execute(
                    "SELECT id, session_id, collection, tool_name, tool_input, tool_output "
                    "FROM pending_observations "
                    "WHERE status = 'pending' "
                    "ORDER BY created_at ASC LIMIT 1"
                ).fetchone()
                if not row:
                    return None

                obs_id = row[0]
                conn.execute(
                    "UPDATE pending_observations "
                    "SET status = 'processing', claimed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') "
                    "WHERE id = ?",
                    (obs_id,),
                )
                conn.commit()

                return {
                    "id": row[0],
                    "session_id": row[1],
                    "collection": row[2],
                    "tool_name": row[3],
                    "tool_input": row[4],
                    "tool_output": row[5],
                }
        except sqlite3.Error as e:
            logger.debug("claim_observation 失败: %s", e)
            return None

    def mark_observation_done(self, obs_id: int) -> None:
        """标记 observation 处理完成"""
        safe_db_write(
            self.conn,
            "UPDATE pending_observations "
            "SET status = 'done', processed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') "
            "WHERE id = ?",
            (obs_id,),
        )

    def mark_observation_failed(
        self, obs_id: int, error: str, max_retries: int = 3,
    ) -> None:
        """标记 observation 处理失败。

        retry_count < max_retries → 重置为 pending（自动重试）
        retry_count >= max_retries → 标记为 failed（永久放弃）
        """
        try:
            conn = self.conn  # property: lazy connect
        except RuntimeError:
            return
        try:
            with self._conn_lock:
                row = conn.execute(
                    "SELECT retry_count FROM pending_observations WHERE id = ?",
                    (obs_id,),
                ).fetchone()
                if not row:
                    return

                retry_count = row[0] + 1
                if retry_count >= max_retries:
                    conn.execute(
                        "UPDATE pending_observations "
                        "SET status = 'failed', retry_count = ?, error = ?, "
                        "    processed_at = strftime('%Y-%m-%dT%H:%M:%f', 'now') "
                        "WHERE id = ?",
                        (retry_count, error[:500], obs_id),
                    )
                else:
                    conn.execute(
                        "UPDATE pending_observations "
                        "SET status = 'pending', retry_count = ?, error = ?, "
                        "    claimed_at = NULL "
                        "WHERE id = ?",
                        (retry_count, error[:500], obs_id),
                    )
                conn.commit()
        except sqlite3.Error as e:
            logger.debug("mark_observation_failed 失败: %s", e)

    def retry_stuck_observations(self, stuck_timeout_seconds: float = 300.0) -> int:
        """重置超时的 processing 消息为 pending（防 consumer crash 后消息丢失）。

        Returns:
            重置的消息数
        """
        result = safe_db_write(
            self.conn,
            "UPDATE pending_observations "
            "SET status = 'pending', claimed_at = NULL "
            "WHERE status = 'processing' "
            "AND claimed_at < strftime('%Y-%m-%dT%H:%M:%f', 'now', ? || ' seconds')",
            (str(-stuck_timeout_seconds),),
        )
        return result or 0

    def gc_pending_observations(self, max_age_days: int = 7) -> int:
        """清理超过 max_age_days 的已完成/失败消息。

        Returns:
            删除的消息数
        """
        result = safe_db_write(
            self.conn,
            "DELETE FROM pending_observations "
            "WHERE status IN ('done', 'failed') "
            "AND created_at < strftime('%Y-%m-%dT%H:%M:%f', 'now', ? || ' days')",
            (str(-max_age_days),),
        )
        return result or 0

    def count_pending_observations(self) -> int:
        """返回 pending 状态的消息数（/health 端点用）"""
        try:
            conn = self.conn  # property: lazy connect
        except RuntimeError:
            return 0
        try:
            row = conn.execute(
                "SELECT COUNT(*) FROM pending_observations WHERE status = 'pending'"
            ).fetchone()
            return row[0] if row else 0
        except sqlite3.OperationalError:
            return 0
