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

376 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 项目历史召回插件设计方案 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 归属解析函数
```python
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 生成
```python
def scope_sql(identity: dict) -> tuple[str, tuple]:
"""返回 (WHERE 片段, 参数)。用于 JOIN sessions 时的项目过滤。
explicit: 多 folder OR 条件
auto: git_repo_root 精确匹配
home: 无过滤(返回空结果,由调用方处理)
"""
```
显式项目的 WHERE(每个 folder 生成 4 个条件):
```sql
-- 每个 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 关键词路径
```sql
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:
```sql
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 归属会话集合
```python
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`
```sql
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)
```python
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 条消息作为上下文:
```sql
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)
```python
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`
```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
```python
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)
```python
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
```python
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
```python
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 桶会话无法检索是已知限制。