   172→    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
   173→        """Generate a project summary from all info entries.
   174→
   175→        Args:
   176→            project: Project directory name.
   177→            extra_paths: Optional vault-relative paths to include.
   178→
   179→        Returns:
   180→            LLM-generated summary.
   181→        """
   182→        all_content = self._load_all_content(project)
   183→        if not all_content.strip():
   184→            return f"📭 项目 *{project}* 暂无记录，无法汇总。"
   185→
   186→        extra_text = ""
   187→        if extra_paths:
   188→            for rel_path in extra_paths:
   189→                fp = self.vault_path / rel_path.strip()
   190→                if fp.exists():
   191→                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
   192→                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
   193→                else:
   194→                    extra_text += f"\n\n⚠ 未找到: {rel_path}"
   195→
   196→        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。
   197→
   198→要求：
   199→1. 项目进展时间线（按时间排列关键事件）
   200→2. 当前待解决问题
   201→3. 关键决策和结论
   202→4. 如有外部笔记，融合其内容进行分析
   203→
   204→用中文回答，简洁直接。
   205→
   206→=== 项目碎片信息 ===
   207→{all_content}
   208→{extra_text}
   209→"""
   210→        response, _ = self.llm.generate_response(prompt, [])
   211→        logger.info(f"InfoManager: generated summary for {project}")
   212→        return response
   213→

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "post:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


bash "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags-shell.sh" "pre:observe" "skills/continuous-learning-v2/hooks/observe.sh" "standard,strict"


   375→
   376→            entries_data = state.get("pending_update_entries", [])
   377→            entries = [InfoEntry(**d) for d in entries_data]
   378→            result = self.info_manager.update_entry(
   379→                active_project, entries, pending_idx, cmd
   380→            )
   381→            state["pending_update_entry_index"] = None
   382→            state.pop("pending_update_entries", None)
   383→            storage.save_state(state)
   384→            self._reply(channel_id, result, response_ts)
   385→            return
   386→
   387→        # --- Command: summary ---
   388→        if cmd_lower.startswith("summary"):
   389→            extra_paths = None
   390→            if "+" in cmd:
   391→                _, paths_str = cmd.split("+", 1)
   392→                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
   393→            result = self.info_manager.summarize(active_project, extra_paths)
   394→            self._reply(channel_id, result, response_ts)
   395→            return
   396→
   397→        # --- Command: search <keyword> ---
   398→        if cmd_lower.startswith("search "):
   399→            keyword = cmd[7:].strip()
   400→            if keyword:
   401→                result = self.info_manager.search_entries(active_project, keyword)
   402→                self._reply(channel_id, result, response_ts)
   403→                return
   404→

            entries_data = state.get("pending_update_entries", [])
            entries = [InfoEntry(**d) for d in entries_data]
            result = self.info_manager.update_entry(
                active_project, entries, pending_idx, cmd
            )
            state["pending_update_entry_index"] = None
            state.pop("pending_update_entries", None)
            storage.save_state(state)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: summary ---
        if cmd_lower.startswith("summary"):
            extra_paths = None
            if "+" in cmd:
                _, paths_str = cmd.split("+", 1)
                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
            result = self.info_manager.summarize(active_project, extra_paths)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):
            keyword = cmd[7:].strip()
            if keyword:
                result = self.info_manager.search_entries(active_project, keyword)
                self._reply(channel_id, result, response_ts)
                return


逻辑很清晰，两处改动：

1. `info_manager.py` 加 `ask` 方法
2. `dispatcher.py` 加 `ask` 命令路由 + 使用说明更新


Edit

def search_entries(self, project: str, keyword: str) -> str:

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:edit-write:suggest-compact" "scripts/hooks/suggest-compact.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:insaits-security" "scripts/hooks/insaits-security-wrapper.js" "standard,strict"


The file /root/projects/butler/slack_bot/obsidian/info_manager.py has been updated successfully.

    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
        """Answer a free-form question based on project info entries.

        Args:
            project: Project directory name.
            question: User's question.
            extra_paths: Optional vault-relative paths to include as context.

        Returns:
            LLM-generated answer.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法回答。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 用中文回答，简洁直接
- 如有时间线相关，按时间顺序整理

=== 项目碎片信息 ===
{all_content}
{extra_text}

=== 用户问题 ===
{question}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: answered question for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:

"""Project info fragment tracker for Obsidian Bot mode info."""

import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional, Tuple

from pydantic import BaseModel, Field

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM

logger = setup_logger(__name__)

_URL_RE = re.compile(r'https?://\S+')

INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
You help organize and comment on project information fragments.
Always respond in Chinese unless the input is entirely in English."""

class InfoEntry(BaseModel):
    """Single information fragment parsed from a weekly Markdown file."""

    timestamp: str
    tags: List[str] = Field(default_factory=list)
    raw_content: str
    comment: str = ""
    updates: List[str] = Field(default_factory=list)
    # Line position in the source file for update insertion
    source_line_start: int = 0
    source_line_end: int = 0

class InfoManager:
    """Manages per-project weekly info files in the Obsidian vault.

    Directory layout::

        vault/info/<project>/YYYY-Www.md
    """

    def __init__(self, vault_path: Path):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)
        # Lazy-loaded URL fetcher from NoteIngester
        self._note_ingester: Optional[object] = None

    # ── Public API ────────────────────────────────────────────────────

    def add_entry(self, project: str, content: str) -> str:
        """Add a new info entry to the current week file.

        Args:
            project: Project directory name.
            content: Raw user input (text, URL, or mixed).

        Returns:
            Slack confirmation message.
        """
        raw_text, source_url = self._resolve_content(content)
        if not raw_text.strip():
            return "⚠ 内容为空，未保存。"

        # Load recent entries for context
        week_file = self._get_week_file(project)
        existing = ""
        if week_file.exists():
            existing = week_file.read_text(encoding="utf-8")

        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)

        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
        tag_str = " ".join(tags) if tags else ""
        header = f"### {now} | {tag_str}".rstrip()

        lines = [
            header,
            "",
            *[f"> {line}" for line in raw_text.strip().split("\n")],
            "",
        ]
        if source_url:
            lines.append(f"来源: {source_url}")
            lines.append("")
        lines.append(f"**点评**: {comment}")
        lines.append("")
        lines.append("---")
        lines.append("")

        entry_text = "\n".join(lines)

        # Ensure directory and file exist
        week_file.parent.mkdir(parents=True, exist_ok=True)
        with open(week_file, "a", encoding="utf-8") as f:
            f.write(entry_text)

        tag_display = f" {tag_str}" if tag_str else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}\n\n> {comment}"

    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
        """List the most recent entries for a project.

        Args:
            project: Project directory name.
            count: Max entries to show.

        Returns:
            Tuple of (formatted message, entry list).
        """
        entries = self._load_recent_entries(project, count)
        if not entries:
            return f"📭 项目 *{project}* 暂无记录。", []

        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
        for i, e in enumerate(entries, 1):
            tag_str = " ".join(e.tags) if e.tags else ""
            # Truncate raw content for display
            preview = e.raw_content[:60].replace("\n", " ")
            if len(e.raw_content) > 60:
                preview += "..."
            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
        return "\n".join(lines), entries

    def update_entry(
        self, project: str, entries: List[InfoEntry], index: int, content: str
    ) -> str:
        """Append an update to an existing entry.

        Args:
            project: Project directory name.
            entries: Entry list from list_recent.
            index: 0-based index into entries.
            content: Update content from user.

        Returns:
            Slack confirmation message.
        """
        if index < 0 or index >= len(entries):
            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"

        entry = entries[index]
        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
        update_line = f"**更新 {now}**: {content.strip()}"

        # Find the source file and insert before the closing ---
        week_files = self._get_week_files(project)
        for wf in week_files:
            file_lines = wf.read_text(encoding="utf-8").split("\n")
            # Match entry by timestamp header
            header_pattern = f"### {entry.timestamp}"
            for i, line in enumerate(file_lines):
                if line.startswith(header_pattern):
                    # Find the next --- separator
                    for j in range(i + 1, len(file_lines)):
                        if file_lines[j].strip() == "---":
                            # Insert update before ---
                            file_lines.insert(j, "")
                            file_lines.insert(j, update_line)
                            wf.write_text("\n".join(file_lines), encoding="utf-8")
                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
                    break

        return "⚠ 未找到对应条目，可能文件已被修改。"

    def summarize(self, project: str, extra_paths: Optional[List[str]] = None) -> str:
        """Generate a project summary from all info entries.

        Args:
            project: Project directory name.
            extra_paths: Optional vault-relative paths to include.

        Returns:
            LLM-generated summary.
        """
        all_content = self._load_all_content(project)
        if not all_content.strip():
            return f"📭 项目 *{project}* 暂无记录，无法汇总。"

        extra_text = ""
        if extra_paths:
            for rel_path in extra_paths:
                fp = self.vault_path / rel_path.strip()
                if fp.exists():
                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
                else:
                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

        prompt = f"""根据以下项目 "{project}" 的碎片信息，生成汇总报告。

要求：
1. 项目进展时间线（按时间排列关键事件）
2. 当前待解决问题
3. 关键决策和结论
4. 如有外部笔记，融合其内容进行分析

用中文回答，简洁直接。

=== 项目碎片信息 ===
{all_content}
{extra_text}
"""
        response, _ = self.llm.generate_response(prompt, [])
        logger.info(f"InfoManager: generated summary for {project}")
        return response

    def search_entries(self, project: str, keyword: str) -> str:
        """Search entries by keyword across all week files.

        Args:
            project: Project directory name.
            keyword: Search term.

        Returns:
            Formatted search results.
        """
        week_files = self._get_week_files(project)
        if not week_files:
            return f"📭 项目 *{project}* 暂无记录。"

        matches: List[str] = []
        kw_lower = keyword.lower()
        for wf in week_files:
            entries = self._parse_entries(wf)
            for e in entries:
                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
                if kw_lower in full_text.lower():
                    tag_str = " ".join(e.tags) if e.tags else ""
                    preview = e.raw_content[:80].replace("\n", " ")
                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")

        if not matches:
            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"

        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
        return header + "\n".join(matches[:10])

    def list_projects(self) -> str:
        """List all project directories.

        Returns:
            Formatted project list.
        """
        if not self.info_dir.exists():
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        projects = sorted(
            d.name for d in self.info_dir.iterdir() if d.is_dir()
        )
        if not projects:
            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"

        lines = [f"📁 共 {len(projects)} 个项目：\n"]
        for p in projects:
            week_files = list((self.info_dir / p).glob("*.md"))
            entry_count = sum(
                len(self._parse_entries(wf)) for wf in week_files
            )
            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
        return "\n".join(lines)

    # ── Internal ──────────────────────────────────────────────────────

    def _get_week_file(self, project: str) -> Path:
        """Get the path for the current week's file, creating dir if needed."""
        now = datetime.now(timezone.utc)
        iso_year, iso_week, _ = now.isocalendar()
        filename = f"{iso_year}-W{iso_week:02d}.md"
        project_dir = self.info_dir / project
        project_dir.mkdir(parents=True, exist_ok=True)
        return project_dir / filename

    def _get_week_files(self, project: str) -> List[Path]:
        """Get all week files for a project, sorted newest first."""
        project_dir = self.info_dir / project
        if not project_dir.exists():
            return []
        files = sorted(project_dir.glob("*.md"), reverse=True)
        return files

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """Parse a weekly Markdown file into structured entries."""
        if not file_path.exists():
            return []

        text = file_path.read_text(encoding="utf-8")
        entries: List[InfoEntry] = []

        # Split by ### headers
        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)

        # blocks = [preamble, header1, body1, header2, body2, ...]
        i = 1
        while i < len(blocks) - 1:
            header_line = blocks[i].strip()
            body = blocks[i + 1]

            # Parse header: ### 2026-03-17 14:30 | #ci #性能
            header_match = re.match(
                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
                header_line,
            )
            if not header_match:
                i += 2
                continue

            timestamp = header_match.group(1)
            tag_str = header_match.group(2) or ""
            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]

            # Parse body
            raw_lines: List[str] = []
            comment = ""
            updates: List[str] = []

            for line in body.split("\n"):
                stripped = line.strip()
                if stripped.startswith("> "):
                    raw_lines.append(stripped[2:])
                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
                    comment = stripped.split(":", 1)[1].strip()
                elif stripped.startswith("**更新"):
                    updates.append(stripped)

            entries.append(InfoEntry(
                timestamp=timestamp,
                tags=tags,
                raw_content="\n".join(raw_lines),
                comment=comment,
                updates=updates,
            ))
            i += 2

        return entries

    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
        """Load the most recent N entries across week files."""
        week_files = self._get_week_files(project)
        all_entries: List[InfoEntry] = []
        for wf in week_files:
            entries = self._parse_entries(wf)
            all_entries.extend(reversed(entries))  # newest first within file
            if len(all_entries) >= count:
                break
        return all_entries[:count]

    def _load_all_content(self, project: str) -> str:
        """Load all week files as raw text, oldest first."""
        week_files = self._get_week_files(project)
        parts: List[str] = []
        for wf in reversed(week_files):  # oldest first
            parts.append(f"=== {wf.stem} ===\n")
            parts.append(wf.read_text(encoding="utf-8"))
        return "\n".join(parts)

    def _resolve_content(self, content: str) -> Tuple[str, Optional[str]]:
        """Detect URLs in content and fetch if needed.

        Returns:
            Tuple of (resolved text, source_url or None).
        """
        url_match = _URL_RE.search(content)
        if not url_match:
            return content, None

        url = url_match.group(0)
        # Strip trailing Slack formatting junk
        url = re.sub(r'[>\'")\].,;]+$', '', url).lstrip("<")

        # If content is mostly just the URL, fetch and replace
        non_url_text = content.replace(url_match.group(0), "").strip()
        try:
            fetched = self._fetch_url(url)
            if non_url_text:
                return f"{non_url_text}\n\n---\n{fetched[:2000]}", url
            return fetched[:2000], url
        except Exception as e:
            logger.warning(f"URL fetch failed for {url}: {e}")
            # Fall back to using the raw content as-is
            return content, url

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content, reusing NoteIngester strategies."""
        if self._note_ingester is None:
            from slack_bot.obsidian.note_ingester import NoteIngester
            # Minimal init just for URL fetching
            self._note_ingester = NoteIngester.__new__(NoteIngester)
            self._note_ingester._MIN_CONTENT_LEN = 50
            NoteIngester._JINA_FIRST_DOMAINS = getattr(
                NoteIngester, '_JINA_FIRST_DOMAINS', set()
            )
            self._note_ingester._JINA_FIRST_DOMAINS = NoteIngester._JINA_FIRST_DOMAINS
        return self._note_ingester._fetch_url(url)

    def _generate_comment_and_tags(
        self, content: str, project: str, context: str
    ) -> Tuple[str, List[str]]:
        """Use LLM to generate a one-line comment and 1-3 tags.

        Args:
            content: The raw information text.
            project: Current project name.
            context: Existing entries in the current week file (for tag consistency).

        Returns:
            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
        """
        # Truncate context to avoid prompt bloat
        ctx_preview = context[-1500:] if len(context) > 1500 else context

        prompt = f"""根据以下信息，生成：
