     1→import os
     2→from pathlib import Path
     3→from typing import Optional, List, Dict, Any
     4→from slack_sdk import WebClient
     5→
     6→from health.utils.logging_config import setup_logger
     7→from slack_bot.obsidian.indexer import ObsidianIndexer
     8→from slack_bot.obsidian.generators import WritingAssistant, ReplyGenerator, DecisionSupport, SearchAnalyzer, DeAIReviser, ZhihuGenerator
     9→from slack_bot.obsidian.info_manager import InfoManager
    10→from slack_bot.context.storage import ContextStorage as BaseStorage
    11→
    12→logger = setup_logger(__name__)
    13→
    14→class ObsidianContextStorage(BaseStorage):
    15→    """Specific storage for Obsidian bot to avoid mixing with Health bot."""
    16→    def __init__(self, channel_id: str):
    17→        super().__init__(channel_id)
    18→        from health import config
    19→        self.context_dir = config.DATA_DIR / "obsidian_context"
    20→        self.context_dir.mkdir(parents=True, exist_ok=True)
    21→        self.file_path = self.context_dir / f"{channel_id}.json"
    22→        self.state_file = self.context_dir / f"{channel_id}_state.json"
    23→
    24→    def save_state(self, state: Dict[str, Any]):
    25→        import json
    26→        with open(self.state_file, "w") as f:
    27→            json.dump(state, f)
    28→
    29→    def load_state(self) -> Dict[str, Any]:
    30→        import json
    31→        if not self.state_file.exists():
    32→            return {"mode": "none"}
    33→        try:
    34→            with open(self.state_file, "r") as f:
    35→                return json.load(f)
    36→        except:
    37→            return {"mode": "none"}
    38→
    39→class ObsidianDispatcher:
    40→    """Routes Obsidian Slack Bot messages."""
    41→
    42→    MODE_WRITE = "write"
    43→    MODE_REPLY = "reply"
    44→    MODE_DECIDE = "decide"
    45→    MODE_SEARCH = "search"
    46→    MODE_DEAI = "deai"
    47→    MODE_ZHIHU = "zhihu"
    48→    MODE_NOTE = "note"
    49→    MODE_ZHIHU_HUNTER = "zhihu-hunter"
    50→    MODE_INFO = "info"
    51→
    52→    VALID_MODES = {MODE_WRITE, MODE_REPLY, MODE_DECIDE, MODE_SEARCH, MODE_DEAI, MODE_ZHIHU, MODE_NOTE, MODE_ZHIHU_HUNTER, MODE_INFO}
    53→
    54→    def __init__(self, vault_path: str):
    55→        self.indexer = ObsidianIndexer(vault_path)
    56→        self.indexer.scan_vault()
    57→
    58→        # Initialize RAG vector store and note ingester
    59→        from slack_bot.obsidian.embeddings import get_embedding_provider
    60→        from slack_bot.obsidian.vector_store import ChromaVectorStore
    61→        from slack_bot.obsidian.note_ingester import NoteIngester
    62→
    63→        self.vector_store = ChromaVectorStore(embedding_provider=get_embedding_provider())
    64→        self.note_ingester = NoteIngester(
    65→            vault_path=Path(vault_path),
    66→            vector_store=self.vector_store,
    67→        )
    68→
    69→        self.generators = {
    70→            self.MODE_WRITE: WritingAssistant(self.indexer, self.vector_store),
    71→            self.MODE_REPLY: ReplyGenerator(self.indexer, self.vector_store),
    72→            self.MODE_DECIDE: DecisionSupport(self.indexer),
    73→            self.MODE_SEARCH: SearchAnalyzer(self.indexer),
    74→            self.MODE_DEAI: DeAIReviser(self.indexer),
    75→            self.MODE_ZHIHU: ZhihuGenerator(self.indexer, self.vector_store),
    76→        }
    77→
    78→        self.info_manager = InfoManager(vault_path=Path(vault_path))
    79→
    80→        token = os.environ.get("OBSIDIAN_SLACK_BOT_TOKEN")
    81→        self.client = WebClient(token=token)
    82→
    83→        # Initialize Zhihu Hunter components
    84→        from slack_bot.zhihu.zhihu_hunter import ZhihuHunter
    85→        from slack_bot.zhihu.zhihu_playwright_engine import ZhihuPlaywrightEngine
    86→        from slack_bot.zhihu.slack_interactive_gateway import SlackInteractiveGateway
    87→
    88→        zhihu_notify_channel = os.environ.get("ZHIHU_NOTIFY_CHANNEL", "")
    89→        self.zhihu_hunter = ZhihuHunter(
    90→            vault_path=Path(vault_path),
    91→            vector_store=self.vector_store,
    92→            indexer=self.indexer,
    93→        )
    94→        _zhihu_engine = ZhihuPlaywrightEngine(
    95→            slack_client=self.client,
    96→            notify_channel=zhihu_notify_channel,
    97→        )
    98→        self.zhihu_gateway = SlackInteractiveGateway(
    99→            slack_client=self.client,
   100→            hunter=self.zhihu_hunter,
   101→            engine=_zhihu_engine,
   102→            notify_channel=zhihu_notify_channel,
   103→        )
   104→        logger.info("ObsidianDispatcher initialized")
   105→
   106→    def dispatch(self, text: str, channel_id: str, user_id: str, response_ts: Optional[str] = None):
   107→        storage = ObsidianContextStorage(channel_id)
   108→        state = storage.load_state()
   109→        current_mode = state.get("mode", "none")
   110→
   111→        cmd = text.strip().lower()
   112→
   113→        # 1. Handle Commands
   114→        if cmd.startswith("mode "):
   115→            new_mode = cmd.split(" ", 1)[1].strip()
   116→            if new_mode in self.VALID_MODES:
   117→                storage.clear()
   118→                storage.save_state({"mode": new_mode})
   119→                msg = f"✅ Switched to mode: *{new_mode}* (History cleared)"
   120→                if new_mode == self.MODE_INFO:
   121→                    msg += (
   122→                        "\n\n📌 *使用说明:*\n"
   123→                        "• `project <名称>` — 切换/创建项目\n"
   124→                        "• 直接发送文字 — 新增条目，末尾加 `(From 来源)` 标注出处\n"
   125→                        "• `update` — 查看最近条目，选序号追加更新\n"
   126→                        "• `ask <问题>` — 基于项目信息回答问题（附带日期和来源）\n"
   127→                        "• `summary` — 汇总项目进展\n"
   128→                        "• `summary + <vault路径>` — 汇总 + 融合外部笔记\n"
   129→                        "• `search <关键词>` — 搜索当前项目\n"
   130→                        "• `list` — 查看所有项目"
   131→                    )
   132→                self._reply(channel_id, msg, response_ts)
   133→                return
   134→            else:
   135→                self._reply(channel_id, f"❌ Invalid mode. Available: {', '.join(sorted(self.VALID_MODES))}", response_ts)
   136→                return
   137→
   138→        if cmd == "clear":
   139→            storage.clear()
   140→            self._reply(channel_id, "🧹 Context cleared.", response_ts)
   141→            return
   142→
   143→        if cmd == "reload":
   144→            self.indexer.scan_vault()
   145→            self._reply(channel_id, "🔄 Vault reloaded.", response_ts)
   146→            return
   147→
   148→        # 2. Handle Content based on mode
   149→        if current_mode == "none":
   150→            self._reply(channel_id, "⚠ No mode selected. Use `mode write/reply/decide/search/deai/zhihu/note/info/zhihu-hunter` to start.", response_ts)
   151→            return
   152→
   153→        # MODE_NOTE: ingest text or URL into vault + vector store
   154→        if current_mode == self.MODE_NOTE:
   155→            try:
   156→                result = self.note_ingester.ingest(text)
   157→                self._reply(channel_id, result, response_ts)
   158→            except Exception as e:
   159→                logger.error(f"Note ingestion failed: {e}", exc_info=True)
   160→                self._reply(channel_id, f"🐛 Ingestion error: {str(e)}", response_ts)
   161→            return
   162→
   163→        # MODE_INFO: project info fragment tracker
   164→        if current_mode == self.MODE_INFO:
   165→            try:
   166→                self._handle_info_mode(text, channel_id, storage, state, response_ts)
   167→            except Exception as e:
   168→                logger.error(f"Info mode failed: {e}", exc_info=True)
   169→                self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
   170→            return
   171→
   172→        # MODE_ZHIHU_HUNTER: scan vault for keywords, surface Zhihu question candidates
   173→        #
   174→        # Command format:
   175→        #   开始                               → full scan, all dirs, auto keywords
   176→        #   article                            → scan Article only (Revised first, no time filter)
   177→        #   article agent开发                  → scan Article, focus on "agent开发"
   178→        #   notes agent开发                    → scan notes, focus on "agent开发"
   179→        #   关键词1 关键词2                     → skip scan, search directly with keywords
   180→        #   Article/8-xxx-Revised.md           → single file: grounded in that article
   181→        #   Article/*openclaw*-Revised.md      → glob: combine matching files
   182→        #   https://www.zhihu.com/question/... → direct URL: answer this specific question
   183→        #
   184→        # Dir shortcuts: notes / article / clippings / claw / deepfact / deep-dive
   185→        if current_mode == self.MODE_ZHIHU_HUNTER:
   186→            from slack_bot.zhihu.zhihu_hunter import DIR_SHORTCUTS
   187→            try:
   188→                trigger_words = {"开始", "hunt", "扫描", "start", "go"}
   189→                words = [w for w in text.strip().split() if w.lower() not in trigger_words]
   190→
   191→                if not words:
   192→                    # Bare trigger → full auto scan
   193→                    self._reply(channel_id, "🔍 正在扫描知乎候选问题（所有目录）...", response_ts)
   194→                    questions = self.zhihu_hunter.scan_and_hunt(since_days=7)
   195→
   196→                elif words and 'zhihu.com/question/' in words[0]:
   197→                    # Direct URL paste — user was invited or found a question to answer.
   198→                    # Any text after the URL is treated as an optional answer outline.
   199→                    # Strip trailing Slack/HTML junk chars (>, ", etc.) from the URL.
   200→                    import re as _re
   201→                    url = _re.sub(r'[>\'")\].,;]+$', '', words[0]).lstrip("<")
   202→                    outline = " ".join(words[1:])[:500] if len(words) > 1 else ""
   203→                    hint = f"（已记录回答思路）" if outline else ""
   204→                    self._reply(channel_id, f"🔍 获取问题标题，准备生成草稿{hint}...", response_ts)
   205→                    questions = self.zhihu_hunter.hunt_direct_url(url)
   206→                    if outline and questions:
   207→                        questions[0].outline = outline
   208→                    logger.info(f"ZhihuHunter: direct URL {url!r} → {questions[0].title!r}" +
   209→                                (f" outline={outline[:40]!r}" if outline else ""))
   210→
   211→                elif words and ('*' in words[0] or '?' in words[0]):
   212→                    # Glob pattern → combine all matched files for keyword extraction
   213→                    pattern = words[0]
   214→                    self._reply(
   215→                        channel_id,
   216→                        f"🔍 匹配 `{pattern}` 中的文件，提取观点搜索知乎问题...",
   217→                        response_ts,
   218→                    )
   219→                    questions = self.zhihu_hunter.scan_glob_files(pattern)
   220→                    logger.info(f"ZhihuHunter: glob '{pattern}' → {len(questions)} questions")
   221→
   222→                elif words and ('/' in words[0] or words[0].endswith('.md')):
   223→                    # Single-file mode: extract questions from one specific file,
   224→                    # and pass source_file so answers stay grounded in that article.
   225→                    rel_path = words[0]
   226→                    self._reply(
   227→                        channel_id,
   228→                        f"🔍 从 `{rel_path}` 提取观点，搜索匹配知乎问题...",
   229→                        response_ts,
   230→                    )
   231→                    questions = self.zhihu_hunter.scan_single_file(rel_path)
   232→                    logger.info(f"ZhihuHunter: single-file scan '{rel_path}' → {len(questions)} questions")
   233→
   234→                elif words[0].lower() in DIR_SHORTCUTS:
   235→                    # Dir-scoped scan with optional topic hint
   236→                    rel_dir = DIR_SHORTCUTS[words[0].lower()]
   237→                    topic_hint = " ".join(words[1:])
   238→                    hint_desc = f"，聚焦：{topic_hint}" if topic_hint else ""
   239→                    self._reply(
   240→                        channel_id,
   241→                        f"🔍 扫描 {rel_dir} 目录{hint_desc}，请稍候...",
   242→                        response_ts,
   243→                    )
   244→                    questions = self.zhihu_hunter.scan_and_hunt(
   245→                        since_days=7, dirs=[rel_dir], topic_hint=topic_hint
   246→                    )
   247→
   248→                else:
   249→                    # Direct keywords → skip vault scan entirely
   250→                    self._reply(channel_id, f"🔍 使用指定关键词搜索：{' · '.join(words)}", response_ts)
   251→                    questions = self.zhihu_hunter._find_questions(words)
   252→                    logger.info(f"ZhihuHunter: manual keywords {words} → {len(questions)} questions")
   253→
   254→                self.zhihu_gateway.post_question_cards(questions, channel=channel_id)
   255→            except Exception as e:
   256→                logger.error(f"Zhihu hunt failed: {e}", exc_info=True)
   257→                self._reply(channel_id, f"❌ 扫描失败：{e}", response_ts)
   258→            return
   259→
   260→        # Get history
   261→        history = storage.get_context()
   262→        generator = self.generators[current_mode]
   263→
   264→        try:
   265→            logger.info(f"Generating Obsidian response in mode: {current_mode}")
   266→            # The chat method handles first turn logic and returns updated history
   267→            # But wait, our generators' chat method appends to history internally.
   268→            # In our Slack setup, we want to append the result to ContextStorage.
   269→
   270→            # Note: generator.chat(text, history) returns (response, new_history_list)
   271→            # new_history_list contains [...history, {user: rich_prompt}, {assistant: response}]
   272→            response_text, updated_history = generator.chat(text, history)
   273→
   274→            # Since generator.chat already did the LLM call and returned history,
   275→            # we just need to save the ONLY NEW messages to our storage.
   276→            # However, during FIRST TURN, text is replaced by rich_prompt.
   277→
   278→            # For simplicity: completely overwrite storage with updated_history
   279→            # ContextStorage doesn't support overwrite easily, but we can clear and re-add.
   280→            # Better: Modify ObsidianContextStorage to support set_context.
   281→
   282→            # Actually, let's just use the updated_history and manually add to storage
   283→            # Warning: first turn 'user' message in storage will be the RICH PROMPT
   284→            # This is actually better for context continuity in Slack too.
   285→
   286→            storage.clear()
   287→            for msg in updated_history:
   288→                storage.add_message(msg["role"], msg["content"])
   289→
   290→            self._reply(channel_id, response_text, response_ts)
   291→
   292→        except Exception as e:
   293→            logger.error(f"Obsidian generation failed: {e}", exc_info=True)
   294→            self._reply(channel_id, f"🐛 Error: {str(e)}", response_ts)
   295→
   296→    def _handle_info_mode(
   297→        self,
   298→        text: str,
   299→        channel_id: str,
   300→        storage: ObsidianContextStorage,
   301→        state: dict,
   302→        response_ts: Optional[str],
   303→    ) -> None:
   304→        """Route commands within MODE_INFO."""
   305→        cmd = text.strip()
   306→        cmd_lower = cmd.lower()
   307→        active_project = state.get("active_project", "")
   308→        pending_idx = state.get("pending_update_entry_index")
   309→
   310→        # --- Command: project <name> ---
   311→        if cmd_lower.startswith("project "):
   312→            project_name = cmd[8:].strip()
   313→            if not project_name or "/" in project_name or ".." in project_name:
   314→                self._reply(channel_id, "⚠ 无效项目名。", response_ts)
   315→                return
   316→            state["active_project"] = project_name
   317→            state["pending_update_entry_index"] = None
   318→            storage.save_state(state)
   319→            self._reply(channel_id, f"📂 已切换到项目: *{project_name}*", response_ts)
   320→            return
   321→
   322→        # --- Command: list ---
   323→        if cmd_lower == "list":
   324→            result = self.info_manager.list_projects()
   325→            self._reply(channel_id, result, response_ts)
   326→            return
   327→
   328→        # Guard: require active project for all other commands
   329→        if not active_project:
   330→            self._reply(
   331→                channel_id,
   332→                "⚠ 请先选择项目。用 `project <名称>` 切换或创建项目。\n用 `list` 查看已有项目。",
   333→                response_ts,
   334→            )
   335→            return
   336→
   337→        # --- Command: update ---
   338→        if cmd_lower == "update":
   339→            msg, entries = self.info_manager.list_recent(active_project)
   340→            if entries:
   341→                # Store entries in state for later reference
   342→                state["pending_update_entries"] = [e.model_dump() for e in entries]
   343→                state["pending_update_entry_index"] = -1  # waiting for index
   344→                storage.save_state(state)
   345→            self._reply(channel_id, msg, response_ts)
   346→            return
   347→
   348→        # --- State: waiting for update index selection ---
   349→        if pending_idx == -1:
   350→            try:
   351→                idx = int(cmd.strip()) - 1  # user sees 1-based
   352→                entries_data = state.get("pending_update_entries", [])
   353→                if 0 <= idx < len(entries_data):
   354→                    state["pending_update_entry_index"] = idx
   355→                    storage.save_state(state)
   356→                    self._reply(
   357→                        channel_id,
   358→                        f"✏ 已选择第 {idx + 1} 条。请发送更新内容：",
   359→                        response_ts,
   360→                    )
   361→                    return
   362→                else:
   363→                    state["pending_update_entry_index"] = None
   364→                    storage.save_state(state)
   365→                    self._reply(channel_id, "⚠ 无效序号，已退出更新模式。", response_ts)
   366→                    return
   367→            except ValueError:
   368→                # Not a number — treat as cancel, fall through to add_entry
   369→                state["pending_update_entry_index"] = None
   370→                state.pop("pending_update_entries", None)
   371→                storage.save_state(state)
   372→
   373→        # --- State: waiting for update content ---
   374→        if isinstance(pending_idx, int) and pending_idx >= 0:
   375→            from slack_bot.obsidian.info_manager import InfoEntry
   376→
   377→            entries_data = state.get("pending_update_entries", [])
   378→            entries = [InfoEntry(**d) for d in entries_data]
   379→            result = self.info_manager.update_entry(
   380→                active_project, entries, pending_idx, cmd
   381→            )
   382→            state["pending_update_entry_index"] = None
   383→            state.pop("pending_update_entries", None)
   384→            storage.save_state(state)
   385→            self._reply(channel_id, result, response_ts)
   386→            return
   387→
   388→        # --- Command: summary ---
   389→        if cmd_lower.startswith("summary"):
   390→            extra_paths = None
   391→            if "+" in cmd:
   392→                _, paths_str = cmd.split("+", 1)
   393→                extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
   394→            result = self.info_manager.summarize(active_project, extra_paths)
   395→            self._reply(channel_id, result, response_ts)
   396→            return
   397→
   398→        # --- Command: ask <question> ---
   399→        if cmd_lower.startswith("ask "):
   400→            question = cmd[4:].strip()
   401→            if question:
   402→                extra_paths = None
   403→                if "+" in question:
   404→                    question, paths_str = question.split("+", 1)
   405→                    extra_paths = [p.strip() for p in paths_str.split(",") if p.strip()]
   406→                    question = question.strip()
   407→                result = self.info_manager.ask(active_project, question, extra_paths)
   408→                self._reply(channel_id, result, response_ts)
   409→                return
   410→
   411→        # --- Command: search <keyword> ---
   412→        if cmd_lower.startswith("search "):
   413→            keyword = cmd[7:].strip()
   414→            if keyword:
   415→                result = self.info_manager.search_entries(active_project, keyword)
   416→                self._reply(channel_id, result, response_ts)
   417→                return
   418→
   419→        # --- Default: add new entry ---
   420→        result = self.info_manager.add_entry(active_project, cmd)
   421→        self._reply(channel_id, result, response_ts)
   422→
   423→    def _reply(self, channel_id: str, text: str, ts: Optional[str] = None):
   424→        from slack_bot.utils.mrkdwn import SlackFormatter
   425→
   426→        # Slack API limits (official documentation):
   427→        # - chat.postMessage: 40,000 chars
   428→        # - chat.update: 4,000 chars
   429→        # Slack markdown formatting can expand text by 50-100% (bold, links, code blocks, etc.)
   430→        TRUNCATE_WARNING = "\n\n⚠ (Response truncated, full text uploaded as file)"
   431→        FILE_UPLOAD_HINT = "📄 Full response uploaded as file above ☝"
   432→
   433→        # Calculate safe pre-formatting limit with aggressive overhead buffer
   434→        if ts:
   435→            # For updates: 4000 limit - 60% overhead for safety = ~1600 chars
   436→            max_raw_length = 1500
   437→        else:
   438→            # For new messages: 40000 limit - 40% overhead = ~24000 chars
   439→            max_raw_length = 24000
   440→
   441→        original_length = len(text)
   442→        should_upload_file = original_length > max_raw_length
   443→
   444→        if should_upload_file:
   445→            # Upload full text as file first
   446→            self._upload_as_file(channel_id, text, ts)
   447→
   448→            # Then send a truncated preview
   449→            truncate_at = text.rfind('\n\n', 0, 800)
   450→            if truncate_at == -1 or truncate_at < 400:
   451→                truncate_at = text.rfind('\n', 0, 800)
   452→            if truncate_at == -1 or truncate_at < 400:
   453→                truncate_at = 800
   454→
   455→            preview_text = text[:truncate_at] + f"\n\n...\n\n{FILE_UPLOAD_HINT}"
   456→            formatted_text = SlackFormatter.convert(preview_text)
   457→            logger.info(f"Uploaded full response ({original_length} chars) as file, sending preview ({len(preview_text)} chars)")
   458→        else:
   459→            # Text is short enough, just format and send
   460→            formatted_text = SlackFormatter.convert(text)
   461→
   462→            # Final safety check after formatting
   463→            final_limit = 3900 if ts else 39900
   464→            if len(formatted_text) > final_limit:
   465→                logger.warning(f"Formatted text ({len(formatted_text)} chars) exceeds limit after formatting. Uploading as file.")
   466→                self._upload_as_file(channel_id, text, ts)
   467→                # Send simple notification
   468→                formatted_text = FILE_UPLOAD_HINT
   469→
   470→        # Send the message
   471→        try:
   472→            if ts:
   473→                self.client.chat_update(channel=channel_id, ts=ts, text=formatted_text)
   474→            else:
   475→                self.client.chat_postMessage(channel=channel_id, text=formatted_text)
   476→        except Exception as e:
   477→            if "msg_too_long" in str(e):
   478→                logger.error(f"Still got msg_too_long error even after truncation. Text length: {len(formatted_text)}")
   479→                # Ultimate fallback: send error message only
   480→                try:
   481→                    error_msg = "❌ Response formatting error. Please check the uploaded file above."
   482→                    if ts:
   483→                        self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   484→                    else:
   485→                        self.client.chat_postMessage(channel=channel_id, text=error_msg)
   486→                except:
   487→                    logger.error("Failed to send even error message", exc_info=True)
   488→            else:
   489→                raise
   490→
   491→    def _upload_as_file(self, channel_id: str, content: str, ts: Optional[str] = None):
   492→        """Fallback: upload long responses as text files."""
   493→        try:
   494→            import tempfile
   495→            from datetime import datetime
   496→
   497→            filename = f"response_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
   498→
   499→            with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as tmp:
   500→                tmp.write(content)
   501→                tmp_path = tmp.name
   502→
   503→            self.client.files_upload_v2(
   504→                channel=channel_id,
   505→                file=tmp_path,
   506→                filename=filename,
   507→                initial_comment="📄 Response too long for Slack message, uploaded as file:",
   508→                thread_ts=ts
   509→            )
   510→
   511→            os.unlink(tmp_path)
   512→            logger.info(f"Uploaded long response as file: {filename}")
   513→        except Exception as e:
   514→            logger.error(f"Failed to upload file: {e}", exc_info=True)
   515→            # Ultimate fallback: send error message
   516→            error_msg = "❌ Response too long to display. Please try a more specific query."
   517→            if ts:
   518→                self.client.chat_update(channel=channel_id, ts=ts, text=error_msg)
   519→            else:
   520→                self.client.chat_postMessage(channel=channel_id, text=error_msg)
   521→
   522→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

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"
                        "• 直接发送文字 — 新增条目，末尾加 `(From 来源)` 标注出处\n"
                        "• `update` — 查看最近条目，选序号追加更新\n"
                        "• `ask <问题>` — 基于项目信息回答问题（附带日期和来源）\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)


