Files
hermes-agent/docs/2026-09-10_081415-project-history-recall-plugin-v3.md

13 KiB
Raw Permalink Blame History

项目历史召回插件设计方案 V3

状态: 可执行方案。基于 V2 + 探针验证结果,所有接口、SQL、参数、失败模式已定死。

Goal: 一个插件解决两个需求:(1) 会话项目归属(cwd 跟随),(2) 项目内历史的按需关键词+语义检索。

定位: 独立插件 ~/.hermes/plugins/project-history-recall/,核心零改动。

探针验证结论(已跑通):

  • 插件发现/注册/调用全链路 ✅
  • state.db 只读访问 ✅(1097 会话,656 有 cwd,441 Home 桶)
  • projects.db 读取 ✅(17 个显式项目)
  • SessionDB acquire + update_session_cwd ✅
  • FTS5 messages_fts 查询 ✅
  • messages 表含 active/compacted 列(可见性过滤用)

一、插件文件结构

~/.hermes/plugins/project-history-recall/
├── plugin.yaml
├── __init__.py          # register(ctx) — 注册两个工具
├── attribution.py       # 归属解析(纯函数)
├── search.py            # 关键词+语义混合检索
├── store.py             # state.db 只读查询 + update_session_cwd 封装
├── embeddings.py        # 向量索引(embeddings.db)
└── consent.py           # 隐私确认记录

二、归属解析(attribution.py)

2.1 数据画像(探针实测)

类别 数量 占比
有 cwd 656 60%
有 git_repo_root 75 7%
Home 桶(无 cwd) 441 40%

结论: Home 桶不是边缘情况,是主要路径之一。归属解析必须优雅处理。

2.2 归属解析函数

def resolve_project(session_row: dict, projects: list) -> dict:
    """三态归属解析。

    Returns:
        {"kind": "explicit", "id": "p_abc", "name": "...", "folders": [...]}
        {"kind": "auto", "key": "path_key", "root": "/path/to/repo"}
        {"kind": "home"}
    """

规则:

  1. 显式项目匹配: 用 session 的 cwd 和 git_repo_root 分别与每个项目的 folders 做最长祖先匹配。路径规范化:去尾部 /,Windows casefold。取匹配深度最深的项目。
  2. 自动 repo: 无显式命中但有 git_repo_root → {"kind": "auto", "key": path_key(root), "root": root}。
  3. Home 桶: 无 cwd 且无 git_repo_root → {"kind": "home"}。

2.3 归属过滤 SQL 生成

def scope_sql(identity: dict) -> tuple[str, tuple]:
    """返回 (WHERE 片段, 参数)。用于 JOIN sessions 时的项目过滤。

    explicit: 多 folder OR 条件
    auto:     git_repo_root 精确匹配
    home:     无过滤(返回空结果,由调用方处理)
    """

显式项目的 WHERE(每个 folder 生成 4 个条件):

