     1→"""ChromaDB vector store for the Second Brain RAG system."""
     2→
     3→import hashlib
     4→import re
     5→from datetime import datetime, timezone
     6→from pathlib import Path
     7→from typing import List, Optional
     8→
     9→from pydantic import BaseModel, Field
    10→
    11→from health.utils.logging_config import setup_logger
    12→from slack_bot.obsidian.embeddings import EmbeddingProvider, get_embedding_provider
    13→
    14→logger = setup_logger(__name__)
    15→
    16→
    17→class ChunkMetadata(BaseModel):
    18→    """Metadata for a document chunk stored in the vector DB."""
    19→
    20→    source_path: str
    21→    chunk_index: int
    22→    header_hierarchy: str
    23→    note_title: Optional[str] = None
    24→    tags: List[str] = Field(default_factory=list)
    25→    ingested_at: str  # ISO datetime string
    26→
    27→
    28→class SearchResult(BaseModel):
    29→    """A single semantic search result."""
    30→
    31→    text: str
    32→    source_path: str
    33→    header_hierarchy: str
    34→    distance: float
    35→    note_title: Optional[str] = None
    36→
    37→
    38→def split_md_by_headers(body: str) -> List[str]:
    39→    """Split a markdown body by H2/H3 headers into chunks.
    40→
    41→    Args:
    42→        body: Markdown text to split.
    43→
    44→    Returns:
    45→        List of non-empty chunks of at least 50 characters.
    46→    """
    47→    raw_chunks = re.split(r'\n(?=#{2,3} )', body)
    48→    return [chunk.strip() for chunk in raw_chunks if len(chunk.strip()) >= 50]
    49→
    50→
    51→class ChromaVectorStore:
    52→    """ChromaDB-backed vector store for Obsidian notes."""
    53→
    54→    COLLECTION_NAME = "obsidian_notes"
    55→
    56→    def __init__(
    57→        self,
    58→        embedding_provider: Optional[EmbeddingProvider] = None,
    59→        db_path: Optional[Path] = None,
    60→    ) -> None:
    61→        """Initialize the ChromaDB vector store.
    62→
    63→        Args:
    64→            embedding_provider: Provider for generating embeddings. Defaults to
    65→                get_embedding_provider().
    66→            db_path: Path to ChromaDB persistence directory. Defaults to
    67→                DATA_DIR/vector_db.
    68→        """
    69→        if embedding_provider is None:
    70→            embedding_provider = get_embedding_provider()
    71→        self.embedding_provider = embedding_provider
    72→
    73→        if db_path is None:
    74→            from health import config
    75→            db_path = config.DATA_DIR / "vector_db"
    76→
    77→        db_path.mkdir(parents=True, exist_ok=True)
    78→        self.db_path = db_path
    79→
    80→        try:
    81→            import chromadb
    82→            self._client = chromadb.PersistentClient(path=str(db_path))
    83→            self._collection = self._client.get_or_create_collection(
    84→                name=self.COLLECTION_NAME,
    85→                metadata={"hnsw:space": "cosine"},
    86→            )
    87→            logger.info(
    88→                f"ChromaVectorStore initialized at {db_path} "
    89→                f"(collection: {self.COLLECTION_NAME})"
    90→            )
    91→        except ImportError:
    92→            raise ImportError("chromadb is not installed. Run: pip install chromadb>=0.5.0")
    93→
    94→    def _make_doc_id(self, source_path: str, chunk_index: int) -> str:
    95→        """Generate a deterministic 16-char document ID for idempotent upsert.
    96→
    97→        Args:
    98→            source_path: Path to the source file.
    99→            chunk_index: Index of the chunk within the file.
   100→
   101→        Returns:
   102→            16-character hex string.
   103→        """
   104→        raw = f"{source_path}:{chunk_index}"
   105→        return hashlib.sha256(raw.encode()).hexdigest()[:16]
   106→
   107→    def add_chunks(self, texts: List[str], metadatas: List[ChunkMetadata]) -> None:
   108→        """Add or update chunks in the vector store (idempotent upsert).
   109→
   110→        Args:
   111→            texts: List of text chunks.
   112→            metadatas: Corresponding ChunkMetadata for each chunk.
   113→        """
   114→        if not texts:
   115→            return
   116→
   117→        doc_ids = [self._make_doc_id(m.source_path, m.chunk_index) for m in metadatas]
   118→        embeddings = self.embedding_provider.embed(texts)
   119→
   120→        # Flatten list fields: ChromaDB metadata values must be str/int/float/bool
   121→        meta_dicts = []
   122→        for m in metadatas:
   123→            d = m.model_dump()
   124→            d["tags"] = ",".join(d["tags"])
   125→            meta_dicts.append(d)
   126→
   127→        self._collection.upsert(
   128→            ids=doc_ids,
   129→            embeddings=embeddings,
   130→            documents=texts,
   131→            metadatas=meta_dicts,
   132→        )
   133→        logger.debug(f"Upserted {len(texts)} chunks into vector store")
   134→
   135→    def search_knowledge(self, query: str, top_k: int = 3) -> List[SearchResult]:
   136→        """Semantic search over stored chunks.
   137→
   138→        Args:
   139→            query: Search query text.
   140→            top_k: Maximum number of results to return.
   141→
   142→        Returns:
   143→            List of SearchResult ordered by relevance (lowest distance first).
   144→        """
   145→        total = self._collection.count()
   146→        if total == 0:
   147→            return []
   148→
   149→        query_embedding = self.embedding_provider.embed([query])[0]
   150→        results = self._collection.query(


Excellent! Now I have all the information needed. Let me compile the comprehensive summary:

## Summary: Obsidian Bot Mode Architecture

Based on my analysis of the codebase, here's a concise breakdown for your "mode info" feature planning:

### 1. Mode Registration & Routing (dispatcher.py)

**VALID_MODES set** (lines 41-50):
```python
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"

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

**Generator Mapping** (lines 67-74):
- `MODE_WRITE` → WritingAssistant
- `MODE_REPLY` → ReplyGenerator
- `MODE_DECIDE` → DecisionSupport
- `MODE_SEARCH` → SearchAnalyzer
- `MODE_DEAI` → DeAIReviser
- `MODE_ZHIHU` → ZhihuGenerator
- `MODE_NOTE` → NoteIngester (special case, not in generators dict)
- `MODE_ZHIHU_HUNTER` → ZhihuHunter (special case)

**Mode Switching Logic** (lines 110-119):
- User sends: `mode <name>`
- Dispatcher validates against VALID_MODES
- On valid mode: clears history, saves state, sends confirmation
- On invalid mode: lists all valid modes

### 2. MODE_NOTE Architecture (Closest Existing Feature)

**Dispatcher Integration** (lines 137-144):
```python
if current_mode == self.MODE_NOTE:
    try:
        result = self.note_ingester.ingest(text)
        self._reply(channel_id, result, response_ts)
    except Exception as e:
        # error handling
        return
```

**NoteIngester Workflow**:
1. Receives raw text or URL
2. Detects & fetches URLs (with fallback strategies: Tavily → Jina → Playwright)
3. LLM extracts structured note via JSON prompt
4. Writes to vault + chunks & indexes to vector store
5. Returns Slack confirmation message

### 3. Vault Directory Structure

**OBSIDIAN_VAULT_PATH** = `/root/vault/obsidian_vault/obsidian/obsidian/`

**Key subdirectories**:
- `notes/` - Auto-ingested notes (via MODE_NOTE)
- `Article/` - Revised articles
- `Clippings/`, `daily/`, `CLAW/`, `zhihu/` - Various content types
- `auto_report/` - Generated reports
- `attachments/`, `accounts/`, `feelings/`, `AI/`, `必备知识/`, `工作记录/`, etc.

**Note files in `/notes/`**:
- Naming: `YYYY-MM-DD_<slug>.md`
- Example: `2026-03-14_Agentic-AI--Evolution--Mechanics--and-Strategic-Cy.md`

### 4. NoteIngester File Format

**File Naming & Location**:
- Path: `vault_path / "notes" / f"{date_str}_{slug}.md"`
- Date: UTC `YYYY-MM-DD`
- Slug: Sanitized title (max 50 chars, Unicode Chinese support)

**YAML Frontmatter** (lines 398-408):
```yaml
---
title: "..."
date: YYYY-MM-DD
tags:
  - auto_ingested
language: zh|en
source_url: "..." (optional)
key_points:
  - "point 1"
  - ...
ingested_at: ISO datetime (UTC)
---
```

**Body Structure**:
```markdown
## Summary
<2-3 sentence summary>

## Content
<cleaned body with [[entity]] wrapped entities>
```

### 5. Pydantic Models

**ExtractedNote** (note_ingester.py, lines 19-28):
```python
class ExtractedNote(BaseModel):
    title: str
    summary: str                    # 2-3 sentences
    key_points: List[str]           # up to 7 items
    entities: List[str]             # wrapped as [[entity]] in cleaned_body
    cleaned_body: str               # full text with entities wrapped
    source_url: Optional[str] = None
    language: str = "zh"
```

**ChunkMetadata** (vector_store.py, lines 17-25):
```python
class ChunkMetadata(BaseModel):
    source_path: str
    chunk_index: int
    header_hierarchy: str
    note_title: Optional[str] = None
    tags: List[str] = Field(default_factory=list)
    ingested_at: str  # ISO datetime string
```

**SearchResult** (vector_store.py, lines 28-35):
```python
class SearchResult(BaseModel):
    text: str
    source_path: str
    header_hierarchy: str
    distance: float
    note_title: Optional[str] = None
```

### Key Insights for "Mode Info" Feature

1. **Similar to MODE_NOTE**: Your feature would follow the same pattern - check current_mode in dispatcher, call a handler method, return Slack-formatted response
2. **State Management**: Each mode can maintain context via `ObsidianContextStorage` (channel-scoped JSON files at `data/obsidian_context/`)
3. **Vector Store Integration**: Optional - NoteIngester chunks notes and indexes them; if MODE_INFO reads from indexed content, you'd use `vector_store.search_knowledge()`
4. **Vault Path**: Access via `Path(vault_path)` throughout - all models use pathlib
5. **Slack Formatting**: Use `SlackFormatter.convert()` for message formatting, watch for 4000/40000 char limits

**Files to reference when implementing**:
- `/root/projects/butler/slack_bot/obsidian/dispatcher.py` - Mode routing template
- `/root/projects/butler/slack_bot/obsidian/note_ingester.py` - Full ingestion pipeline example
- `/root/projects/butler/slack_bot/obsidian/vector_store.py` - Vector store API

agentId: a578bb468fb2b4265 (use SendMessage with to: 'a578bb468fb2b4265' to continue this agent)
<usage>total_tokens: 36283
tool_uses: 10
duration_ms: 192262</usage>

Prompt:
I need to understand the existing Obsidian bot mode architecture in /root/projects/butler for planning a new "mode info" feature. Please find and summarize:

1. How modes are registered and routed in dispatcher.py (the VALID_MODES set, how generators map to modes)
2. How the existing MODE_NOTE works (note_ingester.py) - this is the closest existing feature to "mode info"
3. The vault directory structure under the OBSIDIAN_VAULT_PATH - what subdirectories exist, especially for notes
4. How NoteIngester saves notes (file naming, frontmatter format, directory structure)
5. The Pydantic models used (ExtractedNote, etc.)

Don't read generators.py (I already have it). Focus on: dispatcher.py mode registration, note_ingester.py, and vault structure. Be concise.


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"


Updated task #7 status


Updated task #8 status


好，项目上下文已掌握。现有的 `mode note` 是"一次性收录"模式（URL/文本 → 提取 → 存 vault），你的 `mode info` 是"持续追踪"模式，核心不同在于**信息围绕项目积累，而非独立成篇**。

第一个问题：


AskUserQuestion

Question: mode info 的信息来源主要是什么？用户在 Slack 里直接打字，还是也需要支持粘贴 URL 自动抓取内容？

Choices:
用户在 Slack 直接打字或粘贴文字片段，不需要 URL 抓取
支持直接打字，也支持粘贴 URL 让 bot 自动抓取内容后存入
还需要支持 Slack 上传的文件（截图、PDF 等）


User has answered your questions: "mode info 的信息来源主要是什么？用户在 Slack 里直接打字，还是也需要支持粘贴 URL 自动抓取内容？"="文本 + URL + 文件". You can now continue with the user's answers in mind.

Question: mode info 的信息来源主要是什么？用户在 Slack 里直接打字，还是也需要支持粘贴 URL 自动抓取内容？

Choices:
用户在 Slack 直接打字或粘贴文字片段，不需要 URL 抓取
支持直接打字，也支持粘贴 URL 让 bot 自动抓取内容后存入
还需要支持 Slack 上传的文件（截图、PDF 等）


Choices:
用户发送“project butler”切换当前活动项目，后续消息自动归到该项目下
# 用法示例
mode info          # 进入 info 模式
project butler     # 切换到 butler 项目
这是一条信息  # 自动存入 butler 项目

project my-saas    # 切换到另一个项目
另一条信息      # 存入 my-saas 项目
用户在每条消息开头指定项目名，如“[butler] 发现一个bug”
bot 通过 LLM 自动判断这条信息属于哪个项目

Question: 用户怎么告诉 bot 这条信息属于哪个项目？


User has answered your questions: "用户怎么告诉 bot 这条信息属于哪个项目？"="显式切换项目 (推荐)" selected preview:
# 用法示例
mode info          # 进入 info 模式
project butler     # 切换到 butler 项目
这是一条信息  # 自动存入 butler 项目

project my-saas    # 切换到另一个项目
另一条信息      # 存入 my-saas 项目. You can now continue with the user's answers in mind.

Question: 用户怎么告诉 bot 这条信息属于哪个项目？

Choices:
用户发送“project butler”切换当前活动项目，后续消息自动归到该项目下
# 用法示例
mode info          # 进入 info 模式
project butler     # 切换到 butler 项目
这是一条信息  # 自动存入 butler 项目

project my-saas    # 切换到另一个项目
另一条信息      # 存入 my-saas 项目
用户在每条消息开头指定项目名，如“[butler] 发现一个bug”
bot 通过 LLM 自动判断这条信息属于哪个项目


Choices:
Bot 只是加时间戳、整理格式后的原文存储，不做智能处理
Bot 用 LLM 生成一句话点评（你提到的“简要点评其内容”），追加到原文后面
存储 + 生成点评 (推荐)
## 2026-03-17
> 原始内容：SRE 团队抱怨 CI 流水线每天
> 挂 30 分钟，都卡在 Docker 拉镜像

**点评**: CI 瓶颈在镜像拉取，可能需要
内网 registry 或 cache 层优化。
关联上周的镜像加速议题。
存储 + 点评 + 为每条信息生成标签（例如 #bug #ci #性能）方便后续检索
存储 + 点评 + 自动打标签

Question: Bot 在存入信息时，需要做多少智能处理？


User has answered your questions: "Bot 在存入信息时，需要做多少智能处理？"="存储 + 点评 + 自动打标签". You can now continue with the user's answers in mind.

Question: Bot 在存入信息时，需要做多少智能处理？

Choices:
Bot 只是加时间戳、整理格式后的原文存储，不做智能处理
存储 + 生成点评 (推荐)
Bot 用 LLM 生成一句话点评（你提到的“简要点评其内容”），追加到原文后面
## 2026-03-17
> 原始内容：SRE 团队抱怨 CI 流水线每天
> 挂 30 分钟，都卡在 Docker 拉镜像

**点评**: CI 瓶颈在镜像拉取，可能需要
内网 registry 或 cache 层优化。
关联上周的镜像加速议题。
存储 + 点评 + 自动打标签
存储 + 点评 + 为每条信息生成标签（例如 #bug #ci #性能）方便后续检索

存储 + 点评 + 自动打标签


Choices:
用户发送“update”进入更新模式，Bot 显示最近几条信息，用户选择哪条要追加后续，新内容追加到该条下方
# 用法示例
update              # Bot 显示最近 5 条
> 1. CI 镜像瓶颈 (03-15)
> 2. API 超时问题 (03-14)
> 3. ...

1                   # 选择第 1 条
已部署内网 registry  # 追加更新

# 文件中结果：
## CI 镜像瓶颈
- 03-15: SRE 反馈...
- 03-17: 已部署内网 registry，
         等待效果观察
不做交互，每条信息都是独立的新增，靠 LLM 自动判断是否与旧信息相关并合并
V1 不做更新功能，只做纯追加，后续迭代

Question: 你提到“不断有一些交互获得了结果需要再放回目录” —— 这个更新流程应该怎么设计？


User has answered your questions: "你提到“不断有一些交互获得了结果需要再放回目录” —— 这个更新流程应该怎么设计？"="交互式更新 (推荐)" selected preview:
# 用法示例
update              # Bot 显示最近 5 条
> 1. CI 镜像瓶颈 (03-15)
> 2. API 超时问题 (03-14)
> 3. ...

1                   # 选择第 1 条
已部署内网 registry  # 追加更新

# 文件中结果：
## CI 镜像瓶颈
- 03-15: SRE 反馈...
- 03-17: 已部署内网 registry，
         等待效果观察. You can now continue with the user's answers in mind.

Question: 你提到“不断有一些交互获得了结果需要再放回目录” —— 这个更新流程应该怎么设计？

Choices:
用户发送“update”进入更新模式，Bot 显示最近几条信息，用户选择哪条要追加后续，新内容追加到该条下方
# 用法示例
update              # Bot 显示最近 5 条
> 1. CI 镜像瓶颈 (03-15)
> 2. API 超时问题 (03-14)
> 3. ...

1                   # 选择第 1 条
已部署内网 registry  # 追加更新

# 文件中结果：
## CI 镜像瓶颈
- 03-15: SRE 反馈...
- 03-17: 已部署内网 registry，
         等待效果观察
不做交互，每条信息都是独立的新增，靠 LLM 自动判断是否与旧信息相关并合并
V1 不做更新功能，只做纯追加，后续迭代


Choices:
用户发送“summary”，Bot 读取当前项目所有信息，用 LLM 生成进展摘要 + 待解决问题列表
支持“summary butler my-saas”同时汇总多个项目，找出交叉关联
支持“summary butler + 载入 notes/xxx.md”，技术文档和碧片信息一起分析

Question: 你提到“以后遇到项目问题可以汇总进展和问题” —— 汇总功能需要多大的范围？


User has answered your questions: "你提到“以后遇到项目问题可以汇总进展和问题” —— 汇总功能需要多大的范围？"="汇总 + 外部笔记融合". You can now continue with the user's answers in mind.

Question: 你提到“以后遇到项目问题可以汇总进展和问题” —— 汇总功能需要多大的范围？

Choices:
用户发送“summary”，Bot 读取当前项目所有信息，用 LLM 生成进展摘要 + 待解决问题列表
支持“summary butler my-saas”同时汇总多个项目，找出交叉关联
支持“summary butler + 载入 notes/xxx.md”，技术文档和碧片信息一起分析


Updated task #9 status


需求已清晰，下面是三个方案的对比：

---

## 方案 A：Flat File + LLM 解析（推荐）

**核心思路**：每个项目一个子目录，每周一个 Markdown 文件，信息按时间追加。所有交互靠解析当前周文件 + LLM 生成点评/标签。

```
vault/info/
  butler/
    2026-W12.md      # 本周文件
    2026-W11.md      # 上周文件
  my-saas/
    2026-W12.md
```

每条信息格式：
```markdown
---
### 2026-03-17 14:30 | #ci #性能
> SRE 团队抱怨 CI 每天挂 30 分钟，卡在 Docker 拉镜像

**点评**: CI 瓶颈在镜像拉取，可能需要内网 registry 或 cache 层优化
```

**优点**：实现最简单；文件直接在 Obsidian 里可读可编辑；符合你"每周一个文件"的设想
**缺点**：update 功能需要解析 Markdown 找到条目位置；跨周搜索需要读多个文件
**工作量**：~300 行代码

## 方案 B：JSON 索引 + Markdown 渲染

**核心思路**：每条信息存为结构化 JSON（带 ID、标签、时间线），按周输出到 Markdown 文件作为渲染层。

```
vault/info/
  butler/
    _index.json      # 结构化数据（所有条目）
    2026-W12.md      # 自动生成的可读视图
```

**优点**：update/查询用 JSON 精准定位，不怕 Markdown 解析出错；标签检索更可靠
**缺点**：双写（JSON + MD）增加复杂度；用户在 Obsidian 手动编辑 MD 后和 JSON 不同步
**工作量**：~500 行代码

## 方案 C：复用现有 NoteIngester + 标签系统

**核心思路**：复用 `mode note` 的 NoteIngester，每条信息作为独立笔记存入 `notes/` 目录，用 frontmatter 的 tags 字段区分项目。

**优点**：代码复用最多；自动进入向量库支持语义搜索
**缺点**：每条碎片信息一个文件，文件膨胀严重；无法看到"一个项目的信息流"全貌；不符合你"每周一个文件"的设想
**工作量**：~150 行代码

---

**我推荐方案 A**。理由：
1. 最贴合你的使用习惯（打开 Obsidian 直接看一周的项目动态）
2. 实现简单，Markdown 解析的复杂度可控（条目之间用 `---` 或 `###` 分隔，定位明确）
3. update 功能通过读取当前文件、LLM 列出条目摘要、用户选择后追加即可
4. 汇总功能读所有周文件拼接后交给 LLM，比 JSON 方案更直观

你觉得哪个方案合适？


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"


使用方案A


Updated task #10 status


好，方案 A 确认。逐段呈现设计。

## 设计 Part 1：文件结构与条目格式

**目录结构**：
```
vault/info/
  butler/
    2026-W12.md
    2026-W11.md
  my-saas/
    2026-W12.md
```
- 项目名即子目录名，用户通过 `project <name>` 创建/切换
- 周文件命名：`YYYY-Www.md`（ISO 周编号），不存在时自动创建

**单条信息格式**：
```markdown
### 2026-03-17 14:30 | #ci #性能

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

**点评**: CI 瓶颈在镜像拉取，考虑内网 registry 或 cache 层优化。关联上周的镜像加速议题。

---
```

- `###` 三级标题 = 条目锚点（日期 + 时间 + 标签）
- `>` 引用块 = 原始内容（用户输入或 URL 抓取结果）
- `**点评**` = LLM 生成的一句话评价
- `---` = 条目分隔符

**update 追加格式**（在同一条目内追加）：
```markdown
### 2026-03-15 10:00 | #ci #性能

> SRE 团队抱怨 CI 流水线每天挂 30 分钟

**点评**: CI 瓶颈在镜像拉取...

**更新 2026-03-17**: 已部署内网 registry，等待效果观察。

---
```

这个文件结构你觉得 OK 吗？


ok


Good, Part 1 approved. Let me present Part 2: the command/interaction design.


## 设计 Part 2：命令与交互流程

进入 mode info 后支持以下命令：

| 命令 | 作用 | 示例 |
|------|------|------|
| `project <name>` | 切换/创建当前活动项目 | `project butler` |
| `（直接发文字/URL/文件）` | 新增一条信息到当前项目 | `CI 每天卡 30 分钟` |
| `update` | 列出当前项目最近 N 条，进入更新模式 | `update` → 选序号 → 发补充内容 |
| `summary` | 汇总当前项目全部信息 | `summary` |
| `summary + <vault路径>` | 汇总 + 融合外部笔记 | `summary + Article/xxx.md` |
| `list` | 列出所有项目名 | `list` |
| `search <关键词>` | 在当前项目所有周文件中搜索 | `search Docker` |

**交互状态机**：

```
mode info → 等待命令
  ├─ project <name> → 切换项目 → 回到等待
  ├─ 文字/URL/文件 → LLM 生成点评+标签 → 追加到本周文件 → 回到等待
  ├─ update → 显示最近条目列表 → 等待序号
  │    └─ 序号 → 等待补充内容
  │         └─ 文字 → LLM 生成更新点评 → 追加到对应条目 → 回到等待
  ├─ summary → 读所有周文件 → LLM 汇总 → 回到等待
  ├─ list → 列出项目目录 → 回到等待
  └─ search <kw> → grep 所有周文件 → 返回匹配条目 → 回到等待
```

**状态持久化**（复用 ObsidianContextStorage）：
```json
{
  "mode": "info",
  "active_project": "butler",
  "pending_update_entry_index": null
}
```

当 `pending_update_entry_index` 不为 null 时，下一条消息被视为该条目的更新内容。

这个交互设计 OK 吗？


## 设计 Part 3：信息处理管线

**新增信息流程**（用户发送文字/URL/文件）：

```
用户输入
  ↓
类型检测（URL? 文件? 纯文本?）
  ├─ URL → 复用 NoteIngester 的 URL 抓取逻辑（Tavily → Jina → Playwright）→ 提取正文
  ├─ Slack 文件 → 下载文件内容（文本类直接读，图片/PDF 存 attachments 后记录路径）
  └─ 纯文本 → 直接用
  ↓
LLM 单次调用（生成点评 + 标签）
  prompt: "根据以下信息和项目上下文，生成：1) 一句话点评 2) 1-3个标签（用#前缀）"
  输入: 原始内容 + 当前项目名 + 本周已有条目（提供上下文避免重复标签）
  ↓
格式化为 Markdown 条目
  ↓
追加到 vault/info/{project}/YYYY-Www.md 文件末尾
  ↓
Slack 回复确认（显示点评和标签）
```

**update 流程**：

```
用户发 "update"
  ↓
读取当前项目最近 2 个周文件
  ↓
用正则解析所有条目（### 标题行）
  ↓
取最近 5 条，格式化为编号列表发给用户
  ↓
用户发序号 → 存入 pending_update_entry_index
  ↓
用户发补充内容 → LLM 生成更新点评
  ↓
定位到文件中对应条目的 "---" 分隔符之前，插入 "**更新 YYYY-MM-DD**: ..."
  ↓
清除 pending 状态 → 回复确认
```

**summary 流程**：

```
用户发 "summary" 或 "summary + Article/xxx.md"
  ↓
读取当前项目所有周文件（按时间排序）
  ↓
如有外部笔记路径，从 vault 读取该文件内容
  ↓
拼接所有内容，发给 LLM
  prompt: "你是项目助手，根据以下碎片信息汇总：1) 项目进展时间线 2) 当前待解决问题 3) 关键决策和结论"
  ↓
返回汇总结果
```

**不做的事**（YAGNI）：
- 不入向量库（碎片信息太短，语义搜索价值低，用 grep 够了）
- 不跨项目自动关联（V1 只做显式的多项目 summary）
- 文件类型只处理文本和 URL，图片/PDF 只存路径不做 OCR

这个处理管线 OK 吗？


Part 3 approved. Now let me present Part 4: the code architecture and module design.


## 设计 Part 4：代码架构

**新增文件**：
```
slack_bot/obsidian/info_manager.py    # 核心逻辑（~300 行）
```

**修改文件**：
```
slack_bot/obsidian/dispatcher.py      # 注册 MODE_INFO + 路由逻辑
```

**InfoManager 类设计**：

```python
class InfoEntry(BaseModel):
    """单条信息的结构化表示"""
    timestamp: str           # "2026-03-17 14:30"
    tags: List[str]          # ["#ci", "#性能"]
    raw_content: str         # 原始内容
    comment: str             # LLM 点评
    updates: List[str]       # ["**更新 2026-03-17**: ..."]

class InfoManager:
    def __init__(self, vault_path: Path, indexer: ObsidianIndexer):
        self.vault_path = vault_path
        self.info_dir = vault_path / "info"
        self.llm = GeminiLLM(system_instruction=INFO_SYSTEM_PROMPT)

    # === 核心方法 ===
    def add_entry(self, project: str, content: str, content_type: str) -> str:
        """新增条目 → 返回 Slack 确认消息"""

    def list_recent(self, project: str, count: int = 5) -> str:
        """列出最近 N 条 → 返回编号列表"""

    def update_entry(self, project: str, entry_index: int, content: str) -> str:
        """给指定条目追加更新 → 返回确认"""

    def summarize(self, project: str, extra_paths: List[str] = None) -> str:
        """汇总项目 → 返回 LLM 生成的摘要"""

    def search(self, project: str, keyword: str) -> str:
        """关键词搜索 → 返回匹配条目"""

    def list_projects(self) -> str:
        """列出所有项目名"""

    # === 内部工具 ===
    def _get_week_file(self, project: str) -> Path:
        """获取/创建本周文件路径"""

    def _parse_entries(self, file_path: Path) -> List[InfoEntry]:
        """解析 Markdown 文件为结构化条目列表"""

    def _format_entry(self, entry: InfoEntry) -> str:
        """InfoEntry → Markdown 文本"""

    def _fetch_url_content(self, url: str) -> str:
        """复用 NoteIngester 的 URL 抓取"""

    def _generate_comment_and_tags(self, content: str, project: str, context: str) -> tuple[str, List[str]]:
        """LLM 生成点评和标签"""
```

**Dispatcher 集成**（和 MODE_NOTE / MODE_ZHIHU_HUNTER 同级的特殊处理）：

```python
# dispatcher.py 新增
MODE_INFO = "info"
VALID_MODES = {..., MODE_INFO}

# dispatch() 中新增分支：
if current_mode == self.MODE_INFO:
    # 命令路由：project / update / summary / list / search / 其他（新增条目）
    # 状态管理：active_project, pending_update_entry_index
```

不在 `self.generators` 字典里注册（和 MODE_NOTE 一样，走专属分支），因为 InfoManager 不是对话式 generator。

这个代码架构 OK 吗？


开始


Updated task #11 status


先写设计文档，然后进入实现。


Bash

ls /root/projects/butler/docs/ 2>/dev/null || echo "No docs dir"

Check docs directory


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"


ARCHITECTURE.md
context_management.md
CRON_SETUP.md
enhanced_logging_guide.md
gemini_empty_response_diagnosis.md
gemini_root_cause_analysis.md
gmode_check_detailed.md
health_query_usage.md
HEALTH_SYNC_README.md
log_analysis_2026-02-06.md
MANUAL_ENTRY.md
milestones
proxy_decision_guide.md
proxy_issue_final_analysis.md
QUICK_START_AFTER_RETURN.md
REVIEW_AND_ROADMAP.md
safety_override_restored.md
steps_fix_summary.md
TESTING.md
TODO.md
TOOLS_REFERENCE.md


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"


Create specs directory


(Bash completed with no output)


Write

# Mode Info: 项目碎片信息追踪

## 概述

Obsidian Bot 新增 `mode info`，用于在日常工作中按项目持续收集碎片信息（文字、URL、文件），每条自动生成点评和标签，按周归档到 Obsidian vault。支持对已有条目追加更新、项目进展汇总、关键词搜索。

## 文件结构

```
vault/info/
  butler/
    2026-W12.md
    2026-W11.md
  my-saas/
    2026-W12.md
```

- 项目名 = 子目录名，用户通过 `project <name>` 创建/切换
- 周文件命名：`YYYY-Www.md`（ISO 周编号），不存在时自动创建

## 条目格式

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

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

**点评**: CI 瓶颈在镜像拉取，考虑内网 registry 或 cache 层优化。

---
```

update 追加在 `---` 之前：

```markdown
**更新 2026-03-17**: 已部署内网 registry，等待效果观察。

---
```

## 命令

| 命令 | 作用 |
|------|------|
| `project <name>` | 切换/创建当前活动项目 |
| `（直接发文字/URL/文件）` | 新增一条信息 |
| `update` | 列出最近条目，进入更新模式 |
| `summary` | 汇总当前项目 |
| `summary + <vault路径>` | 汇总 + 融合外部笔记 |
| `list` | 列出所有项目名 |
| `search <关键词>` | 在当前项目搜索 |

## 状态持久化

复用 ObsidianContextStorage，state 字段：

```json
{
  "mode": "info",
  "active_project": "butler",
  "pending_update_entry_index": null
}
```

## 信息处理管线

### 新增

1. 类型检测（URL / Slack 文件 / 纯文本）
2. URL → 复用 NoteIngester 的抓取逻辑；文件 → 下载内容/存路径；纯文本 → 直接用
3. LLM 单次调用生成点评 + 1-3 个标签
4. 格式化为 Markdown 条目，追加到本周文件末尾
5. Slack 回复确认

### Update

1. 读取最近 2 个周文件，解析条目
2. 显示最近 5 条编号列表
3. 用户选序号 → 存入 pending_update_entry_index
4. 用户发补充内容 → LLM 生成更新点评
5. 插入到对应条目 `---` 之前
6. 清除 pending 状态

### Summary

1. 读取当前项目所有周文件（按时间排序）
2. 如有外部笔记路径，读取并拼接
3. LLM 汇总：项目进展时间线 + 待解决问题 + 关键决策

## 代码架构

### 新增文件

`slack_bot/obsidian/info_manager.py`（~300 行）

- `InfoEntry(BaseModel)`: timestamp, tags, raw_content, comment, updates
- `InfoManager`: add_entry, list_recent, update_entry, summarize, search, list_projects
- 内部工具：_get_week_file, _parse_entries, _format_entry, _fetch_url_content, _generate_comment_and_tags

### 修改文件

`slack_bot/obsidian/dispatcher.py`: 注册 MODE_INFO，dispatch() 新增专属分支（与 MODE_NOTE 同级）

## 不做的事

- 不入向量库（碎片信息太短，grep 够用）
- 不跨项目自动关联（V1 只做显式多项目 summary）
- 图片/PDF 只存路径不做 OCR


node "${CLAUDE_PLUGIN_ROOT}/scripts/hooks/run-with-flags.js" "pre:write:doc-file-warning" "scripts/hooks/doc-file-warning.js" "standard,strict"


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


File created successfully at: /root/projects/butler/docs/specs/2026-03-17-mode-info-design.md

# Mode Info: 项目碎片信息追踪

## 概述

Obsidian Bot 新增 `mode info`，用于在日常工作中按项目持续收集碎片信息（文字、URL、文件），每条自动生成点评和标签，按周归档到 Obsidian vault。支持对已有条目追加更新、项目进展汇总、关键词搜索。

## 文件结构

```
vault/info/
  butler/
    2026-W12.md
    2026-W11.md
  my-saas/
    2026-W12.md
```

- 项目名 = 子目录名，用户通过 `project <name>` 创建/切换
- 周文件命名：`YYYY-Www.md`（ISO 周编号），不存在时自动创建

## 条目格式

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

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

**点评**: CI 瓶颈在镜像拉取，考虑内网 registry 或 cache 层优化。

---
```

update 追加在 `---` 之前：

```markdown
**更新 2026-03-17**: 已部署内网 registry，等待效果观察。

---
```

## 命令

| 命令 | 作用 |
|------|------|
| `project <name>` | 切换/创建当前活动项目 |
| `（直接发文字/URL/文件）` | 新增一条信息 |
| `update` | 列出最近条目，进入更新模式 |
| `summary` | 汇总当前项目 |
| `summary + <vault路径>` | 汇总 + 融合外部笔记 |
| `list` | 列出所有项目名 |
| `search <关键词>` | 在当前项目搜索 |

## 状态持久化

复用 ObsidianContextStorage，state 字段：

```json
{
  "mode": "info",
  "active_project": "butler",
  "pending_update_entry_index": null
}
```

## 信息处理管线

### 新增

1. 类型检测（URL / Slack 文件 / 纯文本）
2. URL → 复用 NoteIngester 的抓取逻辑；文件 → 下载内容/存路径；纯文本 → 直接用
3. LLM 单次调用生成点评 + 1-3 个标签
4. 格式化为 Markdown 条目，追加到本周文件末尾
5. Slack 回复确认

### Update

1. 读取最近 2 个周文件，解析条目
2. 显示最近 5 条编号列表
3. 用户选序号 → 存入 pending_update_entry_index
4. 用户发补充内容 → LLM 生成更新点评
5. 插入到对应条目 `---` 之前
6. 清除 pending 状态

### Summary

1. 读取当前项目所有周文件（按时间排序）
2. 如有外部笔记路径，读取并拼接
3. LLM 汇总：项目进展时间线 + 待解决问题 + 关键决策

## 代码架构

### 新增文件

`slack_bot/obsidian/info_manager.py`（~300 行）

- `InfoEntry(BaseModel)`: timestamp, tags, raw_content, comment, updates
- `InfoManager`: add_entry, list_recent, update_entry, summarize, search, list_projects
- 内部工具：_get_week_file, _parse_entries, _format_entry, _fetch_url_content, _generate_comment_and_tags

### 修改文件

`slack_bot/obsidian/dispatcher.py`: 注册 MODE_INFO，dispatch() 新增专属分支（与 MODE_NOTE 同级）

## 不做的事

- 不入向量库（碎片信息太短，grep 够用）
- 不跨项目自动关联（V1 只做显式多项目 summary）
- 图片/PDF 只存路径不做 OCR


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


Spec 已写入 `docs/specs/2026-03-17-mode-info-design.md`。现在开始实现。


TaskCreate

创建 slack_bot/obsidian/info_manager.py，包含 InfoEntry 模型和 InfoManager 类的所有方法

实现 info_manager.py


Task #12 created successfully: 实现 info_manager.py

实现 info_manager.py


在 dispatcher.py 注册 MODE_INFO，添加命令路由和状态管理逻辑

集成 MODE_INFO 到 dispatcher.py


Task #13 created successfully: 集成 MODE_INFO 到 dispatcher.py

集成 MODE_INFO 到 dispatcher.py


导入测试 + 手动验证核心流程（add/update/summary/search）

验证导入和基本功能


Task #14 created successfully: 验证导入和基本功能

验证导入和基本功能


Updated task #12 status


先看一下 NoteIngester 的 URL 抓取方法，方便复用。


Grep

def _fetch_url|def _extract_from_url|def fetch_url


115:    def _fetch_url(self, url: str) -> str:


   115→    def _fetch_url(self, url: str) -> str:
   116→        """Fetch URL content using multiple strategies with automatic fallback.
   117→
   118→        Strategy order:
   119→        - WeChat / known blocked domains: direct js_content parse → Tavily → Playwright
   120→        - All other URLs: Tavily → Jina → Playwright
   121→
   122→        Args:
   123→            url: URL to fetch.
   124→
   125→        Returns:
   126→            Extracted text content (>= _MIN_CONTENT_LEN chars).
   127→
   128→        Raises:
   129→            RuntimeError: If all strategies fail.
   130→        """
   131→        host_match = re.search(r'https?://([^/]+)', url)
   132→        host = host_match.group(1) if host_match else ""
   133→
   134→        if host in self._JINA_FIRST_DOMAINS:
   135→            # WeChat: direct parse first, Playwright as last resort
   136→            strategies = [self._fetch_direct_wechat, self._fetch_tavily, self._fetch_playwright]
   137→        else:
   138→            strategies = [self._fetch_tavily, self._fetch_jina, self._fetch_playwright]
   139→
   140→        last_err: Exception = RuntimeError("No strategies available")
   141→        for strategy in strategies:
   142→            try:
   143→                content = strategy(url)
   144→                if len(content) >= self._MIN_CONTENT_LEN:
   145→                    logger.info(f"Fetched {len(content)} chars via {strategy.__name__}")
   146→                    return content
   147→                logger.debug(f"{strategy.__name__} returned too little content ({len(content)} chars)")
   148→            except Exception as e:
   149→                logger.debug(f"{strategy.__name__} failed: {e}")
   150→                last_err = e
   151→
   152→        raise RuntimeError(f"所有抓取策略均失败: {last_err}")
   153→
   154→    def _fetch_jina(self, url: str) -> str:
   155→        """Fetch via Jina Reader (r.jina.ai) — works well for WeChat and most sites.
   156→
   157→        Args:
   158→            url: Target URL.
   159→
   160→        Returns:
   161→            Markdown text extracted by Jina.
   162→        """
   163→        import httpx
   164→
   165→        jina_url = f"https://r.jina.ai/{url}"
   166→        headers: dict = {"Accept": "text/plain", "X-Return-Format": "markdown"}
   167→        jina_key = os.environ.get("JINA_API_KEY")
   168→        if jina_key:
   169→            headers["Authorization"] = f"Bearer {jina_key}"
   170→
   171→        resp = httpx.get(jina_url, headers=headers, timeout=30, follow_redirects=True)
   172→        resp.raise_for_status()
   173→        return resp.text
   174→

    def _fetch_url(self, url: str) -> str:
        """Fetch URL content using multiple strategies with automatic fallback.

        Strategy order:
        - WeChat / known blocked domains: direct js_content parse → Tavily → Playwright
        - All other URLs: Tavily → Jina → Playwright

        Args:
            url: URL to fetch.

        Returns:
            Extracted text content (>= _MIN_CONTENT_LEN chars).

        Raises:
            RuntimeError: If all strategies fail.
        """
        host_match = re.search(r'https?://([^/]+)', url)
        host = host_match.group(1) if host_match else ""

        if host in self._JINA_FIRST_DOMAINS:
            # WeChat: direct parse first, Playwright as last resort
            strategies = [self._fetch_direct_wechat, self._fetch_tavily, self._fetch_playwright]
        else:
            strategies = [self._fetch_tavily, self._fetch_jina, self._fetch_playwright]

        last_err: Exception = RuntimeError("No strategies available")
        for strategy in strategies:
            try:
                content = strategy(url)
                if len(content) >= self._MIN_CONTENT_LEN:
                    logger.info(f"Fetched {len(content)} chars via {strategy.__name__}")
                    return content
                logger.debug(f"{strategy.__name__} returned too little content ({len(content)} chars)")
            except Exception as e:
                logger.debug(f"{strategy.__name__} failed: {e}")
                last_err = e

        raise RuntimeError(f"所有抓取策略均失败: {last_err}")

    def _fetch_jina(self, url: str) -> str:
        """Fetch via Jina Reader (r.jina.ai) — works well for WeChat and most sites.

        Args:
            url: Target URL.

        Returns:
            Markdown text extracted by Jina.
        """
        import httpx

        jina_url = f"https://r.jina.ai/{url}"
        headers: dict = {"Accept": "text/plain", "X-Return-Format": "markdown"}
        jina_key = os.environ.get("JINA_API_KEY")
        if jina_key:
            headers["Authorization"] = f"Bearer {jina_key}"

        resp = httpx.get(jina_url, headers=headers, timeout=30, follow_redirects=True)
        resp.raise_for_status()
        return resp.text


     1→"""Note ingestion engine for the Second Brain RAG system."""
     2→
     3→import json
     4→import os
     5→import re
     6→from datetime import datetime, timezone
     7→from pathlib import Path
     8→from typing import List, Optional
     9→
    10→from pydantic import BaseModel
    11→
    12→from health.utils.logging_config import setup_logger
    13→from slack_bot.llm.gemini import GeminiLLM
    14→from slack_bot.obsidian.vector_store import ChunkMetadata, ChromaVectorStore, split_md_by_headers
    15→
    16→logger = setup_logger(__name__)
    17→
    18→
    19→class ExtractedNote(BaseModel):
    20→    """LLM-extracted structured note from raw input text."""

"""Note ingestion engine for the Second Brain RAG system."""

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

from pydantic import BaseModel

from health.utils.logging_config import setup_logger
from slack_bot.llm.gemini import GeminiLLM
from slack_bot.obsidian.vector_store import ChunkMetadata, ChromaVectorStore, split_md_by_headers

logger = setup_logger(__name__)

class ExtractedNote(BaseModel):
    """LLM-extracted structured note from raw input text."""


现在写 `info_manager.py`：

Write

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


File created successfully at: /root/projects/butler/slack_bot/obsidian/info_manager.py

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


Updated task #13 status


现在集成到 dispatcher：


    38→class ObsidianDispatcher:
    39→    """Routes Obsidian Slack Bot messages."""
    40→
    41→    MODE_WRITE = "write"
    42→    MODE_REPLY = "reply"
    43→    MODE_DECIDE = "decide"
    44→    MODE_SEARCH = "search"
    45→    MODE_DEAI = "deai"
    46→    MODE_ZHIHU = "zhihu"
    47→    MODE_NOTE = "note"
    48→    MODE_ZHIHU_HUNTER = "zhihu-hunter"
    49→

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"


Edit

    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_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"


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

    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"

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.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"

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

    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),
        }

        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})
                self._reply(channel_id, f"✅ Switched to mode: *{new_mode}* (History cleared)", 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/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_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 _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)


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"


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


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

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


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