现在我需要看看 info_manager.py，这个文件负责管理 info 目录的。


现在让我看看 InfoManager 是怎么创建 info 目录的：


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


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


     1→"""Project info fragment tracker for Obsidian Bot mode info."""
     2→
     3→import re
     4→from datetime import datetime, timezone
     5→from pathlib import Path
     6→from typing import List, Optional, Tuple
     7→
     8→from pydantic import BaseModel, Field
     9→
    10→from health.utils.logging_config import setup_logger
    11→from slack_bot.llm.gemini import GeminiLLM
    12→
    13→logger = setup_logger(__name__)
    14→
    15→# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
    16→_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\s*$')
    17→
    18→INFO_SYSTEM_PROMPT = """You are a concise project note-taker.
    19→You help organize and comment on project information fragments.
    20→Always respond in Chinese unless the input is entirely in English."""
    21→
    22→
    23→class InfoEntry(BaseModel):
    24→    """Single information fragment parsed from a weekly Markdown file."""
    25→
    26→    timestamp: str
    27→    tags: List[str] = Field(default_factory=list)
    28→    source: str = ""
    29→    raw_content: str
    30→    comment: str = ""
    31→    updates: List[str] = Field(default_factory=list)
    32→    # Line position in the source file for update insertion
    33→    source_line_start: int = 0
    34→    source_line_end: int = 0
    35→
    36→
    37→class InfoManager:
    38→    """Manages per-project weekly info files in the Obsidian vault.
    39→
    40→    Directory layout::
    41→
    42→        vault/info/<project>/YYYY-Www.md
    43→    """
    44→
    45→    def __init__(self, vault_path: Path):
    46→        self.vault_path = vault_path
    47→        self.info_dir = vault_path / "info"
    48→        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)
    49→
    50→    # ── Public API ────────────────────────────────────────────────────
    51→
    52→    def add_entry(self, project: str, content: str) -> str:
    53→        """Add a new info entry to the current week file.
    54→
    55→        Args:
    56→            project: Project directory name.
    57→            content: Raw user input (text with optional '(From xxx)' suffix).
    58→
    59→        Returns:
    60→            Slack confirmation message.
    61→        """
    62→        raw_text, source = self._extract_source(content)
    63→        if not raw_text.strip():
    64→            return "⚠ 内容为空，未保存。"
    65→
    66→        # Load recent entries for context
    67→        week_file = self._get_week_file(project)
    68→        existing = ""
    69→        if week_file.exists():
    70→            existing = week_file.read_text(encoding="utf-8")
    71→
    72→        comment, tags = self._generate_comment_and_tags(raw_text, project, existing)
    73→
    74→        now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
    75→        tag_str = " ".join(tags) if tags else ""
    76→        header = f"### {now} | {tag_str}".rstrip()
    77→
    78→        lines = [
    79→            header,
    80→            "",
    81→            *[f"> {line}" for line in raw_text.strip().split("\n")],
    82→            "",
    83→        ]
    84→        if source:
    85→            lines.append(f"来源: {source}")
    86→            lines.append("")
    87→        lines.append(f"**点评**: {comment}")
    88→        lines.append("")
    89→        lines.append("---")
    90→        lines.append("")
    91→
    92→        entry_text = "\n".join(lines)
    93→
    94→        # Ensure directory and file exist
    95→        week_file.parent.mkdir(parents=True, exist_ok=True)
    96→        with open(week_file, "a", encoding="utf-8") as f:
    97→            f.write(entry_text)
    98→
    99→        tag_display = f" {tag_str}" if tag_str else ""
   100→        source_display = f"\n📎 来源: {source}" if source else ""
   101→        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
   102→        return f"✅ 已保存到 *{project}*{tag_display}{source_display}\n\n> {comment}"
   103→
   104→    def list_recent(self, project: str, count: int = 5) -> Tuple[str, List[InfoEntry]]:
   105→        """List the most recent entries for a project.
   106→
   107→        Args:
   108→            project: Project directory name.
   109→            count: Max entries to show.
   110→
   111→        Returns:
   112→            Tuple of (formatted message, entry list).
   113→        """
   114→        entries = self._load_recent_entries(project, count)
   115→        if not entries:
   116→            return f"📭 项目 *{project}* 暂无记录。", []
   117→
   118→        lines = [f"📋 *{project}* 最近 {len(entries)} 条信息：\n"]
   119→        for i, e in enumerate(entries, 1):
   120→            tag_str = " ".join(e.tags) if e.tags else ""
   121→            # Truncate raw content for display
   122→            preview = e.raw_content[:60].replace("\n", " ")
   123→            if len(e.raw_content) > 60:
   124→                preview += "..."
   125→            update_count = f" (+{len(e.updates)}更新)" if e.updates else ""
   126→            lines.append(f"*{i}.* {e.timestamp} {tag_str}{update_count}\n   {preview}")
   127→        lines.append("\n输入序号选择要更新的条目，或发送其他内容新增信息。")
   128→        return "\n".join(lines), entries
   129→
   130→    def update_entry(
   131→        self, project: str, entries: List[InfoEntry], index: int, content: str
   132→    ) -> str:
   133→        """Append an update to an existing entry.
   134→
   135→        Args:
   136→            project: Project directory name.
   137→            entries: Entry list from list_recent.
   138→            index: 0-based index into entries.
   139→            content: Update content from user.
   140→
   141→        Returns:
   142→            Slack confirmation message.
   143→        """
   144→        if index < 0 or index >= len(entries):
   145→            return f"⚠ 无效序号，请输入 1-{len(entries)} 之间的数字。"
   146→
   147→        entry = entries[index]
   148→        now = datetime.now(timezone.utc).strftime("%Y-%m-%d")
   149→        update_line = f"**更新 {now}**: {content.strip()}"
   150→
   151→        # Find the source file and insert before the closing ---
   152→        week_files = self._get_week_files(project)
   153→        for wf in week_files:
   154→            file_lines = wf.read_text(encoding="utf-8").split("\n")
   155→            # Match entry by timestamp header
   156→            header_pattern = f"### {entry.timestamp}"
   157→            for i, line in enumerate(file_lines):
   158→                if line.startswith(header_pattern):
   159→                    # Find the next --- separator
   160→                    for j in range(i + 1, len(file_lines)):
   161→                        if file_lines[j].strip() == "---":
   162→                            # Insert update before ---
   163→                            file_lines.insert(j, "")
   164→                            file_lines.insert(j, update_line)
   165→                            wf.write_text("\n".join(file_lines), encoding="utf-8")
   166→                            logger.info(f"InfoManager: updated entry {entry.timestamp} in {project}")
   167→                            return f"✅ 已更新 *{entry.timestamp}* 的记录。\n\n> {update_line}"
   168→                    break
   169→
   170→        return "⚠ 未找到对应条目，可能文件已被修改。"
   171→
   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→
   214→    def ask(self, project: str, question: str, extra_paths: Optional[List[str]] = None) -> str:
   215→        """Answer a free-form question based on project info entries.
   216→
   217→        Args:
   218→            project: Project directory name.
   219→            question: User's question.
   220→            extra_paths: Optional vault-relative paths to include as context.
   221→
   222→        Returns:
   223→            LLM-generated answer.
   224→        """
   225→        all_content = self._load_all_content(project)
   226→        if not all_content.strip():
   227→            return f"📭 项目 *{project}* 暂无记录，无法回答。"
   228→
   229→        extra_text = ""
   230→        if extra_paths:
   231→            for rel_path in extra_paths:
   232→                fp = self.vault_path / rel_path.strip()
   233→                if fp.exists():
   234→                    text = fp.read_text(encoding="utf-8", errors="ignore")[:8000]
   235→                    extra_text += f"\n\n=== 外部笔记: {rel_path} ===\n{text}"
   236→                else:
   237→                    extra_text += f"\n\n⚠ 未找到: {rel_path}"
   238→
   239→        prompt = f"""根据以下项目 "{project}" 的碎片信息，回答用户问题。
   240→
   241→要求：
   242→- 只基于提供的信息回答，没有相关信息就直说
   243→- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核
   244→- 如果条目没有来源字段，只标注日期
   245→- 用中文回答，简洁直接
   246→
   247→=== 项目碎片信息 ===
   248→{all_content}
   249→{extra_text}
   250→
   251→=== 用户问题 ===
   252→{question}
   253→"""
   254→        response, _ = self.llm.generate_response(prompt, [])
   255→        logger.info(f"InfoManager: answered question for {project}")
   256→        return response
   257→
   258→    def search_entries(self, project: str, keyword: str) -> str:
   259→        """Search entries by keyword across all week files.
   260→
   261→        Args:
   262→            project: Project directory name.
   263→            keyword: Search term.
   264→
   265→        Returns:
   266→            Formatted search results.
   267→        """
   268→        week_files = self._get_week_files(project)
   269→        if not week_files:
   270→            return f"📭 项目 *{project}* 暂无记录。"
   271→
   272→        matches: List[str] = []
   273→        kw_lower = keyword.lower()
   274→        for wf in week_files:
   275→            entries = self._parse_entries(wf)
   276→            for e in entries:
   277→                full_text = f"{e.raw_content} {e.comment} {' '.join(e.updates)}"
   278→                if kw_lower in full_text.lower():
   279→                    tag_str = " ".join(e.tags) if e.tags else ""
   280→                    preview = e.raw_content[:80].replace("\n", " ")
   281→                    matches.append(f"• {e.timestamp} {tag_str}\n  {preview}")
   282→
   283→        if not matches:
   284→            return f"🔍 在 *{project}* 中未找到包含 \"{keyword}\" 的记录。"
   285→
   286→        header = f"🔍 在 *{project}* 中找到 {len(matches)} 条匹配 \"{keyword}\"：\n"
   287→        return header + "\n".join(matches[:10])
   288→
   289→    def list_projects(self) -> str:
   290→        """List all project directories.
   291→
   292→        Returns:
   293→            Formatted project list.
   294→        """
   295→        if not self.info_dir.exists():
   296→            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"
   297→
   298→        projects = sorted(
   299→            d.name for d in self.info_dir.iterdir() if d.is_dir()
   300→        )
   301→        if not projects:
   302→            return "📭 暂无任何项目。使用 `project <名称>` 创建第一个项目。"
   303→
   304→        lines = [f"📁 共 {len(projects)} 个项目：\n"]
   305→        for p in projects:
   306→            week_files = list((self.info_dir / p).glob("*.md"))
   307→            entry_count = sum(
   308→                len(self._parse_entries(wf)) for wf in week_files
   309→            )
   310→            lines.append(f"• *{p}* — {entry_count} 条记录，{len(week_files)} 个周文件")
   311→        return "\n".join(lines)
   312→
   313→    # ── Internal ──────────────────────────────────────────────────────
   314→
   315→    def _get_week_file(self, project: str) -> Path:
   316→        """Get the path for the current week's file, creating dir if needed."""
   317→        now = datetime.now(timezone.utc)
   318→        iso_year, iso_week, _ = now.isocalendar()
   319→        filename = f"{iso_year}-W{iso_week:02d}.md"
   320→        project_dir = self.info_dir / project
   321→        project_dir.mkdir(parents=True, exist_ok=True)
   322→        return project_dir / filename
   323→
   324→    def _get_week_files(self, project: str) -> List[Path]:
   325→        """Get all week files for a project, sorted newest first."""
   326→        project_dir = self.info_dir / project
   327→        if not project_dir.exists():
   328→            return []
   329→        files = sorted(project_dir.glob("*.md"), reverse=True)
   330→        return files
   331→
   332→    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
   333→        """Parse a weekly Markdown file into structured entries."""
   334→        if not file_path.exists():
   335→            return []
   336→
   337→        text = file_path.read_text(encoding="utf-8")
   338→        entries: List[InfoEntry] = []
   339→
   340→        # Split by ### headers
   341→        blocks = re.split(r'^(### .+)$', text, flags=re.MULTILINE)
   342→
   343→        # blocks = [preamble, header1, body1, header2, body2, ...]
   344→        i = 1
   345→        while i < len(blocks) - 1:
   346→            header_line = blocks[i].strip()
   347→            body = blocks[i + 1]
   348→
   349→            # Parse header: ### 2026-03-17 14:30 | #ci #性能
   350→            header_match = re.match(
   351→                r'### (\d{4}-\d{2}-\d{2} \d{2}:\d{2})\s*(?:\|\s*(.*))?$',
   352→                header_line,
   353→            )
   354→            if not header_match:
   355→                i += 2
   356→                continue
   357→
   358→            timestamp = header_match.group(1)
   359→            tag_str = header_match.group(2) or ""
   360→            tags = [t.strip() for t in tag_str.split() if t.strip().startswith("#")]
   361→
   362→            # Parse body
   363→            raw_lines: List[str] = []
   364→            comment = ""
   365→            source = ""
   366→            updates: List[str] = []
   367→
   368→            for line in body.split("\n"):
   369→                stripped = line.strip()
   370→                if stripped.startswith("> "):
   371→                    raw_lines.append(stripped[2:])
   372→                elif stripped.startswith("来源: ") or stripped.startswith("来源:"):
   373→                    source = stripped.split(":", 1)[1].strip()
   374→                elif stripped.startswith("**点评**:") or stripped.startswith("**点评**: "):
   375→                    comment = stripped.split(":", 1)[1].strip()
   376→                elif stripped.startswith("**更新"):
   377→                    updates.append(stripped)
   378→
   379→            entries.append(InfoEntry(
   380→                timestamp=timestamp,
   381→                tags=tags,
   382→                source=source,
   383→                raw_content="\n".join(raw_lines),
   384→                comment=comment,
   385→                updates=updates,
   386→            ))
   387→            i += 2
   388→
   389→        return entries
   390→
   391→    def _load_recent_entries(self, project: str, count: int) -> List[InfoEntry]:
   392→        """Load the most recent N entries across week files."""
   393→        week_files = self._get_week_files(project)
   394→        all_entries: List[InfoEntry] = []
   395→        for wf in week_files:
   396→            entries = self._parse_entries(wf)
   397→            all_entries.extend(reversed(entries))  # newest first within file
   398→            if len(all_entries) >= count:
   399→                break
   400→        return all_entries[:count]
   401→
   402→    def _load_all_content(self, project: str) -> str:
   403→        """Load all week files as raw text, oldest first."""
   404→        week_files = self._get_week_files(project)
   405→        parts: List[str] = []
   406→        for wf in reversed(week_files):  # oldest first
   407→            parts.append(f"=== {wf.stem} ===\n")
   408→            parts.append(wf.read_text(encoding="utf-8"))
   409→        return "\n".join(parts)
   410→
   411→    def _extract_source(self, content: str) -> Tuple[str, str]:
   412→        """Extract '(From xxx)' source tag from content.
   413→
   414→        Returns:
   415→            Tuple of (cleaned text, source name or empty string).
   416→        """
   417→        match = _SOURCE_RE.search(content)
   418→        if match:
   419→            source = match.group(1).strip()
   420→            text = content[:match.start()].strip()
   421→            return text, source
   422→        return content, ""
   423→
   424→    def _generate_comment_and_tags(
   425→        self, content: str, project: str, context: str
   426→    ) -> Tuple[str, List[str]]:
   427→        """Use LLM to generate a one-line comment and 1-3 tags.
   428→
   429→        Args:
   430→            content: The raw information text.
   431→            project: Current project name.
   432→            context: Existing entries in the current week file (for tag consistency).
   433→
   434→        Returns:
   435→            Tuple of (comment string, list of tag strings like ["#ci", "#性能"]).
   436→        """
   437→        # Truncate context to avoid prompt bloat
   438→        ctx_preview = context[-1500:] if len(context) > 1500 else context
   439→
   440→        prompt = f"""根据以下信息，生成：
   441→1. 一句话点评（不超过50字，直接说判断和建议，不要废话）
   442→2. 1-3 个标签（用 # 前缀，简短，和已有标签保持一致性）
   443→
   444→项目名: {project}
   445→已有条目（参考标签风格）:
   446→{ctx_preview}
   447→
   448→新信息:
   449→{content}
   450→
   451→严格按以下 JSON 格式输出，不要输出其他内容:
   452→{{"comment": "...", "tags": ["#tag1", "#tag2"]}}"""
   453→
   454→        try:
   455→            response, _ = self.llm.generate_response(prompt, [])
   456→            # Parse JSON from response
   457→            json_match = re.search(r'\{[^}]+\}', response, re.DOTALL)
   458→            if json_match:
   459→                import json
   460→                data = json.loads(json_match.group(0))
   461→                comment = data.get("comment", "")
   462→                tags = data.get("tags", [])
   463→                # Ensure tags have # prefix
   464→                tags = [t if t.startswith("#") else f"#{t}" for t in tags]
   465→                return comment, tags[:3]
   466→        except Exception as e:
   467→            logger.warning(f"LLM comment/tag generation failed: {e}")
   468→
   469→        return "（自动点评生成失败）", []
   470→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

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

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__)