1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）

项目名: {project}
已有条目（参考标签风格）:
{ctx_preview}

新信息:
{content}

严格按以下 JSON 格式输出，不要输出其他内容:
{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""

        try:
            response, _ = self.llm.generate_response(prompt, [])
            # Parse JSON from response
            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
            if json_match:
                import json
                data = json.loads(json_match.group(0))
                comment = data.get("comment", "")
                tags = data.get("tags", [])
                # Ensure tags have # prefix
                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
                return comment, tags[:3]
        except Exception as e:
            logger.warning(f"LLM comment/tag generation failed: {e}")

        return "（自动点评生成失败）", []

+    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:

+        """Answer a free-form question based on project info entries.

+            project: Project directory name.

+            extra_paths: Optional vault-relative paths to include as context.

+        all_content = self._load_all_content(project)

+            return f"📭 项目 *{project}* 暂无记录，无法回答。"

+            for rel_path in extra_paths:

+                fp = self.vault_path / rel_path.strip()

+                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]

+                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"

+                    extra_text += f"\n\n⚠ 未找到: {rel_path}"

+        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。

+- 只基于提供的信息回答，没有相关信息就直说

+- 用中文回答，简洁直接

+- 如有时间线相关，按时间顺序整理

+        response, _ = self.llm.generate_response(prompt, [])

+        logger.info(f"InfoManager: answered question for {project}")


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:quality-gate" "scripts/hooks/quality-gate.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:format" "scripts/hooks/post-edit-format.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:typecheck" "scripts/hooks/post-edit-typecheck.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:edit:console-warn" "scripts/hooks/post-edit-console-warn.js" "standard,strict"


现在在 dispatcher 中加 `ask` 路由，放在 `summary` 之后：


        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):

        # --- Command: ask <question> ---
        if cmd_lower.startswith("ask "):
            question = cmd[4:].strip()
            if question:
                extra_paths = None
                if "+" in question:
                    question, paths_str = question.split("+", 1)
                    extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
                    question = question.strip()
                result = self.info_manager.ask(active_project, question, extra_paths)
                self._reply(channel_id, result, response_ts)
                return

        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):


The file /root/projects/butler/slack_bot/obsidian/dispatcher.py has been updated successfully.

        # --- Command: ask <question> ---
        if cmd_lower.startswith("ask "):
            question = cmd[4:].strip()
            if question:
                extra_paths = None
                if "+" in question:
                    question, paths_str = question.split("+", 1)
                    extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
                    question = question.strip()
                result = self.info_manager.ask(active_project, question, extra_paths)
                self._reply(channel_id, result, response_ts)
                return

        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):

import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from slack_sdk import WebClient

from health.utils.logging_config import setup_logger
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
from slack_bot.obsidian.info_manager import InfoManager
from slack_bot.context.storage import ContextStorage as BaseStorage

logger = setup_logger(__name__)

class ObsidianContextStorage(BaseStorage):
    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    def __init__(self, channel_id: str):
        super().__init__(channel_id)
        from health import config
        self.context_dir = config.DATA_DIR / "obsidian_context"
        self.context_dir.mkdir(parents=True, exist_ok=True)
        self.file_path = self.context_dir / f"{channel_id}.json"
        self.state_file = self.context_dir / f"{channel_id}_state.json"

    def save_state(self, state: Dict[str, Any]):
        import json
        with open(self.state_file, "w") as f:
            json.dump(state, f)

    def load_state(self) -> Dict[str, Any]:
        import json
        if not self.state_file.exists():
            return {"mode": "none"}
        try:
            with open(self.state_file, "r") as f:
                return json.load(f)
        except:
            return {"mode": "none"}

class ObsidianDispatcher:
    """Routes Obsidian Slack Bot messages."""

    MODE_WRITE = "write"
    MODE_REPLY = "reply"
    MODE_DECIDE = "decide"
    MODE_SEARCH = "search"
    MODE_DEAI = "deai"
    MODE_ZHIHU = "zhihu"
    MODE_NOTE = "note"
    MODE_ZHIHU_HUNTER = "zhihu-hunter"
    MODE_INFO = "info"

    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER, MODE_INFO}

    def __init__(self, vault_path: str):
        self.indexer = ObsidianIndexer(vault_path)
        self.indexer.scan_vault()

        # Initialize RAG vector store and note ingester
        from slack_bot.obsidian.embeddings import get_embedding_provider
        from slack_bot.obsidian.vector_store import ChromaVectorStore
        from slack_bot.obsidian.note_ingester import NoteIngester

        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
        self.note_ingester = NoteIngester(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
        )

        self.generators = {
            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
            self.MODE_DECIDE: DecisionSupport(self.indexer),
            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
            self.MODE_DEAI: DeAIReviser(self.indexer),
            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
        }

        self.info_manager = InfoManager(vault_path=Path(vault_path))

        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
        self.client = WebClient(token=token)

        # Initialize Zhihu Hunter components
        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway

        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
        self.zhihu_hunter = ZhihuHunter(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
            indexer=self.indexer,
        )
        _zhihu_engine = ZhihuPlaywrightEngine(
            slack_client=self.client,
            notify_channel=zhihu_notify_channel,
        )
        self.zhihu_gateway = SlackInteractiveGateway(
            slack_client=self.client,
            hunter=self.zhihu_hunter,
            engine=_zhihu_engine,
            notify_channel=zhihu_notify_channel,
        )
        logger.info("ObsidianDispatcher initialized")

    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
        storage = ObsidianContextStorage(channel_id)
        state = storage.load_state()
        current_mode = state.get("mode", "none")

        cmd = text.strip().lower()

        # 1. Handle Commands
        if cmd.startswith("mode "):
            new_mode = cmd.split(" ", 1)[1].strip()
            if new_mode in self.VALID_MODES:
                storage.clear()
                storage.save_state({"mode": new_mode})
                msg = f"✅ Switched to mode: *{new_mode}* (History cleared)"
                if new_mode == self.MODE_INFO:
                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )
                self._reply(channel_id, msg, response_ts)
                return
            else:
                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
                return

        if cmd == "clear":
            storage.clear()
            self._reply(channel_id, "🧹 Context cleared.", response_ts)
            return

        if cmd == "reload":
            self.indexer.scan_vault()
            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
            return

        # 2. Handle Content based on mode
        if current_mode == "none":
            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/info/zhihu-hunter` to start.", response_ts)
            return

        # MODE_NOTE: ingest text or URL into vault + vector store
        if current_mode == self.MODE_NOTE:
            try:
                result = self.note_ingester.ingest(text)
                self._reply(channel_id, result, response_ts)
            except Exception as e:
                logger.error(f"Note ingestion failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
            return

        # MODE_INFO: project info fragment tracker
        if current_mode == self.MODE_INFO:
            try:
                self._handle_info_mode(text, channel_id, storage, state, response_ts)
            except Exception as e:
                logger.error(f"Info mode failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
            return

        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
        #
        # Command format:
        #   开始                               → full scan, all dirs, auto keywords
        #   article                            → scan Article only (Revised first, no time filter)
        #   article agent开发                  → scan Article, focus on "agent开发"
        #   notes agent开发                    → scan notes, focus on "agent开发"
        #   关键词1 关键词2                     → skip scan, search directly with keywords
        #   Article/8-xxx-Revised.md           → single file: grounded in that article
        #   Article/*openclaw*-Revised.md      → glob: combine matching files
        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
        #
        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
        if current_mode == self.MODE_ZHIHU_HUNTER:
            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
            try:
                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
                words = [w for w in text.strip().split() if w.lower() not in trigger_words]

                if not words:
                    # Bare trigger → full auto scan
                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)

                elif words and 'zhihu.com/question/' in words[0]:
                    # Direct URL paste — user was invited or found a question to answer.
                    # Any text after the URL is treated as an optional answer outline.
                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
                    import re as _re
                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0]).lstrip("<")
                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
                    hint = f"（已记录回答思路）" if outline else ""
                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
                    questions = self.zhihu_hunter.hunt_direct_url(url)
                    if outline and questions:
                        questions[0].outline = outline
                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
                                (f" outline={outline[:40]!r}" if outline else ""))

                elif words and ('*' in words[0] or '?' in words[0]):
                    # Glob pattern → combine all matched files for keyword extraction
                    pattern = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_glob_files(pattern)
                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")

                elif words and ('/' in words[0] or words[0].endswith('.md')):
                    # Single-file mode: extract questions from one specific file,
                    # and pass source_file so answers stay grounded in that article.
                    rel_path = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_single_file(rel_path)
                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")

                elif words[0].lower() in DIR_SHORTCUTS:
                    # Dir-scoped scan with optional topic hint
                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
                    topic_hint = " ".join(words[1:])
                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
                    self._reply(
                        channel_id,
                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_and_hunt(
                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
                    )

                else:
                    # Direct keywords → skip vault scan entirely
                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
                    questions = self.zhihu_hunter._find_questions(words)
                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")

                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
            except Exception as e:
                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
            return

        # Get history
        history = storage.get_context()
        generator = self.generators[current_mode]

        try:
            logger.info(f"Generating Obsidian response in mode: {current_mode}")
            # The chat method handles first turn logic and returns updated history
            # But wait, our generators' chat method appends to history internally.
            # In our Slack setup, we want to append the result to ContextStorage.

            # Note: generator.chat(text, history) returns (response, new_history_list)
            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
            response_text, updated_history = generator.chat(text, history)

            # Since generator.chat already did the LLM call and returned history,
            # we just need to save the ONLY NEW messages to our storage.
            # However, during FIRST TURN, text is replaced by rich_prompt.

            # For simplicity: completely overwrite storage with updated_history
            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
            # Better: Modify ObsidianContextStorage to support set_context.

            # Actually, let's just use the updated_history and manually add to storage
            # Warning: first turn 'user' message in storage will be the RICH PROMPT
            # This is actually better for context continuity in Slack too.

            storage.clear()
            for msg in updated_history:
                storage.add_message(msg["role"], msg["content"])

            self._reply(channel_id, response_text, response_ts)

        except Exception as e:
            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)

    def _handle_info_mode(
        self,
        text: str,
        channel_id: str,
        storage: ObsidianContextStorage,
        state: dict,
        response_ts: Optional[str],
    ) -> None:
        """Route commands within MODE_INFO."""
        cmd = text.strip()
        cmd_lower = cmd.lower()
        active_project = state.get("active_project", "")
        pending_idx = state.get("pending_update_entry_index")

        # --- Command: project <name> ---
        if cmd_lower.startswith("project "):
            project_name = cmd[8:].strip()
            if not project_name or "/" in project_name or ".." in project_name:
                self._reply(channel_id, "⚠ 无效项目名。", response_ts)
                return
            state["active_project"] = project_name
            state["pending_update_entry_index"] = None
            storage.save_state(state)
            self._reply(channel_id, f"📂 已切换到项目: *{project_name}*", response_ts)
            return

        # --- Command: list ---
        if cmd_lower == "list":
            result = self.info_manager.list_projects()
            self._reply(channel_id, result, response_ts)
            return

        # Guard: require active project for all other commands
        if not active_project:
            self._reply(
                channel_id,
                "⚠ 请先选择项目。用 `project <名称>` 切换或创建项目。\n用 `list` 查看已有项目。",
                response_ts,
            )
            return

        # --- Command: update ---
        if cmd_lower == "update":
            msg, entries = self.info_manager.list_recent(active_project)
            if entries:
                # Store entries in state for later reference
                state["pending_update_entries"] = [e.model_dump() for e in entries]
                state["pending_update_entry_index"] = -1  # waiting for index
                storage.save_state(state)
            self._reply(channel_id, msg, response_ts)
            return

        # --- State: waiting for update index selection ---
        if pending_idx == -1:
            try:
                idx = int(cmd.strip()) - 1  # user sees 1-based
                entries_data = state.get("pending_update_entries", [])
                if 0 <= idx < len(entries_data):
                    state["pending_update_entry_index"] = idx
                    storage.save_state(state)
                    self._reply(
                        channel_id,
                        f"✏ 已选择第 {idx + 1} 条。请发送更新内容：",
                        response_ts,
                    )
                    return
                else:
                    state["pending_update_entry_index"] = None
                    storage.save_state(state)
                    self._reply(channel_id, "⚠ 无效序号，已退出更新模式。", response_ts)
                    return
            except ValueError:
                # Not a number — treat as cancel, fall through to add_entry
                state["pending_update_entry_index"] = None
                state.pop("pending_update_entries", None)
                storage.save_state(state)

        # --- State: waiting for update content ---
        if isinstance(pending_idx, int) and pending_idx >= 0:
            from slack_bot.obsidian.info_manager import InfoEntry

            entries_data = state.get("pending_update_entries", [])
            entries = [InfoEntry(**d) for d in entries_data]
            result = self.info_manager.update_entry(
                active_project, entries, pending_idx, cmd
            )
            state["pending_update_entry_index"] = None
            state.pop("pending_update_entries", None)
            storage.save_state(state)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: summary ---
        if cmd_lower.startswith("summary"):
            extra_paths = None
            if "+" in cmd:
                _, paths_str = cmd.split("+", 1)
                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
            result = self.info_manager.summarize(active_project, extra_paths)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):
            keyword = cmd[7:].strip()
            if keyword:
                result = self.info_manager.search_entries(active_project, keyword)
                self._reply(channel_id, result, response_ts)
                return

        # --- Default: add new entry ---
        result = self.info_manager.add_entry(active_project, cmd)
        self._reply(channel_id, result, response_ts)

    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
        from slack_bot.utils.mrkdwn import SlackFormatter

        # Slack API limits (official documentation):
        # - chat.postMessage: 40,000 chars
        # - chat.update: 4,000 chars
        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"

        # Calculate safe pre-formatting limit with aggressive overhead buffer
        if ts:
            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
            max_raw_length = 1500
        else:
            # For new messages: 40000 limit - 40% overhead = ~24000 chars
            max_raw_length = 24000

        original_length = len(text)
        should_upload_file = original_length > max_raw_length

        if should_upload_file:
            # Upload full text as file first
            self._upload_as_file(channel_id, text, ts)

            # Then send a truncated preview
            truncate_at = text.rfind('\n\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = text.rfind('\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = 800

            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
            formatted_text = SlackFormatter.convert(preview_text)
            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
        else:
            # Text is short enough, just format and send
            formatted_text = SlackFormatter.convert(text)

            # Final safety check after formatting
            final_limit = 3900 if ts else 39900
            if len(formatted_text) > final_limit:
                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
                self._upload_as_file(channel_id, text, ts)
                # Send simple notification
                formatted_text = FILE_UPLOAD_HINT

        # Send the message
        try:
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
            else:
                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
        except Exception as e:
            if "msg_too_long" in str(e):
                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
                # Ultimate fallback: send error message only
                try:
                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
                    if ts:
                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
                    else:
                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
                except:
                    logger.error("Failed to send even error message", exc_info=True)
            else:
                raise

    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
        """Fallback: upload long responses as text files."""
        try:
            import tempfile
            from datetime import datetime

            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"

            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
                tmp.write(content)
                tmp_path = tmp.name

            self.client.files_upload_v2(
                channel=channel_id,
                file=tmp_path,
                filename=filename,
                initial_comment="📄 Response too long for Slack message, uploaded as file:",
                thread_ts=ts
            )

            os.unlink(tmp_path)
            logger.info(f"Uploaded long response as file: {filename}")
        except Exception as e:
            logger.error(f"Failed to upload file: {e}", exc_info=True)
            # Ultimate fallback: send error message
            error_msg = "❌ Response too long to display. Please try a more specific query."
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
            else:
                self.client.chat_postMessage(channel=channel_id, text=error_msg)

+        # --- Command: ask <question> ---

+        if cmd_lower.startswith("ask "):

+                    question, paths_str = question.split("+", 1)

+                    extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]

+                    question = question.strip()

+                result = self.info_manager.ask(active_project, question, extra_paths)

+                self._reply(channel_id, result, response_ts)


更新使用说明：


                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )

                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `ask <问题>` — 基于项目信息回答问题\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )


                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `ask <问题>` — 基于项目信息回答问题\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )

import os
from pathlib import Path
from typing import Optional, List, Dict, Any
from slack_sdk import WebClient

from health.utils.logging_config import setup_logger
from slack_bot.obsidian.indexer import ObsidianIndexer
from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
from slack_bot.obsidian.info_manager import InfoManager
from slack_bot.context.storage import ContextStorage as BaseStorage

logger = setup_logger(__name__)

class ObsidianContextStorage(BaseStorage):
    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    def __init__(self, channel_id: str):
        super().__init__(channel_id)
        from health import config
        self.context_dir = config.DATA_DIR / "obsidian_context"
        self.context_dir.mkdir(parents=True, exist_ok=True)
        self.file_path = self.context_dir / f"{channel_id}.json"
        self.state_file = self.context_dir / f"{channel_id}_state.json"

    def save_state(self, state: Dict[str, Any]):
        import json
        with open(self.state_file, "w") as f:
            json.dump(state, f)

    def load_state(self) -> Dict[str, Any]:
        import json
        if not self.state_file.exists():
            return {"mode": "none"}
        try:
            with open(self.state_file, "r") as f:
                return json.load(f)
        except:
            return {"mode": "none"}

class ObsidianDispatcher:
    """Routes Obsidian Slack Bot messages."""

    MODE_WRITE = "write"
    MODE_REPLY = "reply"
    MODE_DECIDE = "decide"
    MODE_SEARCH = "search"
    MODE_DEAI = "deai"
    MODE_ZHIHU = "zhihu"
    MODE_NOTE = "note"
    MODE_ZHIHU_HUNTER = "zhihu-hunter"
    MODE_INFO = "info"

    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER, MODE_INFO}

    def __init__(self, vault_path: str):
        self.indexer = ObsidianIndexer(vault_path)
        self.indexer.scan_vault()

        # Initialize RAG vector store and note ingester
        from slack_bot.obsidian.embeddings import get_embedding_provider
        from slack_bot.obsidian.vector_store import ChromaVectorStore
        from slack_bot.obsidian.note_ingester import NoteIngester

        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
        self.note_ingester = NoteIngester(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
        )

        self.generators = {
            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
            self.MODE_DECIDE: DecisionSupport(self.indexer),
            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
            self.MODE_DEAI: DeAIReviser(self.indexer),
            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
        }

        self.info_manager = InfoManager(vault_path=Path(vault_path))

        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
        self.client = WebClient(token=token)

        # Initialize Zhihu Hunter components
        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway

        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
        self.zhihu_hunter = ZhihuHunter(
            vault_path=Path(vault_path),
            vector_store=self.vector_store,
            indexer=self.indexer,
        )
        _zhihu_engine = ZhihuPlaywrightEngine(
            slack_client=self.client,
            notify_channel=zhihu_notify_channel,
        )
        self.zhihu_gateway = SlackInteractiveGateway(
            slack_client=self.client,
            hunter=self.zhihu_hunter,
            engine=_zhihu_engine,
            notify_channel=zhihu_notify_channel,
        )
        logger.info("ObsidianDispatcher initialized")

    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
        storage = ObsidianContextStorage(channel_id)
        state = storage.load_state()
        current_mode = state.get("mode", "none")

        cmd = text.strip().lower()

        # 1. Handle Commands
        if cmd.startswith("mode "):
            new_mode = cmd.split(" ", 1)[1].strip()
            if new_mode in self.VALID_MODES:
                storage.clear()
                storage.save_state({"mode": new_mode})
                msg = f"✅ Switched to mode: *{new_mode}* (History cleared)"
                if new_mode == self.MODE_INFO:
                    msg += (
                        "\n\n📌 *使用说明:*\n"
                        "• `project <名称>` — 切换/创建项目\n"
                        "• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `summary` — 汇总项目进展\n"
                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
                        "• `search <关键词>` — 搜索当前项目\n"
                        "• `list` — 查看所有项目"
                    )
                self._reply(channel_id, msg, response_ts)
                return
            else:
                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
                return

        if cmd == "clear":
            storage.clear()
            self._reply(channel_id, "🧹 Context cleared.", response_ts)
            return

        if cmd == "reload":
            self.indexer.scan_vault()
            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
            return

        # 2. Handle Content based on mode
        if current_mode == "none":
            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/info/zhihu-hunter` to start.", response_ts)
            return

        # MODE_NOTE: ingest text or URL into vault + vector store
        if current_mode == self.MODE_NOTE:
            try:
                result = self.note_ingester.ingest(text)
                self._reply(channel_id, result, response_ts)
            except Exception as e:
                logger.error(f"Note ingestion failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
            return

        # MODE_INFO: project info fragment tracker
        if current_mode == self.MODE_INFO:
            try:
                self._handle_info_mode(text, channel_id, storage, state, response_ts)
            except Exception as e:
                logger.error(f"Info mode failed: {e}", exc_info=True)
                self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
            return

        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
        #
        # Command format:
        #   开始                               → full scan, all dirs, auto keywords
        #   article                            → scan Article only (Revised first, no time filter)
        #   article agent开发                  → scan Article, focus on "agent开发"
        #   notes agent开发                    → scan notes, focus on "agent开发"
        #   关键词1 关键词2                     → skip scan, search directly with keywords
        #   Article/8-xxx-Revised.md           → single file: grounded in that article
        #   Article/*openclaw*-Revised.md      → glob: combine matching files
        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
        #
        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
        if current_mode == self.MODE_ZHIHU_HUNTER:
            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
            try:
                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
                words = [w for w in text.strip().split() if w.lower() not in trigger_words]

                if not words:
                    # Bare trigger → full auto scan
                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)

                elif words and 'zhihu.com/question/' in words[0]:
                    # Direct URL paste — user was invited or found a question to answer.
                    # Any text after the URL is treated as an optional answer outline.
                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
                    import re as _re
                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0]).lstrip("<")
                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
                    hint = f"（已记录回答思路）" if outline else ""
                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
                    questions = self.zhihu_hunter.hunt_direct_url(url)
                    if outline and questions:
                        questions[0].outline = outline
                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
                                (f" outline={outline[:40]!r}" if outline else ""))

                elif words and ('*' in words[0] or '?' in words[0]):
                    # Glob pattern → combine all matched files for keyword extraction
                    pattern = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_glob_files(pattern)
                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")

                elif words and ('/' in words[0] or words[0].endswith('.md')):
                    # Single-file mode: extract questions from one specific file,
                    # and pass source_file so answers stay grounded in that article.
                    rel_path = words[0]
                    self._reply(
                        channel_id,
                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_single_file(rel_path)
                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")

                elif words[0].lower() in DIR_SHORTCUTS:
                    # Dir-scoped scan with optional topic hint
                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
                    topic_hint = " ".join(words[1:])
                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
                    self._reply(
                        channel_id,
                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
                        response_ts,
                    )
                    questions = self.zhihu_hunter.scan_and_hunt(
                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
                    )

                else:
                    # Direct keywords → skip vault scan entirely
                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
                    questions = self.zhihu_hunter._find_questions(words)
                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")

                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
            except Exception as e:
                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
            return

        # Get history
        history = storage.get_context()
        generator = self.generators[current_mode]

        try:
            logger.info(f"Generating Obsidian response in mode: {current_mode}")
            # The chat method handles first turn logic and returns updated history
            # But wait, our generators' chat method appends to history internally.
            # In our Slack setup, we want to append the result to ContextStorage.

            # Note: generator.chat(text, history) returns (response, new_history_list)
            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
            response_text, updated_history = generator.chat(text, history)

            # Since generator.chat already did the LLM call and returned history,
            # we just need to save the ONLY NEW messages to our storage.
            # However, during FIRST TURN, text is replaced by rich_prompt.

            # For simplicity: completely overwrite storage with updated_history
            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
            # Better: Modify ObsidianContextStorage to support set_context.

            # Actually, let's just use the updated_history and manually add to storage
            # Warning: first turn 'user' message in storage will be the RICH PROMPT
            # This is actually better for context continuity in Slack too.

            storage.clear()
            for msg in updated_history:
                storage.add_message(msg["role"], msg["content"])

            self._reply(channel_id, response_text, response_ts)

        except Exception as e:
            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)

    def _handle_info_mode(
        self,
        text: str,
        channel_id: str,
        storage: ObsidianContextStorage,
        state: dict,
        response_ts: Optional[str],
    ) -> None:
        """Route commands within MODE_INFO."""
        cmd = text.strip()
        cmd_lower = cmd.lower()
        active_project = state.get("active_project", "")
        pending_idx = state.get("pending_update_entry_index")

        # --- Command: project <name> ---
        if cmd_lower.startswith("project "):
            project_name = cmd[8:].strip()
            if not project_name or "/" in project_name or ".." in project_name:
                self._reply(channel_id, "⚠ 无效项目名。", response_ts)
                return
            state["active_project"] = project_name
            state["pending_update_entry_index"] = None
            storage.save_state(state)
            self._reply(channel_id, f"📂 已切换到项目: *{project_name}*", response_ts)
            return

        # --- Command: list ---
        if cmd_lower == "list":
            result = self.info_manager.list_projects()
            self._reply(channel_id, result, response_ts)
            return

        # Guard: require active project for all other commands
        if not active_project:
            self._reply(
                channel_id,
                "⚠ 请先选择项目。用 `project <名称>` 切换或创建项目。\n用 `list` 查看已有项目。",
                response_ts,
            )
            return

        # --- Command: update ---
        if cmd_lower == "update":
            msg, entries = self.info_manager.list_recent(active_project)
            if entries:
                # Store entries in state for later reference
                state["pending_update_entries"] = [e.model_dump() for e in entries]
                state["pending_update_entry_index"] = -1  # waiting for index
                storage.save_state(state)
            self._reply(channel_id, msg, response_ts)
            return

        # --- State: waiting for update index selection ---
        if pending_idx == -1:
            try:
                idx = int(cmd.strip()) - 1  # user sees 1-based
                entries_data = state.get("pending_update_entries", [])
                if 0 <= idx < len(entries_data):
                    state["pending_update_entry_index"] = idx
                    storage.save_state(state)
                    self._reply(
                        channel_id,
                        f"✏ 已选择第 {idx + 1} 条。请发送更新内容：",
                        response_ts,
                    )
                    return
                else:
                    state["pending_update_entry_index"] = None
                    storage.save_state(state)
                    self._reply(channel_id, "⚠ 无效序号，已退出更新模式。", response_ts)
                    return
            except ValueError:
                # Not a number — treat as cancel, fall through to add_entry
                state["pending_update_entry_index"] = None
                state.pop("pending_update_entries", None)
                storage.save_state(state)

        # --- State: waiting for update content ---
        if isinstance(pending_idx, int) and pending_idx >= 0:
            from slack_bot.obsidian.info_manager import InfoEntry

            entries_data = state.get("pending_update_entries", [])
            entries = [InfoEntry(**d) for d in entries_data]
            result = self.info_manager.update_entry(
                active_project, entries, pending_idx, cmd
            )
            state["pending_update_entry_index"] = None
            state.pop("pending_update_entries", None)
            storage.save_state(state)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: summary ---
        if cmd_lower.startswith("summary"):
            extra_paths = None
            if "+" in cmd:
                _, paths_str = cmd.split("+", 1)
                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
            result = self.info_manager.summarize(active_project, extra_paths)
            self._reply(channel_id, result, response_ts)
            return

        # --- Command: ask <question> ---
        if cmd_lower.startswith("ask "):
            question = cmd[4:].strip()
            if question:
                extra_paths = None
                if "+" in question:
                    question, paths_str = question.split("+", 1)
                    extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
                    question = question.strip()
                result = self.info_manager.ask(active_project, question, extra_paths)
                self._reply(channel_id, result, response_ts)
                return

        # --- Command: search <keyword> ---
        if cmd_lower.startswith("search "):
            keyword = cmd[7:].strip()
            if keyword:
                result = self.info_manager.search_entries(active_project, keyword)
                self._reply(channel_id, result, response_ts)
                return

        # --- Default: add new entry ---
        result = self.info_manager.add_entry(active_project, cmd)
        self._reply(channel_id, result, response_ts)

    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
        from slack_bot.utils.mrkdwn import SlackFormatter

        # Slack API limits (official documentation):
        # - chat.postMessage: 40,000 chars
        # - chat.update: 4,000 chars
        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"

        # Calculate safe pre-formatting limit with aggressive overhead buffer
        if ts:
            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
            max_raw_length = 1500
        else:
            # For new messages: 40000 limit - 40% overhead = ~24000 chars
            max_raw_length = 24000

        original_length = len(text)
        should_upload_file = original_length > max_raw_length

        if should_upload_file:
            # Upload full text as file first
            self._upload_as_file(channel_id, text, ts)

            # Then send a truncated preview
            truncate_at = text.rfind('\n\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = text.rfind('\n', 0, 800)
            if truncate_at == -1 or truncate_at < 400:
                truncate_at = 800

            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
            formatted_text = SlackFormatter.convert(preview_text)
            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
        else:
            # Text is short enough, just format and send
            formatted_text = SlackFormatter.convert(text)

            # Final safety check after formatting
            final_limit = 3900 if ts else 39900
            if len(formatted_text) > final_limit:
                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
                self._upload_as_file(channel_id, text, ts)
                # Send simple notification
                formatted_text = FILE_UPLOAD_HINT

        # Send the message
        try:
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
            else:
                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
        except Exception as e:
            if "msg_too_long" in str(e):
                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
                # Ultimate fallback: send error message only
                try:
                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
                    if ts:
                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
                    else:
                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
                except:
                    logger.error("Failed to send even error message", exc_info=True)
            else:
                raise

    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
        """Fallback: upload long responses as text files."""
        try:
            import tempfile
            from datetime import datetime

            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"

            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
                tmp.write(content)
                tmp_path = tmp.name

            self.client.files_upload_v2(
                channel=channel_id,
                file=tmp_path,
                filename=filename,
                initial_comment="📄 Response too long for Slack message, uploaded as file:",
                thread_ts=ts
            )

            os.unlink(tmp_path)
            logger.info(f"Uploaded long response as file: {filename}")
        except Exception as e:
            logger.error(f"Failed to upload file: {e}", exc_info=True)
            # Ultimate fallback: send error message
            error_msg = "❌ Response too long to display. Please try a more specific query."
            if ts:
                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
            else:
                self.client.chat_postMessage(channel=channel_id, text=error_msg)

"• 直接发送文字或 URL — 新增条目（自动生成点评和标签）\n"

"• `update` — 查看最近条目，选序号追加更新\n"

+                        "• `ask <问题>` — 基于项目信息回答问题\n"


Bash

Restart obsidian bot


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:bash:tmux-reminder" "scripts/hooks/pre-bash-tmux-reminder.js" "strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:bash:git-push-reminder" "scripts/hooks/pre-bash-git-push-reminder.js" "strict"


(Bash completed with no output)


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:bash:pr-created" "scripts/hooks/post-bash-pr-created.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "post:bash:build-complete" "scripts/hooks/post-bash-build-complete.js" "standard,strict"


systemctl status butler-obsidian --no-pager -l | head -10

Verify bot is running


● butler-obsidian.service - Butler Obsidian Bot
     Loaded: loaded (/etc/systemd/system/butler-obsidian.service; disabled; preset: disabled)
     Active: active (running) since Tue 2026-03-17 19:21:24 CST; 1min 42s ago
   Main PID: 3380468 (python)
      Tasks: 16 (limit: 9171)
     Memory: 94.6M (peak: 95.3M)
        CPU: 2.040s
     CGroup: /system.slice/butler-obsidian.service
             └─3380468 /root/projects/butler/venv/bin/python scripts/obsidian_bot.py


已完成。改动说明：

**改了什么：**
- `info_manager.py`：新增 `ask()` 方法，加载项目全部信息作为上下文，配合用户问题发给 LLM，要求只基于已有信息回答
- `dispatcher.py`：新增 `ask` 命令路由，支持 `ask 问题 + vault路径` 附加外部笔记（与 summary 语法一致）
- 使用说明中加入了 `ask` 命令

**用法：**
```
ask CI 流水线的瓶颈解决了吗？
ask 目前还有哪些风险点？+ notes/架构笔记.md
```


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:check-console-log" "scripts/hooks/check-console-log.js" "standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:session-end" "scripts/hooks/session-end.js" "minimal,standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:evaluate-session" "scripts/hooks/evaluate-session.js" "minimal,standard,strict"


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "stop:cost-tracker" "scripts/hooks/cost-tracker.js" "minimal,standard,strict"


有两个新的需求：1. 不需要解析url，因为它大多是一个引用，具体内容不需要展开；2. 我现在会用（From xxx）来标注来源，这也许不会是好的方式，怎么样更好地结构性记录，目标是为了以后询问问题时最好返回日期和来源，以便人工复核


The user has two requests:

1. Remove URL fetching from info_manager - URLs should be kept as-is references, not fetched and expanded
2. Better structured source tracking - currently using "(From xxx)" in text, wants a more structured way to record sources so that when asking questions, the LLM can return dates and sources for verification

Let me think about #2. The current entry format is:

```markdown
### 2026-03-17 14:30 | #ci #性能

> SRE 团队抱怨 CI 流水线每天挂 30 分钟，都卡在 Docker 拉镜像

```

For structured sources, I think the simplest approach is to add a `来源:` field to each entry. The user can specify source inline, like `from xxx 内容` or `内容 from xxx`, and I can auto-detect the "(From xxx)" pattern and extract it. This way each entry would have a dedicated source field that's easy to parse when the LLM needs to reference dates and sources for verification.

I'm leaning toward detecting the `(from xxx)` pattern since the user is already using that format — I can extract it into a structured field and strip it from the raw content, keeping things clean and backward-compatible. A regex pattern would capture variations like `(from xxx)`, `(From xxx)`, or `(FROM xxx)` anywhere in the text.

For the `ask` responses, I need to update the prompt to instruct the LLM to cite dates and sources when answering. I'll also add a `source` field to `InfoEntry` and refactor the parsing logic to extract and store sources separately.

The main changes involve removing the URL fetching logic, updating `InfoEntry` with the source field, extracting the source pattern during entry creation, reformatting entries to include a `来源:` line when present, and updating both the parsing and prompting logic to handle citations properly.


两个改动很清晰：

1. 去掉 URL 抓取，原样保留 URL 作为引用
2. 自动提取 `(From xxx)` 为结构化来源字段，存为 `来源: xxx`，`ask` 时要求 LLM 引用日期+来源