376 lines
13 KiB
Markdown
376 lines
13 KiB
Markdown
# 项目历史召回插件设计方案 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 桶会话无法检索是已知限制。
|