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

15 KiB
Raw Permalink Blame History

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

状态: 设计方案,非实施代码。V2 修正了 V1 中"插件叠加 workspace.move RPC"的错误前提,补齐执行所需的全部接口定义。

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

定位: Footprint Ladder 第 4 级插件。装在 ~/.hermes/plugins/project-history-recall/。核心零改动。


一、修正:workspace.move 不可用

V1 假设插件能调用 session.workspace.move(tui_gateway JSON-RPC 方法)。核实后这是错误的:

  • 插件工具通过 ctx.register_tool 注册(hermes_cli/plugins.py:449-491),走 handle_function_call → tools/registry.py 路径。
  • session.workspace.move 是 tui_gateway 的 JSON-RPC 方法(tui_gateway/methods_session.py:840),插件工具没有它的句柄。
  • 两者是不同的调用通道,插件无法"叠加"到 workspace.move 上。

修正后的移动路径: 插件自己实现归属更新,不调用 workspace.move。归属与 cwd 一致移动的含义变为:插件工具同时更新归属记录和会话的 cwd/git_repo_root——通过直接调用 SessionDB.update_session_cwd()(hermes_state_sessions.py:486)写 state.db。这是唯一的例外写入,且复用核心已有的公共写接口,不绕过核心的持久化逻辑。

二、插件结构

~/.hermes/plugins/project-history-recall/
├── plugin.yaml
├── __init__.py          # register(ctx)
├── attribution.py       # 归属解析(纯函数,无 IO)
├── search.py            # 关键词+语义混合检索
├── store.py             # 归属记录读写(state.db 只读 + update_session_cwd 例外)
└── config_schema.py     # embedding 配置声明

三、需求一:项目归属

3.1 归属解析(attribution.py)

纯函数,输入为数据不读文件:

def resolve_project(
    session_row: dict,           # 当前会话行:{id, cwd, git_repo_root, ...}
    projects: list[dict],        # projects_db.list_projects() 的结果(含 folders)
) -> dict:
    """返回归属身份。三态:
    - {"kind": "explicit", "id": "p_abc", "name": "Hermes"}  — 显式项目
    - {"kind": "auto", "key": "path_key_of_repo_root"}        — 自动 repo(Tier 2)
    - {"kind": "home"}                                          — 无归属(Home 桶)
    """

规则(镜像桌面端 project_tree.py 的语义,插件内重新实现):

  1. 用 session_row["cwd"] 和 session_row["git_repo_root"] 匹配 projects 的 folders(最长祖先匹配,路径规范化处理 Windows casefold)。
  2. 显式命中 → {"kind": "explicit", "id": ..., "name": ...}。
  3. 无显式命中但有 git_repo_root → {"kind": "auto", "key": path_key(git_repo_root)}。
  4. 两者都无 → {"kind": "home"}。

3.2 归属记录存储

归属记录的权威存储是 state.db 的 sessions 表,但插件不加列。归属关系通过运行时解析得出:每次检索时,从当前会话行的 cwd/git_repo_root + projects.db 的 folders 计算归属。

这等价于"归属 = cwd/git_repo_root 的函数",不需要持久化 project_id 列。优点是核心零改动;缺点是归属随 cwd 变化——用户手动改 cwd 时归属跟着变。这是接受的行为(cwd 是用户选择的工作空间,归属跟随工作空间)。

3.3 移动会话(project_session_move 工具)

移动 = 改 cwd 到目标项目的目录。归属自然跟随(因为归属是 cwd 的函数)。

实现:调用 SessionDB.update_session_cwd(session_id, new_cwd, git_branch, git_repo_root, replace_git_meta=True)(hermes_state_sessions.py:486-517),同时做 git 探测(git_probe.branch() / git_probe.common_repo_root())获取 branch 和 repo_root。

只读约束的例外: update_session_cwd 是核心已有的公共写接口,插件调用它写 state.db 是合法复用,不违背"不改核心 schema"的约束。插件绝不直接 INSERT/UPDATE/DELETE messages 表。

3.4 工具 schema

MOVE_SCHEMA = {
    "name": "project_session_move",
    "description": (
        "Move this conversation to a different project. The working directory "
        "switches to the target project's folder, and future history searches "
        "will look in the new project. Use when the user says 'move this to X' "
        "or 'this belongs to project Y'."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "project_id": {
                "type": "string",
                "description": (
                    "Target project ID (e.g. 'p_abc123'). Use '' to remove from "
                    "any project (moves to Home). The conversation's working "
                    "directory changes to the project's primary folder."
                ),
            },
        },
        "required": ["project_id"],
    },
}