# Match (From xxx) or (from xxx) — case-insensitive, captures the source name
_SOURCE_RE = re.compile(r'\((?:[Ff]rom|FROM)\s+(.+?)\)\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)
    source: str = ""
    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)

    # ── 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 with optional '(From xxx)' suffix).

        Returns:
            Slack confirmation message.
        """
        raw_text, source = self._extract_source(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:
            lines.append(f"来源: {source}")
            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 ""
        source_display = f"\n📎 来源: {source}" if source else ""
        logger.info(f"InfoManager: added entry to {project}/{week_file.name}{tag_display}")
        return f"✅ 已保存到 *{project}*{tag_display}{source_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 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}" 的碎片信息，回答用户问题。

要求：
- 只基于提供的信息回答，没有相关信息就直说
- 每个论点必须标注出处：[日期, 来源]（如 [2026-03-17, 张三]），方便人工复核
- 如果条目没有来源字段，只标注日期
- 用中文回答，简洁直接

=== 项目碎片信息 ===
{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:
        """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 = ""
            source = ""
            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("来源:"):
                    source = stripped.split(":", 1)[1].strip()
                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,
                source=source,
                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 _extract_source(self, content: str) -> Tuple[str, str]:
        """Extract '(From xxx)' source tag from content.

        Returns:
            Tuple of (cleaned text, source name or empty string).
        """
        match = _SOURCE_RE.search(content)
        if match:
            source = match.group(1).strip()
            text = content[:match.start()].strip()
            return text, source
        return content, ""

    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 "（自动点评生成失败）", []