-- 每个 folder 生成:
(s.cwd LIKE ? ESCAPE '\' OR s.cwd = ? OR s.git_repo_root LIKE ? ESCAPE '\' OR s.git_repo_root = ?)
-- 参数:(folder + '/%', folder, folder + '/%', folder)
-- 多个 folder 用 OR 连接

LIKE 转义:\ → \\,% → \%,_ → \_。

三、检索(search.py)

3.1 关键词路径

SELECT m.id, m.session_id, m.role, m.content, m.timestamp,
       s.title, s.started_at, s.cwd, s.git_repo_root
FROM messages_fts f
JOIN messages m ON f.rowid = m.id
JOIN sessions s ON m.session_id = s.id
WHERE messages_fts MATCH ?
  AND m.role IN ('user', 'assistant')
  AND m.active = 1
  AND s.archived = 0 AND s.hidden = 0
  AND s.id != ?              -- 排除当前会话
  AND {scope_where}           -- 项目过滤
ORDER BY rank
LIMIT ?

参数顺序:(query, current_session_id, *scope_params, limit)。

CJK 回退: FTS5 对 CJK 不友好。查询含 CJK 字符时回退到 LIKE:

SELECT m.id, m.session_id, m.role, m.content, m.timestamp,
       s.title, s.started_at, s.cwd, s.git_repo_root
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.content LIKE ? ESCAPE '\'
  AND m.role IN ('user', 'assistant')
  AND m.active = 1
  AND s.archived = 0 AND s.hidden = 0
  AND s.id != ?
  AND {scope_where}
ORDER BY m.timestamp DESC
LIMIT ?

FTS5 查询消毒: 用现有 _sanitize_fts5_query 的规则——引号包裹、OR/AND/NOT 保留、* 前缀匹配。语法错误时回退 LIKE。

3.2 归属会话集合

def get_project_sessions(db_conn, identity: dict, current_session_id: str) -> list[dict]:
    """返回项目内所有会话的元数据(排除当前会话和隐藏来源)。"""

用于语义索引时确定"需要索引哪些会话",也用于结果中的 sessions_in_scope。

3.3 语义路径(embeddings.py)

存储: ~/.hermes/plugins/project-history-recall/embeddings.db

CREATE TABLE IF NOT EXISTS chunks (
    id INTEGER PRIMARY KEY,
    session_id TEXT NOT NULL,
    message_id INTEGER NOT NULL,
    chunk_index INTEGER NOT NULL,
    content_text TEXT NOT NULL,        -- 原文片段(用于调试和 snippet)
    content_hash TEXT NOT NULL,        -- SHA-256 hex
    model_fingerprint TEXT NOT NULL,   -- provider:model:dimensions
    vector BLOB NOT NULL,              -- float32 array
    created_at REAL NOT NULL,
    UNIQUE(session_id, message_id, chunk_index, model_fingerprint)
);
CREATE INDEX IF NOT EXISTS idx_chunks_session ON chunks(session_id);

切片参数(起始值,P4 实测调整):

参数 起始值 说明
chunk_size 512 chars 字符数,不是 token(简单可靠)
chunk_overlap 64 chars 重叠
top_k 20 语义检索候选数
rrf_k 60 RRF 标准常数
batch_size 32 embedding API 批量

索引流程:

  1. get_project_sessions() 得到项目会话集合。
  2. 对每个会话,读 active=1 的 user/assistant 消息。
  3. 每条消息按 chunk_size 切片,计算 content_hash。
  4. 跳过已有且 hash 未变的 chunk。
  5. 批量调用 embedding API,存向量。
  6. 返回索引统计(新增/跳过/失败数)。

检索流程:

  1. 归属解析 → 项目会话 ID 集合。
  2. 查询 embedding API 得到查询向量。
  3. 从 embeddings.db 读该集合内所有 chunk(按 session_id IN 过滤)。
  4. 计算余弦相似度(numpy 或纯 Python,B0 决定)。
  5. 取 top_k,按 message_id 归并。

模型指纹: {provider}:{model}:{dimensions},如 openai:text-embedding-3-small:1536。不同指纹的向量不可混用。

3.4 混合检索(RRF)

def rrf_merge(keyword_results: list[dict], semantic_results: list[dict],
              k: int = 60) -> list[dict]:
    """Reciprocal Rank Fusion. 按 message_id 归并两路结果。

    score = 1/(k + rank_keyword) + 1/(k + rank_semantic)
    只出现在一路的结果,另一路 rank = infinity(贡献 0)。
    """

归并后按 RRF 分数降序,取 top limit 条。每条的 score_source 标注 "keyword" / "semantic" / "both"。

3.5 结果展开

对每个命中消息,读前后各 2 条消息作为上下文:

SELECT id, role, content, timestamp FROM messages
WHERE session_id = ? AND id BETWEEN ? - 2 AND ? + 2 AND active = 1
ORDER BY id

返回时包含 session_id、message_id、link、title、snippet(命中消息内容,截断 500 字符)、context(前后消息列表)、when、score_source。

四、移动(store.py)

def move_session(session_id: str, project_id: str, **kw) -> dict:
    """移动会话到项目。cwd 跟随到项目 primary_path。"""

逻辑:

  1. project_id 为空 → 移到 Home:cwd 设为 ~。
  2. 非空 → 从 projects.db 读目标项目的 primary_path。
  3. git 探测:git_probe.branch(path) + git_probe.common_repo_root(path)。
  4. 调用 SessionDB.update_session_cwd(session_id, path, branch, root, replace_git_meta=True)。
  5. 返回 {"success": true, "project": {...}, "cwd": path}。

获取 SessionDB: hermes_state_registry.acquire()(探针已验证可用)。

五、隐私确认(consent.py)

存储: ~/.hermes/plugins/project-history-recall/consent.json

{
  "version": 1,
  "grants": [{
    "profile": "default",
    "provider": "openai",
    "endpoint": "https://api.openai.com/v1",
    "model": "text-embedding-3-small",
    "projects": ["p_abc", "p_def"],
    "scope": "history+incremental",
    "confirmed_at": 1757486400.0
  }]
}

确认流程:

  1. mode=semantic 或 mode=hybrid 且 embedding 未配置 → 返回 semantic_status: "consent_required"。
  2. Agent 在对话中说明服务方、发送内容、项目范围。
  3. 用户确认后,Agent 写入 consent.json + 配置 embedding 参数。
  4. 后续同 provider+endpoint+项目范围不重复确认。
  5. 换 provider/endpoint/扩大项目范围 → 重新确认。

六、check_fn

def check_available() -> bool:
    """state.db 存在即可注册。"""
    try:
        from hermes_state import _default_db_path
        return _default_db_path().exists()
    except Exception:
        return False

七、工具注册(__init__.py)

def register(ctx) -> None:
    ctx.register_tool(
        name="project_history_search", toolset="project",
        schema=SEARCH_SCHEMA, handler=handle_search,
        check_fn=check_available, emoji="📂",
    )
    ctx.register_tool(
        name="project_session_move", toolset="project",
        schema=MOVE_SCHEMA, handler=handle_move,
        check_fn=check_available, emoji="📁",
    )

八、完整工具 schema

SEARCH_SCHEMA = {
    "name": "project_history_search",
    "description": (
        "Search past conversations in the SAME project as this one. "
        "Returns actual messages from other sessions with links. "
        "Modes: 'hybrid' (default, keyword+semantic), 'keyword' (exact terms), "
        "'semantic' (similar meaning). Use 'keyword' for code symbols/error codes; "
        "'semantic' for 'how did we handle X' when exact words differ."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "Search query. Keywords, phrases, or natural language.",
            },
            "mode": {
                "type": "string",
                "enum": ["hybrid", "keyword", "semantic"],
                "default": "hybrid",
            },
            "limit": {
                "type": "integer",
                "default": 3,
                "minimum": 1,
                "maximum": 10,
            },
        },
        "required": ["query"],
    },
}

