Ctrl K
ComunidadGuías…

scripts/continuity_recall.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
Continuity Recall — Active Recall Layer Phase 1

查詢來源:MEMORY.md + memory/*.md + wiki/topics/*.md
用於 hard trigger(進度詢問、定義回溯、決策查詢)

Usage:
  python3 continuity_recall.py "XKB 下一步是什麼"
  python3 continuity_recall.py "active recall 的定義" --json
  python3 continuity_recall.py "query" --source memory   # 只查 memory
  python3 continuity_recall.py "query" --source wiki     # 只查 wiki
"""
from __future__ import annotations

import argparse
import json
import math
import os
import re
import sys
import threading
from pathlib import Path
from typing import NamedTuple

sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
import xkb_provenance
import xkb_relevance
import xkb_text

WORKSPACE = xkb_paths.WORKSPACE
_SKILL_DIR = xkb_paths.SKILL_DIR
MEMORY_DIR = xkb_paths.DATA_DIR
WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR
MEMORY_MD = xkb_paths.MEMORY_MD

STOPWORDS = {
    "的", "了", "是", "我", "你", "他", "她", "它", "在", "有", "和", "與", "就", "也", "都", "很",
    "想", "要", "用", "讓", "把", "跟", "對", "中", "上", "下", "嗎", "呢", "啊", "吧", "這", "那",
    "this", "that", "with", "from", "have", "will", "about", "your", "they", "what",
    "when", "where", "which", "how", "why", "for", "and", "the", "are", "was",
}


class RecallResult(NamedTuple):
    source_type: str    # memory | wiki
    source_file: str    # relative path
    section: str        # section title or ""
    excerpt: str        # text snippet (150 chars)
    score: float
    url: str = ""       # wiki topic URL or ""


def _result(*, source_type, source_file, section, excerpt, score, url="") -> RecallResult:
    """Build a result with the bookkeeping taken out.

    Governance stamps every promoted line with the candidate fingerprint that
    makes its batch reversible. A recall for 碳盤查 was answering with 64
    characters of hex in the middle of the sentence. There are three places
    that build a result and more than one that renders it, so the removal
    belongs here, once, where the next one added inherits it.
    """
    return RecallResult(
        source_type=source_type,
        source_file=source_file,
        section=xkb_provenance.strip_markers(section),
        excerpt=xkb_provenance.strip_markers(excerpt),
        score=score,
        url=url,
    )


def tokenize(text: str) -> list[str]:
    return xkb_text.tokenize(text, STOPWORDS)


def _score_text(tokens: list[str], text: str) -> float:
    """Simple token overlap score."""
    if not tokens or not text:
        return 0.0
    text_lower = text.lower()
    # Phrase bonus: check if two consecutive tokens appear adjacent
    phrase_bonus = 0.0
    for i in range(len(tokens) - 1):
        phrase = tokens[i] + tokens[i + 1]
        if phrase in text_lower or f"{tokens[i]} {tokens[i+1]}" in text_lower:
            phrase_bonus += 0.5
    return xkb_text.overlap_score(tokens, text) + phrase_bonus


def _split_into_sections(content: str) -> list[tuple[str, str]]:
    """Split markdown into (section_title, body) pairs."""
    sections: list[tuple[str, str]] = []
    current_title = ""
    current_lines: list[str] = []

    for line in content.splitlines():
        if re.match(r"^#{1,3} .+", line):
            if current_lines:
                sections.append((current_title, "\n".join(current_lines)))
            current_title = re.sub(r"^#{1,3} ", "", line).strip()
            current_lines = []
        else:
            current_lines.append(line)

    if current_lines:
        sections.append((current_title, "\n".join(current_lines)))

    return sections


def _excerpt(text: str, tokens: list[str], max_len: int = 200) -> str:
    """Extract the most relevant snippet from text."""
    text_clean = re.sub(r"\n+", " ", text).strip()
    if len(text_clean) <= max_len:
        return text_clean

    # Find best window containing most tokens
    words = text_clean.split()
    best_start = 0
    best_score = 0.0
    window = 30  # words
    for i in range(0, max(1, len(words) - window)):
        chunk = " ".join(words[i:i + window])
        s = _score_text(tokens, chunk)
        if s > best_score:
            best_score = s
            best_start = i

    snippet = " ".join(words[best_start:best_start + window])
    if len(snippet) > max_len:
        snippet = snippet[:max_len - 1] + "…"
    return snippet


# ── Memory recall ─────────────────────────────────────────────────────────────

def recall_from_memory(query: str, top_k: int = 3) -> list[RecallResult]:
    """Search MEMORY.md + memory/*.md for relevant sections."""
    tokens = tokenize(query)
    if not tokens:
        return []

    candidates: list[RecallResult] = []

    # Collect all memory files
    memory_files: list[Path] = []
    if MEMORY_MD.exists():
        memory_files.append(MEMORY_MD)
    if MEMORY_DIR.exists():
        memory_files.extend(MEMORY_DIR.glob("*.md"))

    for path in memory_files:
        try:
            content = path.read_text(encoding="utf-8")
        except Exception:
            continue

        rel_path = str(path.relative_to(WORKSPACE))
        sections = _split_into_sections(content)

        for section_title, body in sections:
            if not body.strip():
                continue
            # Score: title match weighted higher
            title_score = _score_text(tokens, section_title) * 2.0
            body_score = _score_text(tokens, body)
            total = title_score + body_score * 0.5

            if total < 0.3:
                continue

            excerpt = _excerpt(body, tokens)
            candidates.append(_result(
                source_type="memory",
                source_file=rel_path,
                section=section_title,
                excerpt=excerpt,
                score=round(total, 3),
            ))

    candidates.sort(key=lambda r: r.score, reverse=True)
    return candidates[:top_k]


# ── Wiki recall ───────────────────────────────────────────────────────────────

def _parse_wiki_frontmatter(content: str) -> dict[str, str]:
    m = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
    if not m:
        return {}
    data: dict[str, str] = {}
    for line in m.group(1).splitlines():
        if ":" in line:
            k, v = line.split(":", 1)
            data[k.strip()] = v.strip()
    return data


# ── 語意召回 ──────────────────────────────────────────────────────────────────
#
# XKB 的核心不是資料庫,是根據語意主動召回。但 wiki 這一層原本只有字串比對:
# 中文沒有空格,切 n-gram 之後「之前」「怎麼」「處理」會命中任何一份文件,
# 於是「我們之前怎麼處理碳盤查的」撈回一堆無關結果,還給出 0.42 的分數。
#
# 語意分數說得出「沒有夠像的」——實測那句在 wiki 裡最高只有 0.574,
# 因為知識庫裡本來就沒有碳盤查的內容。字串比對永遠說不出這句話。

WIKI_MIN_SIMILARITY = xkb_relevance.threshold("wiki_recall")

_VECTORS: dict[str, list[float]] | None = None
_PROVIDER = None
_INDEX_LOCK = threading.RLock()


SEMANTIC_PREFIXES = {"wiki/topics/": "wiki_semantic", "memory/": "memory_semantic"}


def _load_semantic_vectors() -> dict[str, list[float]]:
    """載入 wiki 與記憶檔的段落向量(已單位化)。

    優先讀二進位小檔:整份 vector_index.json 有 8,000 多個卡片向量,
    存成 JSON 光載入就要 5 秒,而召回是每句話都要跑的。
    """
    with _INDEX_LOCK:
        global _VECTORS
        if _VECTORS is not None:
            return _VECTORS
        _VECTORS = {}

        meta_path = Path(os.getenv("XKB_SEMANTIC_INDEX",
                                   str(xkb_paths.BOOKMARKS_DIR / "semantic_index.json")))
        bin_path = meta_path.with_suffix(".bin")
        if meta_path.exists() and bin_path.exists():
            try:
                import array
                with meta_path.open(encoding="utf-8") as fh:
                    meta = json.load(fh)
                dims, keys = int(meta["dims"]), list(meta["keys"])
                packed = array.array("f")
                payload = bin_path.read_bytes()

                # 二進位格式沒有自我描述能力,對不上時不會拋錯,只會安靜地
                # 切出空的或錯位的向量——分數變成 0,看起來就像「知識庫沒東西」。
                # 所以寧可大聲退回 JSON,也不要拿可能錯位的資料去算相似度。
                problems = []
                if meta.get("byteorder") and meta["byteorder"] != sys.byteorder:
                    problems.append(f"byteorder {meta['byteorder']} != {sys.byteorder}")
                if meta.get("itemsize") and int(meta["itemsize"]) != packed.itemsize:
                    problems.append(f"itemsize {meta['itemsize']} != {packed.itemsize}")
                expected = len(keys) * dims * packed.itemsize
                if len(payload) != expected:
                    problems.append(f"size {len(payload)} != expected {expected}"
                                    f"(keys 與 .bin 不同步,可能只重建了其中一個)")
                if problems:
                    raise ValueError("; ".join(problems))

                packed.frombytes(payload)
                _VECTORS = {
                    key: packed[i * dims:(i + 1) * dims].tolist()
                    for i, key in enumerate(keys)
                }
                return _VECTORS
            except (OSError, ValueError, KeyError) as exc:
                print(f"(semantic index unusable, falling back to JSON: {exc})", file=sys.stderr)
                _VECTORS = {}

        # 退路:直接讀完整索引(慢,但至少能動)
        try:
            with xkb_paths.VECTOR_FILE.open(encoding="utf-8") as fh:
                data = json.load(fh)
            _VECTORS = {
                k: v for k, v in (data.get("vectors") or {}).items()
                if any(k.startswith(prefix) for prefix in SEMANTIC_PREFIXES)
                and not k.startswith("memory/cards/")
            }
        except (OSError, ValueError):
            pass
        return _VECTORS


_CARD_KEYS: dict[str, int] | None = None
_CARD_DIMS = 0

# 卡片相關度下限。gbrain 回的是 RRF 排名分數,不是相關度——
# 名次第一永遠約 0.88,即使整個知識庫裡沒有一張卡跟問題有關。
# 實測「量子力學的波函數塌縮」(知識庫裡完全沒有的主題)照樣拿到 0.855,
# 而且第一名是一段 Bilibili 導覽選單的爬蟲殘渣。
#
# 換成真實餘弦相似度後兩者分得很開(2026-07-29 實測,各取前三名):
#     seedance 工作流程        0.60 0.64 0.67
#     gpt image 2 人像 prompt  0.66 0.74 0.65
#     碳盤查                   0.49 0.48 0.51
#     量子力學的波函數塌縮      0.49 0.50 0.57
#     幫我訂明天的餐廳          0.47 0.48 0.53
# 相關的落在 0.60~0.74,不相關的落在 0.47~0.57,門檻取中間。
CARD_MIN_SIMILARITY = xkb_relevance.threshold("card_recall")


def _card_index_paths() -> tuple[Path, Path]:
    meta = Path(os.getenv("XKB_CARDS_INDEX",
                          str(xkb_paths.BOOKMARKS_DIR / "cards_index.json")))
    return meta, meta.with_suffix(".bin")


def _find_card_rows(key: str, *, card_level_only: bool = False,
                    exact: bool = False) -> list[int]:
    """同一張卡在不同來源有不同前綴,比對時要把它們對起來。

    索引鍵是 `01-openclaw-workflows/X.md` 或 `memory/cards/X.md`,
    gbrain 回的是 `cards/01-openclaw-workflows/X.md`。
    直接字串比對永遠對不上——而對不上的後果是靜默的:
    算不出相似度就不過濾,等於這個功能沒生效卻沒人知道。
    """
    assert _CARD_KEYS is not None
    if key in _CARD_KEYS:
        return [_CARD_KEYS[key]]

    stem = key
    for prefix in ("cards/", "memory/cards/", "memory/"):
        if stem.startswith(prefix):
            stem = stem[len(prefix):]
            break
    for candidate in (stem, f"cards/{stem}", f"memory/cards/{stem}"):
        if candidate in _CARD_KEYS:
            return [_CARD_KEYS[candidate]]

    # 論點級向量的鍵是 relpath#kpN。卡片級找不到時要看這張卡的**所有**論點,
    # 不是字典順序的第一個——分數要面對 0.55 這道硬門檻,所以「第三個論點
    # 正好命中」的卡片,會因為第一個論點離題而被丟掉。semantic_recall 本來
    # 就是取最大值,這條路徑漏了。
    #
    # 2026-09-01 實測:目前 1,628 張有論點向量的卡片,全部都有卡片級鍵,
    # 所以這條路徑現在走不到,實際影響是零。留著修好的版本是因為
    # 「卡片級鍵不存在」是完全可能出現的狀態(索引重建、卡片改名),
    # 而那時候取第一個論點會是靜默的錯誤。
    if not card_level_only:
        # 呼叫端明講只要卡片級時跳過這一段。退回論點級會偷偷換掉粒度,
        # 而有些門檻(吸收閘門的 MAX_REDUNDANCY)是照卡片級校準的。
        # 只擋這一段:下面用檔名比對的那一層只可能命中卡片級鍵
        # (論點鍵長 X.md#kp1,檔名永遠不等於 X.md),把它一起擋掉的話,
        # 目錄改名之後重複度檢查會整個靜靜關掉——而那正是那層存在的理由。
        for candidate in (key, stem):
            rows = [i for k, i in _CARD_KEYS.items() if k.startswith(f"{candidate}#")]
            if rows:
                return rows

    # 最後退路:只比對檔名。分類目錄改過名時仍能對上。
    if exact:
        # 最後這層是拿檔名猜的:目錄不同、檔名相同就會命中**別張卡**。
        # 對召回來說那是可接受的近似,對「這張卡自己的向量」不是。
        #
        # 更正一個我寫錯的理由:我原本說「判不出重複最多讓一張重複的卡片
        # 過去,下一輪還抓得到」。不會——收錄之後它的網址就進了
        # existing_by_topic,永遠不再是候選。兩種失敗都是永久的,所以那個
        # 論證根本不適用。真正的理由是:拿別張卡的向量去判,錯的是「判斷
        # 本身無效」;查不到而不判,錯的是「沒有判斷」——後者至少不會把一個
        # 無效的判斷寫成永久決策。而查不到這件事本身要出聲,見呼叫端。
        return []
    name = stem.rsplit("/", 1)[-1]
    return [i for k, i in _CARD_KEYS.items() if k.rsplit("/", 1)[-1] == name][:1]


# 一張卡最多讀幾條論點向量。定長記錄的 seek 很便宜,但沒有上限的話,
# 一個命名異常的鍵可能讓一次召回讀進上百筆。
MAX_CARD_ARGUMENT_ROWS = 8


def lookup_card_vectors(keys: list[str], *, card_level_only: bool = False,
                        exact: bool = False) -> dict[str, list[list[float]]]:
    """只取指定卡片的向量,用 seek 讀單筆。

    卡片有 6,000 多個向量、100MB,全部載入要好幾秒——但我們只需要驗證
    gbrain 挑出來的那三五張。定長記錄的好處就是可以直接算出位移,
    讀 12KB 而不是 100MB。
    """
    with _INDEX_LOCK:
        global _CARD_KEYS, _CARD_DIMS
        meta_path, bin_path = _card_index_paths()
        if _CARD_KEYS is None:
            _CARD_KEYS = {}
            try:
                with meta_path.open(encoding="utf-8") as fh:
                    meta = json.load(fh)
                _CARD_DIMS = int(meta["dims"])
                _CARD_KEYS = {k: i for i, k in enumerate(meta["keys"])}
            except (OSError, ValueError, KeyError):
                _CARD_KEYS = {}
        if not _CARD_KEYS or not bin_path.exists():
            return {}

        import array
        row_bytes = _CARD_DIMS * 4
        out: dict[str, list[list[float]]] = {}
        try:
            with bin_path.open("rb") as fh:
                for key in keys:
                    # 一張卡可能有好幾條論點向量。全部讀回來,讓上面決定用哪一個。
                    rows = _find_card_rows(key, card_level_only=card_level_only,
                                           exact=exact)
                    for row in rows[:MAX_CARD_ARGUMENT_ROWS]:
                        fh.seek(row * row_bytes)
                        chunk = fh.read(row_bytes)
                        if len(chunk) != row_bytes:
                            continue
                        vec = array.array("f")
                        vec.frombytes(chunk)
                        out.setdefault(key, []).append(vec.tolist())
        except OSError:
            return {}
        return out


def card_similarities(query: str, keys: list[str]) -> dict[str, float] | None:
    """回傳每張卡片與問題的真實相似度。None 代表無法判斷(不該據此過濾)。"""
    if not keys:
        return {}
    vectors = lookup_card_vectors(keys)
    if not vectors:
        return None
    query_vector = _embed_query(query)
    if query_vector is None:
        return None
    # 一張卡取它最像的那條論點。用第一條的話,第三點正好命中的卡片會被
    # 第一點的分數判死。
    return {k: max(_cosine(query_vector, v) for v in rows)
            for k, rows in vectors.items() if rows}


# 同一句話在一次召回裡會被 embed 兩次:混合檢索先打一次,相關度過濾再打一次。
# 查詢字串一模一樣,向量當然也一樣,第二次純粹是多付一趟網路來回。
# 小快取就夠——一次召回內命中,而且行程結束就丟掉,不會拿到過期的向量。
_QUERY_VECTORS: dict[str, list[float] | None] = {}
_QUERY_VECTOR_LIMIT = 64


def _embed_query(query: str) -> list[float] | None:
    """拿不到 embedding 就回 None,呼叫端會退回字串比對。"""
    with _INDEX_LOCK:
        if query in _QUERY_VECTORS:
            return _QUERY_VECTORS[query]
    vector = _embed_query_uncached(query)
    with _INDEX_LOCK:
        if len(_QUERY_VECTORS) >= _QUERY_VECTOR_LIMIT:
            _QUERY_VECTORS.clear()
        _QUERY_VECTORS[query] = vector
    return vector


def _embed_query_uncached(query: str) -> list[float] | None:
    global _PROVIDER
    if _PROVIDER is None:
        try:
            sys.path.insert(0, str(xkb_paths.SKILL_DIR))
            from tools.embedding_providers import get_provider
            _PROVIDER = get_provider()
        except Exception as exc:
            print(f"(semantic recall unavailable: {exc})", file=sys.stderr)
            _PROVIDER = False
    if not _PROVIDER:
        return None
    try:
        return _PROVIDER.embed(query)
    except Exception as exc:
        print(f"(embedding failed: {exc})", file=sys.stderr)
        return None


def _cosine(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb) if na * nb > 1e-9 else 0.0


# 逐段用 Python 迴圈算餘弦,4,357 段 × 3,072 維一次要 1 秒;每輪最多三條查詢,
# 又被 GIL 排成一列,2026-10-05 實測 wiki 這條腿中位數 5.7 秒。整批矩陣乘法
# 約 0.01 秒,前 20 名與分數跟逐段算的一致。沒有 numpy、或向量維度不齊
# (混了不同模型的向量)時退回逐段算,結果不變,只是慢。
_MATRIX: tuple | None = None


def _score_all(vectors: dict[str, list[float]], query_vector: list[float]) -> list[tuple[float, str]]:
    """回傳 (餘弦, 鍵) 由高到低;與逐段 _cosine 同一個定義。"""
    global _MATRIX
    try:
        import numpy as np
        with _INDEX_LOCK:
            if _MATRIX is None or _MATRIX[0] is not vectors:
                keys = list(vectors)
                try:
                    matrix = np.asarray([vectors[k] for k in keys], dtype=np.float32)
                    if matrix.ndim != 2:
                        raise ValueError("ragged vectors")
                    _MATRIX = (vectors, keys, matrix, np.linalg.norm(matrix, axis=1))
                except ValueError:
                    # 記住失敗:否則每次查詢都在鎖裡重轉一次整份索引才退回。
                    _MATRIX = (vectors, None, None, None)
            _, keys, matrix, norms = _MATRIX
            if matrix is None:
                raise ValueError("ragged vectors")
        query = np.asarray(query_vector, dtype=np.float32)
        if query.shape != (matrix.shape[1],):
            raise ValueError("query dimension mismatch")
        denominator = norms * float(np.linalg.norm(query))
        sims = np.where(denominator > 1e-9, (matrix @ query) / np.maximum(denominator, 1e-9), 0.0)
        return sorted(zip(sims.tolist(), keys), reverse=True)
    except (ImportError, ValueError):
        return sorted(((_cosine(query_vector, vec), key) for key, vec in vectors.items()), reverse=True)


# 索引用 @N 標示「同一段被切成好幾塊」、~N 標示「同名標題的第幾個」,兩個都
# 加在 # 之後,所以讀取端會拿「做法-Workflow~2」去找標題。實測索引裡有 895
# 個這種鍵,648 個是 wiki 的——每一個都佔掉名額然後顯示空白。
_INDEX_SUFFIX = re.compile(r"[@~]\d+$")


def _base_heading(source: str, section: str) -> str:
    """把索引加上的 @N / ~N 拿掉,還原成真正的標題。

    只在「原樣找不到、去掉後綴找得到」時才換:真的以 ~2 結尾的標題照樣能用。

    後綴會**疊加**,所以要一層一層剝。寫入端加兩種:`~N` 是同名標題在這一頁
    的第幾次出現,`@N` 是那個段落切成的第幾塊,於是會出現
    `#結論(消化自累積筆記)~2@7` 這種鍵。原本只剝一層,這類鍵一律讀不回內容。

    這件事本來看不出來:另外 506 個已經死掉的鍵靠「退回母標題」碰巧讀得回東西,
    把失敗率稀釋到測試的 90% 門檻之下。清掉死鍵沒有弄壞這個測試,是不再幫它遮掩。
    """
    if not section:
        return section
    # 沒有後綴就沒得剝——直接回。這一條要放在最前面:召回的熱路徑上絕大多數
    # 的鍵都沒有後綴,而 _section_text 每次都要重讀整份頁面(有的到 680K)。
    if not _INDEX_SUFFIX.search(section):
        return section
    candidate = section
    # 最多剝兩層(~N 與 @N),多留一次餘裕以防之後又加了第三種後綴。
    for _ in range(3):
        if _section_text(source, candidate):
            return candidate
        if not _INDEX_SUFFIX.search(candidate):
            return section
        candidate = _INDEX_SUFFIX.sub("", candidate)
    return candidate if _section_text(source, candidate) else section


def _section_text(topic_file: str, section: str) -> str:
    """一個 wiki 段落的內文。

    索引鍵長這樣:wiki/topics/learning-base.md#做法-Workflow。呼叫端拿掉
    wiki/ 之後傳進來的是 topics/learning-base.md,而 WIKI_TOPICS_DIR 本身
    就以 topics 結尾——原本直接相接,於是每一次都在找
    .../wiki/topics/topics/learning-base.md,讀不到、回空字串。

    每一個 wiki 命中都是空的,而 format_chat 在摘要為空時會退回顯示標題,
    所以看起來一直像正常運作。
    """
    name = topic_file[len("topics/"):] if topic_file.startswith("topics/") else topic_file
    path = WIKI_TOPICS_DIR / name
    try:
        content = re.sub(r"^---\n.*?\n---\n", "", path.read_text(encoding="utf-8"), flags=re.DOTALL)
    except OSError:
        return ""
    for title, body in _split_into_sections(content):
        if title == section:
            return body.strip()
    return ""


def _memory_section_text(filename: str, section: str) -> str:
    for base in (MEMORY_DIR, MEMORY_MD.parent):
        path = base / filename
        if not path.exists():
            continue
        try:
            content = path.read_text(encoding="utf-8")
        except OSError:
            continue
        for title, body in _split_into_sections(content):
            if title == section:
                return body.strip()
        return content.strip()
    return ""


def recall_semantic(query: str, top_k: int = 2, *, min_similarity: float | None = None,
                    sections_per_document: int = 1, excerpt_limit: int = 200) -> list[RecallResult] | None:
    """向量召回。回傳 None 代表「語意能力不可用」,空 list 代表「真的沒有夠相關的」。

    這兩者必須分得開:不可用要退回字串比對,沒有夠相關的就該安靜。
    """
    floor = WIKI_MIN_SIMILARITY if min_similarity is None else min_similarity
    vectors = _load_semantic_vectors()
    if not vectors:
        return None
    query_vector = _embed_query(query)
    if query_vector is None:
        return None

    scored = _score_all(vectors, query_vector)

    if sections_per_document > 1:
        # Dense similarity often prefers an overview or the same broad advice.
        # Reserve the companion slot for the best explicit-term match within
        # that same indexed document. Keep its real cosine score and section
        # identity; the shared judge still decides whether it is applicable.
        tokens = tokenize(query)
        companions, first_sections = {}, {}
        # Only inspect companions of the leading documents. Resolving indexed
        # suffixes can read a source file, so do not do it for the whole library.
        for similarity, key in scored:
            if similarity < floor or not key.startswith('wiki/'):
                continue
            rest, _, heading = key[len('wiki/'):].partition('#')
            if rest not in first_sections:
                first_sections[rest] = _base_heading(rest, heading)
                if len(first_sections) >= top_k:
                    break
        for similarity, key in scored:
            if similarity < floor or not key.startswith('wiki/'):
                continue
            rest, _, heading = key[len('wiki/'):].partition('#')
            if rest not in first_sections:
                continue
            heading = _base_heading(rest, heading)
            if heading == first_sections[rest]:
                continue
            lexical = _score_text(tokens, heading)
            candidate = (lexical, similarity, key)
            if lexical > 0 and candidate > companions.get(rest, (0, 0, '')):
                companions[rest] = candidate
        expanded, visited = [], set()
        for similarity, key in scored:
            expanded.append((similarity, key))
            rest = key[len('wiki/'):].partition('#')[0] if key.startswith('wiki/') else ''
            if rest and rest not in visited:
                visited.add(rest)
                if rest in companions:
                    _, companion_score, companion_key = companions[rest]
                    expanded.append((companion_score, companion_key))
        scored = expanded

    results: list[RecallResult] = []
    seen_sections: set[tuple[str, str]] = set()
    topic_counts: dict[str, int] = {}
    for similarity, key in scored:
        if similarity < floor or len(results) >= top_k:
            break
        prefix = next((p for p in SEMANTIC_PREFIXES if key.startswith(p)), "")
        source_type = SEMANTIC_PREFIXES.get(prefix, "wiki_semantic")
        rest, _, section = key[len(prefix):].partition("#")
        section = _base_heading(rest, section)
        identity = (f"{prefix}{rest}", section)
        if identity in seen_sections or topic_counts.get(identity[0], 0) >= sections_per_document:
            continue
        seen_sections.add(identity)
        topic_counts[identity[0]] = topic_counts.get(identity[0], 0) + 1
        is_wiki = source_type == "wiki_semantic"
        excerpt = _section_text(rest, section) if is_wiki else _memory_section_text(rest, section)
        from xkb_evidence import bounded_excerpt
        excerpt = xkb_provenance.strip_markers(excerpt)
        excerpt = bounded_excerpt(excerpt, excerpt_limit) if sections_per_document > 1 else excerpt[:excerpt_limit]
        results.append(_result(
            source_type=source_type,
            source_file=f"{prefix}{rest}",
            section=section,
            excerpt=excerpt,
            score=round(similarity, 3),
            url=f"wiki/topics/{Path(rest).stem}" if is_wiki else "",
        ))
    return results


def recall_from_wiki(query: str, top_k: int = 2, semantic: bool = True) -> list[RecallResult]:
    """Search wiki/topics/*.md for relevant content.

    semantic=True 時優先用向量。語意可用但沒有夠相關的內容,就回空——
    不再退回字串比對,否則剛擋掉的雜訊會從後門進來。
    """
    if semantic:
        semantic_results = recall_semantic(query, top_k)
        if semantic_results is not None:
            return semantic_results

    return _recall_from_wiki_keyword(query, top_k)


def _recall_from_wiki_keyword(query: str, top_k: int = 2) -> list[RecallResult]:
    """字串比對版本。語意不可用時的退路。"""
    tokens = tokenize(query)
    if not tokens or not WIKI_TOPICS_DIR.exists():
        return []

    candidates: list[RecallResult] = []

    for path in WIKI_TOPICS_DIR.glob("*.md"):
        try:
            content = path.read_text(encoding="utf-8")
        except Exception:
            continue

        fm = _parse_wiki_frontmatter(content)
        title = fm.get("title", path.stem)
        tags_str = fm.get("tags", "")

        # Remove frontmatter for body search
        body = re.sub(r"^---\n.*?\n---\n", "", content, flags=re.DOTALL).strip()

        # Score
        title_score = _score_text(tokens, title) * 3.0
        tags_score = _score_text(tokens, tags_str) * 1.5
        body_score = _score_text(tokens, body) * 0.4
        total = title_score + tags_score + body_score

        if total < 0.4:
            continue

        # Find best excerpt from body
        sections = _split_into_sections(body)
        best_excerpt = ""
        best_section = ""
        best_sec_score = 0.0
        for sec_title, sec_body in sections:
            s = _score_text(tokens, sec_title) * 2 + _score_text(tokens, sec_body)
            if s > best_sec_score:
                best_sec_score = s
                best_section = sec_title
                best_excerpt = _excerpt(sec_body, tokens)

        if not best_excerpt:
            best_excerpt = _excerpt(body, tokens)

        candidates.append(_result(
            source_type="wiki",
            source_file=f"wiki/topics/{path.name}",
            section=best_section or title,
            excerpt=best_excerpt,
            score=round(total, 3),
            url=f"wiki/topics/{path.stem}",
        ))

    candidates.sort(key=lambda r: r.score, reverse=True)
    return candidates[:top_k]


# ── Main ──────────────────────────────────────────────────────────────────────

def recall(query: str, source: str = "both", top_k: int = 5,
           semantic: bool = True) -> list[RecallResult]:
    """Unified entry: search memory and/or wiki.

    語意可用時,memory 與 wiki 都走同一次向量查詢——兩層的段落都在同一份索引裡,
    分開查只會多打一次 embedding API。語意不可用才各自退回字串比對。
    """
    if semantic and source == "both":
        semantic_results = recall_semantic(query, top_k=top_k)
        if semantic_results is not None:
            return semantic_results

    results: list[RecallResult] = []
    if source in ("memory", "both"):
        results.extend(recall_from_memory(query, top_k=3))
    if source in ("wiki", "both"):
        results.extend(recall_from_wiki(query, top_k=2, semantic=semantic))
    results.sort(key=lambda r: r.score, reverse=True)
    return results[:top_k]


def format_chat(results: list[RecallResult]) -> str:
    if not results:
        return ""
    lines = ["【知識庫補位】"]
    for r in results:
        # 語意路徑產生的是 memory_semantic / wiki_semantic,不是 memory /
        # wiki——原本只比對 "memory",所以每一筆語意召回的記憶檔都被標成
        # wiki/memory/xxx.md:說它是 wiki 頁,還給一個不存在的路徑。
        if r.source_type.startswith("memory"):
            source_label = "MEMORY"
        elif r.url:
            source_label = f"wiki/{r.url.split('/')[-1]}"
        else:
            source_label = f"wiki/{Path(r.source_file).stem}"
        lines.append(f"• [{source_label}] {r.section}")
        if r.excerpt:
            lines.append(f"  {r.excerpt[:150]}")
        lines.append("")
    return "\n".join(lines).strip()


def main() -> int:
    parser = argparse.ArgumentParser(description="Continuity Recall — searches MEMORY.md + wiki/topics")
    parser.add_argument("query", nargs="?", help="Search query")
    parser.add_argument("--source", choices=["memory", "wiki", "both"], default="both")
    parser.add_argument("--limit", type=int, default=5)
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--format", choices=["chat", "full"], default="chat")
    args = parser.parse_args()

    query = args.query or sys.stdin.read().strip()
    if not query:
        print("Usage: continuity_recall.py <query>")
        return 1

    results = recall(query, source=args.source, top_k=args.limit)

    if args.json:
        print(json.dumps([r._asdict() for r in results], ensure_ascii=False, indent=2))
        return 0

    if not results:
        print("(沒找到相關的 continuity 記憶)")
        return 0

    if args.format == "chat":
        print(format_chat(results))
    else:
        for r in results:
            print(f"[{r.source_type}] {r.source_file} § {r.section}")
            print(f"  score: {r.score}")
            print(f"  {r.excerpt[:200]}")
            print()

    return 0


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())