现在我需要检查一下同步系统。让我看看用户使用的是哪种同步方式。我注意到用户提到这是"同步到手机和电脑上的obsidian软件"，通常 Obsidian 同步使用的是：
1. Obsidian Sync（官方同步）
2. iCloud Drive
3. Dropbox
4. Google Drive
5. Syncthing
6. Git

我需要检查是否有任何同步工具在运行，或者是否有任何隐藏的配置文件。


现在让我检查一下同步相关的配置，看看是否有任何同步工具在运行：


Bash

ps aux | grep -E 'sync|dropbox|onedrive|icloud|obsidian' | grep -v grep

Check running sync processes


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"


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


Check config directories


Check mounted drives


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"


root          47  0.0  0.0      0     0 ?        S    Feb25   0:13 [memcg_priod_async]
root      144931  0.0  0.4 50725524 32632 ?      S<sl Feb25   2:16 /root/.cache/ms-playwright/chromium_headless_shell-1208/chrome-headless-shell-linux64/chrome-headless-shell --disable-field-trial-config --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-back-forward-cache --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --no-default-browser-check --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=AvoidUnnecessaryBeforeUnloadCheckSync,BoundaryEventDispatchTracksNodeRemoval,DestroyProfileOnBrowserClose,DialMediaRouteProvider,GlobalMediaControls,HttpsUpgrades,LensOverlay,MediaRouter,PaintHolding,ThirdPartyStoragePartitioning,Translate,AutoDeElevate,RenderDocument,OptimizationHints --enable-features=CDPScreenshotNewSurface --allow-pre-commit-input --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --no-service-autorun --export-tagged-pdf --disable-search-engine-choice-screen --unsafely-disable-devtools-self-xss-warnings --edge-skip-compat-layer-relaunch --enable-automation --disable-infobars --disable-search-engine-choice-screen --disable-sync --enable-unsafe-swiftshader --headless --hide-scrollbars --mute-audio --blink-settings=primaryHoverType=2,availableHoverTypes=2,primaryPointerType=4,availablePointerTypes=4 --no-sandbox --user-data-dir=/tmp/playwright_chromiumdev_profile-r9HqTO --remote-debugging-pipe --no-startup-window
root     3388172  0.0  1.7 1437160 135908 ?      Ssl  Mar17   1:29 /root/projects/butler/venv/bin/python scripts/obsidian_bot.py