project_session_move

MOVE_SCHEMA = {
    "name": "project_session_move",
    "description": (
        "Move this conversation to a different project. The working directory "
        "switches to the target project's folder. Use when the user says "
        "'move this to project X' or 'this belongs to project Y'."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "project_id": {
                "type": "string",
                "description": (
                    "Target project ID or slug (e.g. 'p_abc123' or 'hermes-agent'). "
                    "Use '' to remove from any project."
                ),
            },
        },
        "required": ["project_id"],
    },
}

九、失败模式

场景 返回
state.db 不存在 {"success": false, "error": "session database not found"}
projects.db 不存在 退化为 auto repo 归属,不报错
当前会话无 cwd 且无 git_repo_root {"success": true, "results": [], "sessions_in_scope": 0, "message": "No project context"}
项目内无其他会话 {"success": true, "results": [], "sessions_in_scope": 1, "message": "No other sessions in this project"}
FTS5 语法错误 回退 LIKE,不报错
embedding 未启用 semantic_status: "disabled",关键词正常返回
embedding 未确认 semantic_status: "consent_required",关键词正常返回
embedding 服务不可用 semantic_status: "unavailable",关键词正常返回
移动目标项目不存在 {"success": false, "error": "project not found: {id}"}
移动目标路径不存在 {"success": false, "error": "folder not found: {path}"}
SessionDB acquire 失败 {"success": false, "error": "session database unavailable"}

十、实施顺序

步骤 内容 验证命令
B0 ✅ 已完成(探针验证) 已跑通
P1 attribution.py 归属解析 + 单测 显式/自动/Home 三态 + Windows 路径
P2 search.py 关键词路径 + 注册 + 归属过滤 P/Q 隔离、Home 空、排除当前、溯源链接
P3 store.py 移动 + update_session_cwd 移动后新检索用新归属、cwd 已切换
P4 embeddings.py 语义路径 + RRF 混合 同义词命中、关键词保底、移动不重算向量
P5 consent.py 隐私确认 未确认不发送、确认后自主检索

十一、评审自审

  • V2 的 SessionDB 获取问题已解决: hermes_state_registry.acquire() 探针验证可用。
  • 归属 = cwd 的函数: 无持久化列,核心零改动。归属随 cwd 变化是接受的行为。
  • 只读约束: 除 update_session_cwd 外,state.db 只有 SELECT。
  • 执行水准: schema、SQL、参数、失败模式、check_fn 全部定死。
  • 诚实边界: chunk_size/overlap/top_k/rrf_k 是起始值,P4 实测调整;embedding 模型选型待 B0 确认;40% 的 Home 桶会话无法检索是已知限制。