15 KiB
项目历史召回插件设计方案 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 的语义,插件内重新实现):
- 用
session_row["cwd"]和session_row["git_repo_root"]匹配 projects 的 folders(最长祖先匹配,路径规范化处理 Windows casefold)。 - 显式命中 →
{"kind": "explicit", "id": ..., "name": ...}。 - 无显式命中但有 git_repo_root →
{"kind": "auto", "key": path_key(git_repo_root)}。 - 两者都无 →
{"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 逻辑:
project_id为空 → 移到 Home:调用update_session_cwd设为~(或terminal.cwd配置)。project_id非空 → 从 projects.db 读目标项目的 primary_path → git 探测该路径 →update_session_cwd。- 返回
{"success": true, "project": {...}, "cwd": "...", "message": "Moved to project X, cwd now ..."}。 - 失败(项目不存在、路径不存在、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)
);
索引构建:
- 归属解析出项目会话集合。
- 读这些会话的 user/assistant 消息(
active = 1)。 - 按消息切片(chunk_size=512 tokens, overlap=64 tokens,起始值,P4 实测调整)。
- 计算 content_hash(SHA-256),跳过已有且未变的 chunk。
- 调用 embedding 模型,存向量。
检索:
- 归属解析 → 项目会话 ID 集合。
- 从 embeddings.db 读该集合内所有 chunk 的向量。
- 计算查询向量与每个 chunk 的余弦相似度。
- 取 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)。
隐私确认链路:
enabled: false时mode=hybrid或mode=semantic返回consent_required,Agent 在对话中说明。- 用户确认后 Agent 调用配置命令写入
enabled: true+ 服务信息。 - 授权记录存
~/.hermes/plugins/project-history-recall/consent.json:{profile, projects, provider, endpoint, model, confirmed_at, scope: "history+incremental"}。 - 换 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 确认。