total 4
drwxr-xr-x  6 root root   69 Mar 16 16:24 .
dr-xr-x--- 18 root root 4096 Mar 19 12:11 ..
drwx------  2 root root   27 Feb 26 20:46 caddy
drwxr-xr-x  2 root root   27 Mar 16 16:24 ccstatusline
drwx------  2 root root   25 Feb 25 19:08 clawhub
drwxr-xr-x  3 root root   18 Feb 24 18:18 systemd


NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINTS
sr0     11:0    1   19M  0 rom
vda    253:0    0  120G  0 disk
└─vda1 253:1    0  120G  0 part /


现在我需要进一步理解问题。用户说他们在手机和电脑上都看不到 `info` 子目录。

根据我的观察：
1. `info` 目录确实存在于 `/root/vault/obsidian_vault/obsidian/obsidian/info`
2. 它有 4 个子目录：boss, business, gpu, openclaw
3. 每个子目录下有一个文件：`2026-W12.md`
4. `info` 目录的权限是 0755，用户 root，组 root
5. 没有看到明显的同步进程在运行

问题可能在于：
1. 同步工具没有同步这个目录（可能有忽略规则）
2. 这个目录是在错误的 vault 位置创建的
3. Obsidian 的 vault 根目录不是这个路径

让我检查一下 Obsidian 的 vault 实际是怎么配置的，看看是否有 .obsidian 配置目录在其他位置。


让我检查一下 Obsidian 的配置文件位置，看看实际的 vault 目录在哪里：


find "/root/vault" -name ".obsidian" -type d 2>/dev/null

Find .obsidian config directories


ls -laR "/root/vault/obsidian_vault/obsidian/Documents/obsidian" 2>/dev/null

Check Documents/obsidian directory


(Bash completed with no output)