Handler 逻辑:

  1. project_id 为空 → 移到 Home:调用 update_session_cwd 设为 ~(或 terminal.cwd 配置)。
  2. project_id 非空 → 从 projects.db 读目标项目的 primary_path → git 探测该路径 → update_session_cwd。
  3. 返回 {"success": true, "project": {...}, "cwd": "...", "message": "Moved to project X, cwd now ..."}。
  4. 失败(项目不存在、路径不存在、git 探测失败)返回 {"success": false, "error": "..."}。

四、需求二:项目内历史检索

4.1 工具 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. "
        "Use when the user asks about prior work on this codebase: "
        "'what did we decide about X', 'where did we leave Y'. "
        "Modes: 'hybrid' (default, keyword + semantic), 'keyword' "
        "(exact terms), 'semantic' (similar meaning)."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "Search query. Keywords, phrases, or natural language.",
            },
            "mode": {
                "type": "string",
                "enum": ["hybrid", "keyword", "semantic"],
                "default": "hybrid",
                "description": (
                    "'hybrid' (default): keyword + semantic combined. "
                    "'keyword': exact terms only. 'semantic': similar meaning only. "
                    "Use 'keyword' for code symbols/error codes; 'semantic' for "
                    "'how did we handle X' when exact words differ."
                ),
            },
            "limit": {
                "type": "integer",
                "default": 3,
                "description": "Max sessions to return (default 3, max 10).",
            },
        },
        "required": ["query"],
    },
}

4.2 归属过滤的 SQL

归属 = cwd 的函数,所以过滤条件直接作用在 sessions 行的 cwd/git_repo_root 上:

-- 显式项目:cwd 或 git_repo_root 在项目 folders 的子树内
WHERE (
    s.cwd LIKE ? ESCAPE '\'          -- folder_path + '/%'
    OR s.git_repo_root LIKE ? ESCAPE '\'
    OR s.cwd = ?                      -- folder_path 精确匹配
    OR s.git_repo_root = ?
)

-- 自动 repo:git_repo_root 匹配
WHERE s.git_repo_root = ?

-- Home 桶:无 cwd 且无 git_repo_root
WHERE s.cwd IS NULL AND s.git_repo_root IS NULL

LIKE 的 % 和 _ 用 ESCAPE '\' 转义。路径比较前统一规范化(去尾部 /,Windows casefold)。

当前会话排除: 归属过滤天然包含当前会话(它的 cwd 在项目里)。关键词搜索时需要额外排除当前 session_id,避免返回自己:

AND s.id != ?  -- current_session_id

4.3 关键词路径

def _keyword_search(db_path: str, query: str, scope_where: str, scope_params: tuple,
                    current_session_id: str, limit: int) -> list[dict]:
    """FTS5 search scoped to project sessions."""
    # 用 messages_fts FTS5 表 + sessions JOIN
    sql = f"""
        SELECT m.id, m.session_id, m.role, m.content, m.timestamp,
               s.title, s.started_at, s.cwd, s.git_repo_root,
               rank
        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 {scope_where}
          AND s.id != ?
        ORDER BY rank
        LIMIT ?
    """
    params = (query, *scope_params, current_session_id, limit)
    # ... execute, return rows

FTS5 查询语法沿用现有 _sanitize_fts5_query 的消毒规则。LIKE 回退(CJK 查询)用 m.content LIKE ? ESCAPE '\'。

4.4 语义路径

插件自有 embeddings.db(~/.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_hash TEXT NOT NULL,
    model_fingerprint TEXT NOT NULL,
    vector BLOB NOT NULL,
    created_at REAL NOT NULL,
    UNIQUE(session_id, message_id, chunk_index, model_fingerprint)
);

索引构建:

  1. 归属解析出项目会话集合。
  2. 读这些会话的 user/assistant 消息(active = 1)。
  3. 按消息切片(chunk_size=512 tokens, overlap=64 tokens,起始值,P4 实测调整)。
  4. 计算 content_hash(SHA-256),跳过已有且未变的 chunk。
  5. 调用 embedding 模型,存向量。

检索:

  1. 归属解析 → 项目会话 ID 集合。
  2. 从 embeddings.db 读该集合内所有 chunk 的向量。
  3. 计算查询向量与每个 chunk 的余弦相似度。
  4. 取 Top-K(K=20,起始值),按 message_id 归并到消息级别。

混合检索(RRF):

  • 关键词结果按 rank 排序,语义结果按相似度排序。
  • RRF 分数:1 / (k + rank_keyword) + 1 / (k + rank_semantic),k=60(标准值)。
  • 按 message_id 归并,取 Top-N。

4.5 结果格式

