13 KiB
项目历史召回插件设计方案 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"}
"""
规则:
- 显式项目匹配: 用 session 的
cwd和git_repo_root分别与每个项目的folders做最长祖先匹配。路径规范化:去尾部/,Windows casefold。取匹配深度最深的项目。 - 自动 repo: 无显式命中但有
git_repo_root→{"kind": "auto", "key": path_key(root), "root": root}。 - 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 批量 |
索引流程:
get_project_sessions()得到项目会话集合。- 对每个会话,读
active=1的 user/assistant 消息。 - 每条消息按 chunk_size 切片,计算 content_hash。
- 跳过已有且 hash 未变的 chunk。
- 批量调用 embedding API,存向量。
- 返回索引统计(新增/跳过/失败数)。
检索流程:
- 归属解析 → 项目会话 ID 集合。
- 查询 embedding API 得到查询向量。
- 从 embeddings.db 读该集合内所有 chunk(按 session_id IN 过滤)。
- 计算余弦相似度(numpy 或纯 Python,B0 决定)。
- 取 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。"""
逻辑:
project_id为空 → 移到 Home:cwd 设为~。- 非空 → 从 projects.db 读目标项目的
primary_path。 - git 探测:
git_probe.branch(path)+git_probe.common_repo_root(path)。 - 调用
SessionDB.update_session_cwd(session_id, path, branch, root, replace_git_meta=True)。 - 返回
{"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
}]
}
确认流程:
mode=semantic或mode=hybrid且 embedding 未配置 → 返回semantic_status: "consent_required"。- Agent 在对话中说明服务方、发送内容、项目范围。
- 用户确认后,Agent 写入 consent.json + 配置 embedding 参数。
- 后续同 provider+endpoint+项目范围不重复确认。
- 换 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
project_history_search
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 桶会话无法检索是已知限制。