{
  "success": true,
  "mode": "hybrid",
  "project": {"kind": "explicit", "id": "p_abc", "name": "Hermes"},
  "results": [{
    "session_id": "20260901_143022_a1b2",
    "link": "@session:20260901_143022_a1b2",
    "title": "项目归属设计讨论",
    "message_id": 47,
    "snippet": "……项目归属应该持久化……",
    "score_source": "both",
    "when": "2026-09-01 14:30"
  }],
  "sessions_in_scope": 12,
  "semantic_status": "ready"
}

semantic_status:disabled / consent_required / indexing / ready / partial / unavailable。

五、Embedding 配置与隐私

配置走 config.yaml 的 plugins.project-history-recall 节:

plugins:
  project-history-recall:
    embedding:
      provider: "openai"           # 或 "local"、"voyage" 等
      model: "text-embedding-3-small"
      base_url: ""                 # 可选
      chunk_size: 512              # tokens
      chunk_overlap: 64
      top_k: 20
      enabled: false               # 首次需对话确认后开启

密钥进 .env(PROJECT_HISTORY_EMBEDDING_API_KEY)。

隐私确认链路:

  1. enabled: false 时 mode=hybrid 或 mode=semantic 返回 consent_required,Agent 在对话中说明。
  2. 用户确认后 Agent 调用配置命令写入 enabled: true + 服务信息。
  3. 授权记录存 ~/.hermes/plugins/project-history-recall/consent.json:{profile, projects, provider, endpoint, model, confirmed_at, scope: "history+incremental"}。
  4. 换 provider/endpoint/扩大项目范围时重新确认。

六、check_fn

def check_available() -> bool:
    """state.db 和 projects.db 至少一个存在即可注册(归属解析需要它们)。"""
    from hermes_state import _default_db_path
    return _default_db_path().parent.exists()

工具注册后始终可见(hermes tools 里能看到),但调用时归属解析失败返回明确错误。

七、失败模式

场景 返回
state.db 不存在 {"success": false, "error": "session database not found", "code": "no_db"}
projects.db 不存在 退化为 auto repo 归属(用 git_repo_root),不报错
当前会话无 cwd 且无 git_repo_root {"success": true, "results": [], "message": "No project context (no working directory recorded)", "sessions_in_scope": 0}
归属匹配到 0 个其他会话 {"success": true, "results": [], "message": "No other sessions in this project", "sessions_in_scope": 1}
FTS5 查询语法错误 回退到 LIKE 查询,不报错
embedding 未启用 semantic_status: "disabled",关键词结果正常返回
embedding 未确认 semantic_status: "consent_required",关键词结果正常返回
embedding 服务不可用 semantic_status: "unavailable",关键词结果正常返回
移动目标项目不存在 {"success": false, "error": "project not found: p_xxx"}
移动目标路径不存在 {"success": false, "error": "project folder not found: /path"}

八、与核心的交互边界

插件做的 插件不做的
读 state.db(SELECT only,除 update_session_cwd) 写 messages 表、改 state.db schema
读 projects.db(SELECT only) 写 projects.db
调用 SessionDB.update_session_cwd(核心公共接口) 直接 UPDATE sessions 表
自己的 embeddings.db 改核心任何文件
注册两个工具到 project toolset 修改 session_search 或 inline executor

九、实施顺序

步骤 内容 验证
B0 验证 state.db 只读访问 + projects.db 读取 + update_session_cwd 可调用 临时 HERMES_HOME 下跑通
P1 attribution.py 归属解析纯函数 + 单测 显式/自动/Home 三态 + Windows 路径
P2 project_history_search 关键词路径 + 注册 P/Q 隔离、Home 空、排除当前会话、溯源链接
P3 project_session_move + update_session_cwd 集成 移动后新检索用新归属、cwd 已切换
P4 embeddings.db + 语义路径 + RRF 混合 同义词命中、关键词保底、移动不重算向量
P5 隐私确认链路 未确认不发送、确认后自主检索

十、不做的事

  • 不改核心 session_search、inline executor、SessionDB schema、project_tree.py。
  • 不做项目摘要、会话摘要、自动上下文注入。
  • 不做旧会话自动归类。
  • 不做权限撤销协议、版本表、跨 profile 联合检索。
  • 不动全局 MEMORY/USER。

十一、评审自审

  • V1 的 workspace.move 错误已修正: 插件不调 JSON-RPC,改用核心公共接口 update_session_cwd。
  • 归属 = cwd 的函数: 不加列、不改 schema,核心零改动。归属随 cwd 变化是接受的行为。
  • 只读约束: 除 update_session_cwd 外,插件对 state.db 只有 SELECT。
  • 执行水准: 工具 schema、归属输出形状、SQL WHERE 子句、失败模式、check_fn 全部定死。
  • 诚实边界: 语义检索的 chunk_size/overlap/top_k/RRF-k 是起始值,P4 实测后可能调整;embedding 模型选型待 B0 确认。